第9章 如何有效设计接口框架
这一章将以PhalApi为立足点,却又超越PhalApi这一具体成例,讲述如何设计接口框架。
9.1 正统
任何一个开源框架,或者说任何一个负责任、正式的开源框架,它都不是一时兴起,或者是随意而为的。如果真的打算设计一个接口框架,至少要把它摆上台面,并按照一定的流程规范、遵循既有的约定和惯例。这意味着不仅需要投入大量时间长期维护,还需要花费心思和精力、秉持着严谨的态度,以及团队或社区的参与和支持。只有这样,一个接口框架才能称得上正统。而一个正统的框架,更是值得我们为之努力,为之投入。
正统是美好的,但美好的事情,在实现过程中难免会困难重重,正如毛主席所说的“人间正道是沧桑”。所以,要做好思想准备,因为很可能你将会开始“新的长征”……
9.1.1 定位
研发一个框架和研发一个产品,有点类似但又有所区别。相同的是它们都有特定的目标人群,定位和价值观。其中就定位而言,在准备着手研发接口框架之前,事先确定好接口框架的应用领域是非常重要的。最好是能用一句话来高度概括,如PhalApi最初以及到现在的定位都是:致力于快速开发接口服务。
如果再细分一下,可以加上前置约束条件,如使用在哪些范畴。例如:
“PhalApi是一个PHP轻量级开源接口框架,致力于快速开发接口服务。”
9.1.2 正名
孔子曾有云:“名不正,则言不顺;言不顺,则事不成”。由此可见,在我国很早之前就已经知道命名的重用性。立身必先正其名,对如今研发一个接口框架同样是不可轻视的。通过一个名字,可以向外界传递的信息,包括有意识的、无意识的以及潜意识的,都是非常丰富且影响微妙。
所起的名字应该能起到自我解释的作用,即能清晰地说明,或者让人明白此框架是属于PHP实现的接口框架,这样既可以方便记忆,容易理解,又能提高搜索命中率。
PhalApi的名字,后半部分为“Api”,即表示此框架是专注于接口服务领域的。以字母P开头,让熟悉PHP的技术开发人员能都容易记得住。而前缀“Phal”则是参考了著名优秀的开源框架Phalcon的名称,这里使用了相同的前缀并不是因为想像Java那样想借Phalcon的名气,而是抱着一种向Phalcon学习、以它为师的态度。
PhalApi这一书面语,虽然显得正式、容易让人理解,但是不好发音。看似优雅,实际在口头沟通上不好表达,因为它本身就是一个视觉型的名字。由此就需要更为简单的发音、能够传递PhalApi框架的精神和文化的中文名——π框架。
取中文名为“π框架”,其含义有:
- π取自派发音(pai),容易记忆,与框架名字谐音
- π是无理数,无限循环,符合PhalApi不断更新、保持生气的初衷
- π是圆周率,代表着我们中国的历史文化
- π是一个数学符号,而数据与计算机编程又有着不可分割的微妙联系
- π也是一个可以继续精化的数字,体现着PhalApi希望能够敢为人先,专注接口,汇众之长,自成一派!
框架的发音,以一个发音为优,其次为两个发音,最后是三个发音。尽量避免使用更多发音的名字。如我们熟悉的主流框架,它们的名字,如果细细品读,都是在三个发音以内,例如:Yii、Slim、Laravel、Symfony。
9.2 在开始之前
人类一思考,上帝就发笑。
想好了框架的名字,明确好框架的定位好,先不要着急敲打键盘编写第一行代码。正如在开始写作之前,先不要着急动笔写第一句话。应该安静下来,作为框架的设计者和创始人,认真、全面、深入地思考几个问题:最终用户会(或者说期望)如何使用这个框架?开发人员将会基于此框架进行怎样的开发?还有没有什么方面暂时没想到的……
随着你思考得越多,你就会发现得越多,自然而然就会收获得更多。
9.2.1 揣测它的使用
内在物质
揣测框架最终是如何被使用的,是非常有趣的。不同的使用方式,带来的效果不同,给技术人员的开发体验也不尽相同。不管怎样,框架的使用应该保持简单,并尽可能适用于任何场景。那怎么做到简单却又能应用于任何场景呢?让我们结合PhalApi的示例代码细细解读一下。
抛开初始化和其他操作不算,PhalApi的使用只需要三行代码即可,如下所示:
$api = new PhalApi();
$rs = $api->response();
$rs->output();
但这三行代码,背后所隐藏的信息量非常大。如果不细细品读,很可能会不轻易错过很多重要的信息。这三行代码如此重要,甚至值得我们逐行分析。
第一行代码,创建了一个PhalApi实例,这是很多框架常见的手法。即通过与框架名字相同的类来启动或者创建实例。并且最好的情况是,此时不需要任何参数,或者至少不需要任何必须的参数。这有利于减少初始化的成本,降低初学者的心理压力。通常对于不需要参数的案例,新手都会在潜意识地告诉自己:啊哈!这个框架使用很简单!
$api = new PhalApi();
创建好框架实例后,下一步自然就是使用它,重点在于如何使用。按理说,使用应该也是一行代码就能完成是最好的。因为我(相信很多开发人员都)很喜欢创建好一个实例后,简单启动一下它就能帮我完成剩下全部其他的事情。试问,到现在,如果不看任何帮助资料,还有多少人记得如何创建一个TCP的链接?
如何不能简单地一步做到,那就需要有充分的理由。显然,PhalApi这里的使用分为了两步。为什么?理由很简单,也很充分,因为需要应用于更广阔的领域。这两步将接口的响应执行与结果的输出进行分离,首先是响应执行:
$rs = $api->response();
其次是结果的输出:
$rs->output();
虽然是三行代码,但它们之前的联系是非常紧密,并且依赖顺序明显。因为前面一行代码的结果,是下一行代码的操作对象。除了执行顺序明显外,更重要的是通过将执行响应与结果输出分离,可以适用于更多的场景,除HTTP外更多其他的协议。例如,当需要进行单元测试时,获得结果后并不需要输出,而是直接对返回的结果对象数据进行验证即可,也更为方便。
下面是一个测试用例的代码片段,在执行完接口响应后,获取了返回的结果,然后断言其返回的ret状态码是否为200。
class PhpUnderControl_PhalApi_Test extends PHPUnit_Framework_TestCase
{
public function testResponse()
{
$api = new PhalApi();
$rs = $api->response();
$res = $rs->getResult();
$this->assertEquals(200, $res['ret']);
}
}
如果在Task计划任务扩展中寻找此PhalApi入口类的使用,也可以发现这种分离的方式,还可以包装在特定的调度系统中。相关的代码片段是:
class Task_Runner_Local extends Task_Runner {
protected function youGo($service, $params) {
// ... ...
$phalapi = new PhalApi();
$rs = $phalapi->response();
$apiRs = $rs->getResult();
if ($apiRs['ret'] != 200) {
// ... ...
return FALSE;
}
return TRUE;
}
}
而有些协议,如PHPRPC协议,是不需要直接输出结果的,如果过早强制输出,则会过早约束不必要的条件。可以说,PhalApi::response()
是一个释意接口,也是符合单一职责原则的。我只负责响应,把结果返回给你,至少如何处理结果,你看着办。例如,在使用PHPRPC协议进行通信时,如果不需要直接输出结果,可以省略最后一步,即化简为只有两步:
$server = new PHPRPC_Lite();
$server->response();
你永远不会知道,用户最终是如何使用接口框架的,但是可以肯定的是,通过关注点分离,可以更大限度适用于不同的场景。从上面的例子可以看出,PhalApi除了可以正常用于接口请求外,还可以用于单元测试,也可以被调度系统如计划任务再封装,当然也可以切换到其他协议。这是一个很棒的设计!因为它灵活、优雅。而不像某些框架,得到结果后直接echo输出,然后直接die掉,连一点机会也不给你。
外在功用
前面介绍的是框架自身的特点,由内而外,很大程度上决定了它对外所能提供的功能。但在设计一个接口框架时,除了要考虑自身的因素外,还要深入思考它如何与外部的异构系统进行交互,这才是最为关键,也是接口框架的价值所在。
再试想一下,这时的客户不再是PHP后端技术开发人员,而是Android,或者iOS、Windowns Phone等其他应用开发人员,那么他们如何知道有哪些接口服务,又该如何发起请求、处理结果呢?当成功时,接口服务应该返回客户端所需要的业务数据,并且应以一种向前兼容支持扩展的方式提供。当失败时,应该能明确告诉客户端为什么出错,原因是什么。在这些系统对接中,需要制定的是一种规范,一种语义,以便消除服务端接口与客户端应用之间的沟通鸿沟。
这时,明显有两种途径可以做到服务端与客户端之间进行有效的信息传递。一种是文档,一种是结果本身。文档要考虑的是自动生成,还是人工编写,还是自动生成再人工编写?大家都知道,维护文档是有成本的,并且随着代码的不断演变,文档很容易过时,从而因信息失去时效性而产生误导。因此,比较好的建议是能让文档支持自动生成,但又不需要开发人员手动过多维护。最为重要的是,即便是需要手动维护,也应该是维护描述元数据,而不是维护自动生成后的文档内容。因为,历史上从来都不建议人工修改自动生成的内容。那怎么更好地做到自动生成文档呢?这需要你花一点时间,静静地思考……
除了文档,另一个有效的途径,也是最为重要、必不可少的途径就是结果本身。因为结果本身就是具有自我说明性的,同时也承载着客户端所需要的数据信息。在设计接口的返回格式时,要特别注意对于异常情况的处理和提示。
如果没有什么头绪,可以参考一下一些主流平台的做法。对于接口的异常处理,在使用HTTP协议下,可以通过HTTP本身的响应状态码来进行区分。 在非HTTP协议并有SDK包的情况下,异常的处理手段则会更为多样。
优酷接口采用了HTTP响应状态码加结果返回的形式,如:
Request URL:https://openapi.youku.com/v2/videos/show_basic.json
Request Method:GET
Status Code:400 Bad Request
{"error":{"code":1004,"type":"SystemException","description":"Client id null"}}
新浪微博也一样:
Request URL:https://api.weibo.com/2/statuses/mentions/ids.json
Request Method:GET
Status Code:403 Forbidden
{"error":"auth by Null spi!","error_code":21301,"request":"/2/statuses/mentions/ids.json"}
微信接口则采用了统一200的形式,如:
Request URL:https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=
Request Method:GET
Status Code:200 OK
{"errcode":41002,"errmsg":"appid missing"}
比较不幸的是,对于接口服务的规范和语义,现在社区暂时还没形成统一的标准,这就导致了客户端接入了A接口系统后,当需要再次接入另一个B接口系统,又需要重新开发一次,完成对接的工作。因为很可能,不同的接口系统,它们制定的格式不一样,签名方式不一样,使用的协议也不尽相同。
哪一种格式更好呢?需要时间来证明。
9.2.2 如何用于开发?
揣测接口框架最终将如何被使用,是开发完成后所体现的成效。但在这之前,技术开发人员还有完成一系列的编码开发、测试联调、Bug修复等一系列操作。如何开发一个接口服务,很大程度上取决于接口框架如何设计、如何引导以及如何约束。不同的开发过程,不仅会带来不同的开发体验,还会最终塑造不同的接口形态。
回归到本质的问题,当别人在使用你设计的接口框架进行开发时,你是希望他们顺势而下,还是逆流而上?顺势而下即自顶而下的方式,先最外层或最顶层,再到最底层;逆流而上则是自底而上的方式。还是想两者皆可得?
不管是自顶而下,还是自底而上,都需要一种架构分层模式来分离关注点,以便将高层的调度决策与底层的实现细节分开。这时,则需要引入合适的分层模式,将日后将会产生的大量应用层代码进行垂直或水平划分。同时,分层模式还应能为大众所熟悉,以降低学习成本以及理解的难度。MVC是很流行的分层模式,但也是被误用、误学得最多的分层模式。
此外,接口框架还应帮助开发人员管理系统所面对的领域复杂性以及技术复杂性,并且将这两者明显区别开来,从而保障领域业务概念的完整性得以充分测试,基础设施部分可以脱离业务逻辑最大程度上实现重用。这就是Evans在《领域驱动设计》一书中重点讲述的内容。
在现代软件开发中,一个不可忽略的最佳实践是测试驱动开发。哪怕作为接口框架的设计者,你可能不会编写单元测试,但至少你所提供的接口框架应该能支持别的开发人员对他们所编写的应用层代码进行单元测试,并做到友好支持。
所以,开发人员在下载我们的接口框架后,如何进行开发,很大程度上取决于我们当初是如何设计接口框架的。在本质上,我们应探寻一种符合接口服务系统领域本身,恰如其分的设计和规范,并通过时间和实践反复验证、调整。这是一个开放式的话题。但有一些原则是值得去遵循的。其中有一个原则就是易用性。可以这么来理解框架的易用性,如果一个普通的开发人员,在前面学习了文档并曾经已经做过功能开发,当需要再次开发一个类似的功能时,他是否能在不看文档、不复制已有代码的前提下,快速、自然而然、不需要陷入回忆痛苦就能完成?如果是,那么你的接口框架设计得非常优秀。如果不是,或者说甚至乎连自己都要反复查阅文档才能完成新的功能开发,那么接口框架的设计还有待提高。
通过提前设想将来时态的开发情况,从而反推现在接口框架的设计是件很有意思的事情。因为你还需要设想开发人员在开发过程中还会遇到哪些问题,以及作为接口框架的设计者,你又该如何向他们进行解释呢?
9.2.3 异常,扩展与不确定性
一人追求完美,活得很累。禅师对他说:“这世界是一半一半的。天一半地一半;男一半女一半;善一半恶一半;清净一半浊秽一半。很可惜,你拥有的是不全的世界。”“为什么?”“你要求完美,不能接受残缺的一半,所以你拥有的是不全的世界,毫无圆满可言。”“学会包容,就会拥有一个完整的世界。”
对于设计开发框架也一样,要学会包容,才能成为一个完整的框架。包容指除了能处理正常的场景外,还应能兼容异常的情况、轻松吸纳第三方扩展类库,并拥有对未来不确定性的开放性。
处理异常的艺术
异常的处理是一种艺术,应以一种优雅的方式来统筹,而不应放而任之,更不应该将其视为“恶魔之源”而排斥之。曾经有一个更有趣的轶事,与我共事的一位同事,他强烈反对在PHP代码中过多使用异常抛出。当我追问为什么时,他的回答竟然是过多使用异常会影响正常情况下的运行性能。我说不会,只有当异常抛出时才会触发异常调用栈的响应机制,而在正常情况下是丝毫不会影响性能的。他依然坚持否认这个观点。无奈之下,我写了一段简单的测试代码来对比,并通过实际的效果向他证明了事实的真相。最终他也承认是之前认识上有所偏差。
通过这个身边的小故事,也许很多人会反对或者潜意识里排斥异常,可能是因为他们觉得会对性能造成影响,又或者他们因为异常一抛出就会导致程序终止运行。无论如何,他们都不喜欢异常。但这些认知都是不对的,恰恰相反,异常机制是一种传递错误的信号,不仅能减少代码嵌套的复杂度,如果能在高层上统一异常的处理机制,还能形成优雅的框架设计。
细想一下,当使用框架进行开发时,会遇到哪些异常呢?最为明显的是,因底层系统或者基础服务而导致的错误,如:数据库连接不上、CURL时网络不通、系统文件无写入权限。其次,还有开发人员在应用层自行设计抛出的异常。不容易识别的是,当开发人员未按照框架所设计的规范开发时,又该进行友好的错误提示,以便开发人员能及时、清晰地定位到问题。因此,异常情况可简单归为三类:
- 底层异常:因底层不可抗因素而导致的失败。
- 框架异常:因不符合框架规范时的错误。
- 应用异常:应用层自身的异常体系。
那么,对于异常的处理方案又有哪些呢?PHP一开始是面向过程式的,后来才慢慢支持面向对象编程,注意是支持而不是强制。由于PHP语言的历史原因,它提供了很多面向过程式的函数,在这种情况下,处理异常的方式通常是静默式的,即不会显式抛出异常,而是通过返回一个状态码来表示成功与否。这也就是为什么PHP会提供那么多错误码以及获取某个操作后错误的函数。例如,常见的CURL操作时失败的判断:
<?php
// Create a curl handle to a non-existing location
$ch = curl_init('http://404.php.net/');
// Execute
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
// Check if any error occurred
if(curl_errno($ch))
{
echo 'Curl error: ' . curl_error($ch);
}
// Close handle
curl_close($ch);
上面示例引自PHP官方文档的PHP: curl_errno - Manual。
在PHP中处理异常的另一种方式则是采用面向对象的异常机制。考虑到前面归结的三类异常情况,在设计异常体系时,也应进行分门别类,以便建立起不同场景下不同的处理方式。不难推断出,这三类异常是呈包含关系的。以PhalApi框架为例,底层异常是Exception,框架异常是PhalApi_Exception(Exception的子类),应用层的异常则应该是PhalApi_Exception的子类。即:
<?php
// 框架异常
class PhalApi_Exception extends Exception{ }
// 应用异常
class App_Exception extends PhalApi_Exception { }
除非有特殊原因或者历史回退,不然在设计框架时都应采用面向对象的异常体系。这就需要我们把底层异常转换成框架异常,避免同时混用静默式错误码和显式异常抛出。并且,当应用层异常尚未处理时,也可以将由框架统一管控。这是一种归约式、向框架靠拢的设计,因为过底的底层错误将提升到框架级别,过高的应用层异常也会回退到框架级别。而当以这种一致的形式约定错误时,便可在框架级别进行统一的处理、响应以及监控。
扩展能力
无论如何努力,一个不可否认的事实就是,框架自身不可能、也没办法提供任何应用系统所需要的全部功能。就以缓存来例,实现缓存的机制从客户端到本地服务器、再到远程服务器,就有多种多样,包括但不限于:COOKIE缓存、SESSION缓存、文件缓存、APCU缓存、Redis缓存、Memcache缓存、数据库缓存。提供过多的具体功能,会导致自身开发、维护成本巨高,同时也会增加新手学习框架的成本。但如果提供过少的功能,又会面临这样的尴尬,即没人使用。如何在多与少之间权衡,是一个问题。
但是有一个很值得参考借鉴的原则是:“针对接口编程,而不是针对实现编程”。
框架应该在高层上约定接口的规范,然后提供途径给应用系统开发、定制、扩展的能力。从微观角度上看,应用系统可以重载框架的某个核心类,做到不影响框架原有的功能,从而完成自己定制化的项目需求。从宏观角度上看,应用系统甚至可以将一个组件、一个扩展包、一个封装好的工具集成到框架,并能与框架基础设施层良好共同工作而不会相互排斥,从而完成更大的任务。
如果,这些扩展的类、组件、包和工具,能在不同的项目之间流通、重用,那么将是一件很棒的事情!小到个人项目,大到在整个软件开发领域内,不管是商业公司的闭源内部,还是全球的开源社区。
不确定性
很好,现在我们的框架已经拥有了异常处理的机制,也拥有了强大的扩展能力,接下来,我们还需要什么呢?
加厚软件工程过去的这几十年发展,有很多设计已被淘汰,有些设计则逐渐显露了一些弊端,但也有一些虽然过去了十几年直至现在乃至未来很长一段时间内仍然适用。IPv4已经开始显得不够用,需要使用IPv6来弥补。但在2017的今天纵使用最新的iPhone智能手机也能访问90年代的网站。
对于开发框架呢?即使不考虑数十年后的未来,在日新月异的当下,到了明年、后年是否仍能跟得上技术的浪潮而不被淘汰?就PHP开发框架而言,受到影响的可以是PHP语言本身的升级,如PHP 7的出现;可以是开源社区的主流做法,如composer的依赖包管理(截至编写此电子书时,PhalApi已推出支持composer的PhalApi 2.x 全新版本);还可以是市场方面的需要,如过去十年是互联网的时代,需要频繁建站,则现在更多是移动时代,需要提供各种接口服务。
这是一个需要不断演进的过程。你需要做好充分准备,不然一不留神,很可能就被抛在浪潮之后了。:)
9.2.4 忠于自己
中世纪时的画家,如果他只是临摹某一位大师的作品,到最后他也只是一位普通的画家,画出来的也只是普通的作品。若换种方式,他把历史上全部某个派别大师的作品、思想都研究、学习一遍,在融入自己的见解和独特思考后,再来画面,所产出的将是完全不一样的作品,他本人也很有可能成为一代大师。
开发框架也一样,不要急于开始,而应做好前期的大量准备。多看下,多学习,多研究下现在的开源框架,去摸索它们内部深层的设计与理念,了解其优势所在。与此同时,还要明白过去框架的痛点、不足与缺陷。看框架,不能只看表面的官方说明,还要深入到框架源代码本身。通过源代码可以折射出很多内在的、不轻易改变的特质。
例如,曾经对Lavarel框架的研究,当时对它整理的静态类结构图如下:
图9-1 整理的Lavarel静态类结构图
也许是我组织方式的问题,但从上图看出Lavarel框架还是很疯狂的。因为它:
- 大量全是静态方法的工具类(Utility),除非逼不得已(需要多态)时才会用实例对象
- 存在循环依赖,且不符合:类应该向稳定方向依赖的原则
- 没有很好的封装性,很多类成员属性都是public级别
- 回调函数的大量使用
另一方面,Lavarel也有着很多优秀、值得学习的地方。例如:
- 代码风格良好,有使用代码示例
- 配置使用了文件媒介,而且使用了点连接的路径表示法
- 定义了一些简短的函数
- 自动加载策略:别名->映射->空间目录->PSR加载(下划线分割)
最后,对于当时的我,学到的额外知识还有:
- 使用了命名空间
- __callStatic 静态级别的魔法方法
- 值得参考学习的PHP用法:
return new static($view, $data);
- 随机字符串生成:
substr(str_shuffle(str_repeat('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 5)), 0, $len);
相比之下,再来看下,我们经常用到的PHPUnit,它的内部设计又如何呢? 先来看一张整体的UML静态类结构图。
图9-2 整理的PHPUnit类静态类结构图
明显可以看到,它是清晰、层次分明、容易理解的。进一步,再来看下它的异常体系。
图9-3 PHPUnit的异常体系
同样也是不言而喻的,如果觉得异常体系本身就是简单的话,那么可以再来看下PHPUnit核心的比较器部分的结构。
图9-4 PHPUnit的比较器
通过这么多静态结构图,主要是为了说明,好的框架设计是经得起推敲的,值得学习,并且是耐人寻味的。
一方面,要深入框架内部研究它的特质,另一方面,也要跳出代码细节这个盒子,去研究不同开源框架在何种背景下被提出,它们的理念和聚集解决的问题又是什么。
开发框架要忠于自己,首先要考虑的是问题领域和解空间,自己本身的内在设计。其次要考虑的是如何整理开发文档,向广大开发人员阐明如何使用。最后要考虑的才是如何提升框架的欢迎成度。作为一个开源框架,不应过多考虑的是如何盈利。
9.3 吾有框架初成形
在做了那么多前期铺垫后,现在终于可以进行框架编码部分了。但纵使进入了编码环节,也还是有很多内容需要注意,并且需要知悉的。这些内容就有对SOLID原则的理解,稳定依赖的管理,协作与交流,还有不同编程范式的对比。
9.3.1 SOLID原则与稳定依赖
在设计、研发框架时,一个重要的指导原则是SOLID原则。老牛重弹一下,SOLID原则分别是指:
- SRP 单一职责原则
- OCP 开放封闭原则
- LSP 里氏替换原则
- ISP 接口隔离原则
- DIP 依赖倒置原则
SRP 单一职责原则很容易理解,但怎么强调也不过分,因为现实开发中总会有人在不知不觉、或者宁愿偷懒在已有的类添加一个新方法也不愿意再创建一个文件来放置一个新的单独类。遵循这个原则很简单,但讽刺的是,也是违反最多的。为什么明明知道耦合了过多角色的功能,而不去抽取分离,雪上加霜呢?也许归结为信仰问题,或者说专业与否的问题。无论如何,都坚持短而美的写法, 致力于编写优雅的代码、编写人容易理解的代码 。
OCP 开放封闭原则可以理解成“即插即用”,添加扩展一个新的功能,不应该改动原有的处理方式。另一方面,如果需要修改已有的功能,应该提供途径给别人进行定制、重载、二次开发。
DIP 依赖倒置原则,则是一个需要仔细琢磨的原则。通过PhalApi,也可体现这一原则的应用。PhalApi框架,最大的特色莫过于它提供了一种如何快速进行接口开发的机制,但不强制你使用不必要的功能,甚至还鼓励你通过它来尝试研发自己的框架 。说得通俗一点就是,在开发应用层系统时,可以在不修改框架源代码的情况下,完成所需的定制化操作。控制权开放在应用层,而非限制在框架底层。
LSP 里氏替换原则有点微妙,在PHP框架中应用较少,但也要注意。而ISP 接口隔离原则利于把不同概念的接口规范进行划分,以便更灵活的组合。
综合应用这些原则,并在复杂的设计中进行权衡,离产出优秀框架就不远了。如果再加上稳定依赖管理就完善了。从框架调用的入口开始,到内部不同功能模块的调用,再到底层的触发调度,这些依赖关系都应遵循“高层依赖于底层,并朝更稳定的方向进行依赖”。底层不能反过来,即不能依赖于高层,如果确实需要,则可以通过回调函数,又或者订阅/发布者这样的设计模式来实现。
9.3.2 协作与交流
曾经有位设计了一个内部框架的同事跟我说燕深信“框架最重要的部分是路由”。对于这一点,我觉得比较片面。很明显,对于一个命令行的框架,路由很可能就不是必须的。但作为一个软件系统,特别是框架,我觉得最需要注重的是内部及至和外部的协作与交流。
先来看几段简单的代码。
<?php
echo Config::get('version');
上面的代码不难理解,其作用是用于读取version这个配置。实现方式是调用了类静态方法Config::get()
。这是一种硬编码的方式,也是一种僵硬的设计。不管Config是何种实现,最终都限制了只能采用某一种特定的实现。如果需要改用其他的方式,则会变得非常困难,甚至不可能。因为Config类已限制了一切。
再来看下,同样的实现效果,另一种编码方式。
<?php
$cfg = new Config();
echo $cfg->get('version');
这种方式,稍微好一点。虽然限制了创建时的类名,但如何创建与使用分离的话,在应用层还是可以通过替换前面的实例,来完成定制化需求的。不幸的是,在框架底层,由于代码封装在内部,是难以调整的,除非修改框架源代码。还有一点,这样使用一次创建一次也是不合理的,应该结合单例模式,减少不必要的重复创建工作。
这样不行,那样也不行,那应该怎么办呢?很简单,应该建立一个容器,进行统一管理,不仅管理数量,同时管理是何种实现。通过唯一的标识进行服务查找,然后再使用。一如:
<?php
echo Container::lookup('config')->get('version');
这样,可以完成高效的协作与交流。在框架内部,可以轻松与日记、配置、缓存这些进行协作,而不用关心它们是如何实现、又是如何创建的。设计精良的汽车,内部的各个部分都是基于协作的,使用方向盘和刹车时,肯定不需要去考虑转向灯是如何制作的。只要知道怎么使用它们就好!不管是进口的德国制造,还是国产的零件,最后都能很好的一起工作。
设计框架也应该这样,框架更像是一个设计精良的架构,可以容纳符合接口规范的不同实现,通过清晰明了的协作方式,完成出色的任务。
9.3.3 论编程范式
学习不同的开发语言,不同的编程范式是件很在趣的事。你可以从其他语言身上洞悉到别样的理念。从最初直白的面向过程编程,到应对复杂现实模型的面向对象编程,再到现在崭露头角的函数式编程、面向切面编程和元编程,随着技术的发展,伴随的是思想、理念的转变。
设计接口框架,在这里的上下文是,如何在PHP语言内,采用面向对象编程方式设计一个接口框架。最终设计出来的框架,很大程度上取决于我们对面向对象编程的理解与掌握。我们经常不离口的说词是,“我们正在面向对象编程”。然而,对于OOP的本质,以及它所涉及的知识、专业术语、以及理论基础我们是否均已能历历在目,并能恰当使用?如是否能有效地进行UML建模,是否能恰如其分地采用设计模式,是否能很好地组织不同的角色进行良好的协作?
除了面向对象编程世界内的知识,还可以借鉴其他世界的理念。在函数式编程世界里,值不变性可以称得上是一等公民,而在元编程中则有代码生成代码的理念,这些在某种程度上也可应用在面向对象编程世界中。
9.4 精雕细琢
框架的绌形出来后,还不能作为成品公布于众。在确保框架具备可测试性、优越的性能以及高质量的代码。这些并不是在完成框架开发后再来考虑的问题,而是应该在框架开发过程中就要时刻惦记着。如果忽略了或者不能兼顾,至少要在框架发布出去前确保这些都得到了保证。
可测试性、性能和代码质量,这些都是框架不可或缺的品质,需要精雕细琢。
9.4.1 可测试性
曾经有同事跟我说过,没有单元测试的开源框架无人敢用。对此,我颇为认同。
框架的可测试性,体现在两方面 。一方面是框架本身的单元测试,要建立完善、自化动、具备自我验收能力的测试体系。这样可以在框架迭代、Bug修复以及维护过程中提供测试支持,便于尽早发现自身存在的不足和问题。这在分布式开发中是非常有帮助的,它可以为不同地方的开发人员提供快速验证的能力和快速反馈的机制。一旦某处框架的改动,不能符合预期的效果,或者对既有的功能造成了影响,就可以通过专业的方式进行播报。编写单元测试还有一个隐藏好处是,可以帮助新手快速熟悉框架的使用。因为每一个测试用例都是一个很好的使用示例。
另一方面。框架还应考虑到如何为应用层提供可测试性的能力。也就是说,自己要能测试,还要让别人可测试。可不能光顾自己,而不管他人。
那如何让框架具备可测试性?或者,采用逆向思维,哪些行为会导致框架不具备可测试性,也许很容易让人理解。以下这些做法应该避免。
避免:使用die()/exit()直接退出
有不少经验不足的框架,为贪图方便,会直接使用die()/exit()终止程序。例如,最为常见的,当数据库连接失败时,直接die掉。
<?php
$link = mysql_connect('localhost', 'mysql_user', 'mysql_password');
if (!$link) {
die('Could not connect: ' . mysql_error());
}
上面示例引自PHP官方文档的PHP: mysql_connect - Manual。
很多人一看到这样的示例,就误以为,不管在何种情况下,只要数据库连接失败了就终止。这种误解是严重有问题的。一来,应用层也许根本不需要用到数据库;二来,当系统出现故障时,也许应用层需要进行更友好的用户提示,而不是输出一句让非技术人员摸不着头脑的提示信息。
还有一种情况就是,当框架认为程序执行完毕后就执行die()/exit()操作,以“保证”后续不会再执行其他操作。例如,在某些接口框架里,当输出结果后,会执行exit()操作。
<?php
$result = array(); // 待输出返回的结果
echo json_encode($result);
die();
这样的话,会导致在PHPUnit进行单元测试时,无法继续下去,也就无法测试了。
避免:直接使用PHP的原生函数
在编写C代码时,为了可移植性,会使用包装器模式对系统级的操作进行再封装。在编写PHP代码时,为了可测试性,也可以对PHP原生函数进行封闭,以便提供测试时的缝纫点。以header()函数为例,直接在代码片段中使用header()输出头部的话,会导致在命令行CLI模式执行单元测试时报错,因为如果输出内部后再进行header()的话,PHP会提示“Cannot modify header information - headers already sent by……”。
在开启严格的错误级别时,这样的错误提示会让PHPUnit终止后续的代码执行,从而影响测试。更好的方法是,通过提取子函数、或者建立一个简单的包装器,对这些底层的操作进行封装。
避免:过多使用过静态类方法或者私有方法
有时,在测试时,需要使用到桩件、替身、仿件对象等。但如果使用的是表态类方法,则不能通过模拟的对象来替换已有的实现,这会导致难以测试,同时也会不可避免地产生不必要的副作用。例如,当发送邮件的实现代码片段是:
<?php
Mailer::send($title, $adreess, $content);
在测试时,不能Mock的话,就会真的发一封邮件给用户,也不好模拟发送失败这样的异常场景。
类函数的访问级别可分为:public公开、protected保护和private私有这三种级别。但这么多年,我发现在PHP开发过程中,几乎尚未找到非使用private级别不可的场景。相反,为了可测试性,可将private提升到protected级别。而且,PHP所提供的访问级别,似乎也不近完美。当一个子类重载父类的private方法时,并没有语法上的错误,虽然重载的方法最终也是无效的。
<?php
class Person {
public function haveFun() {
$this->doSth();
}
private function doSth() {
echo '听听歌';
}
}
class Student extends Person {
protected function doSth() {
echo '看看书';
}
}
$person = new Student();
$person->haveFun();
例如上面这个示例中,父类Person有一个private的私有方法doSth(),子类Student重载了自己的doSth(),并将访问级别提升到protected。由于子类不能访问父类的私有方法,反过来亦然。最终Student实例对象使用的依然是父类Person的doSth()方法。如果不注意观察或者未能识别,会让某些新手的开发人员疑惑不已。
除了类方法存在这样的问题外,类的成员属性也会存在同样微妙的问题。让我们来看另外一个例子。一个看完后会怀疑人生的例子。请观察以下代码:
<?php
class Person {
private $name = '无名小卒';
public function whoami() {
echo $this->name, "\n";
}
}
class Student extends Person {
protected $name = '小学生';
public function whoamiAgain() {
echo $this->name, "\n";
}
}
$person = new Student();
$person->whoami();
$person->whoamiAgain();
如果不看答案,不直接运行代码,你能想到最终输出的答案是什么吗?这里的关键点在于子类Student有两个name属性,一个是继承于父类的private级别的name,一个是自身public级别的name。当输出名字时,由于父类的私有属性不能被子类的同名属性覆盖,因为执行whoami()方法时输出的仍然是父类的名字“无名小卒”。而在执行whoamiAgain()方法时,在当前类作用域内存在protected级别的name属性,因此输出的是“小学生”。也许这样的答案让你惊讶不已,那么下面的输出肯定会让你开始怀疑人生。
继上面的代码,打印person对象,可以看到这样的输出:
var_dump($person);
object(Student)#1 (2) {
["name":protected]=>
string(9) "小学生"
["name":"Person":private]=>
string(12) "无名小卒"
}
什么?Student的对象竟然有两个name属性?!这初初看起来是难以理解的,但实际上又是可解释,符合PHP语言规范的。但对于初学者来说,肯定是一个难以发现的坑。
基于此,为了可测试性,以及减少困惑,如无特殊情况,都应将类函数成员或属性成员访问级别从priavte提升到protected。
9.4.2 性能之巅
运行PHP的代码已经够慢了,要想让用PHP编写的框架运行得更快,最好的做法是做减法。尽量减少不必要的操作,仅当有需要以及有必要时才执行。此外,还要区分不同的操作对性能的影响。优化性能的根本原则无非是减少函数调用的次数,尤其减少耗时的操作。细节很重要。
在进行性能优化时,可以考虑以下这些点:
- 手动引入必须的源文件,避免自动加载带来的损耗
- 通过临时变量缓存频繁操作的对象,减少不必要的函数调度
- 延迟耗时的操作,例如不要总是一进来就连接数据库,只有需要时才连接
- 内联简单的操作,而不是新增一个函数
当然,性能优化的方面远不止上面这些内容。可以使用XHprof工具,进行性能的剖析,找出各自的瓶颈所在。按经验法则,一个优秀的PHP框架,在运行其默认的页面或接口服务时,总的执行时间应控制在10毫秒以内。即留给框架的时间只有10毫秒,如何充分利用这10 ms,是设计者不得不考虑的难题。
9.4.3 代码质量
一个应用项目,要统一代码规范和风格。作为一个开发框架,更要注重自身的代码质量。而这些重量不是用IDE简单的自动化对齐就能拥有,事实上我总是反对使用IDE对代码进行格式化。因为任何经典、传神的艺术品都不是出自机器,而是来自人工的打磨。
很好的代码规范是一方面,另一方面,低复杂度,“高内聚、低耦合”这些也是需要的。很多时候,你的大脑里会存在两种声音:“这就样行了”,“再调整打磨一下吧”。软件开发是一件需要投入大量时间和精力的过程。这是一个需要坚持的过程。
如果需要对代码的质量进行分析,可使用Sonar或者PHPMetric等这些工具。
9.4.4 从优秀到卓越
有时,我不禁在想,一个优秀的框架,或者说一个卓越的框架,它到底应该具备哪些品质呢?直到现在,我才慢慢发现,卓越的框架并没有太多与众的不同,相反,它是普通、平凡、为众人所熟悉的。但这些浅显易懂的,正是我们最为容易在有意识、潜意识和无意识下忽略的。
框架有多个维度,既有动的一面,也有静的一面;既有内向的一面,也有外向的一面。当把需要遵循的规范都遵循了,需要符合的标准都符合了,这时框架离优秀或卓越也就不远矣。
什么叫专业?专业就是纵使罗列出长长的标准,逐一比对,都是符合要求时,就是专业了。但不能为了符合标准而去刻意迎合,我们更需要打造的是一个有活力、有生气、能实际应用到项目开发的框架,需要的是一个能落地、接地气、贴近开发的框架。
至于怎么做到卓越,还需要我们作为设计者和开发者,慢慢思考。
9.5 有效交付
在完成了这么多工作,经历了那么多周期后,终于可以等到了框架问世的时刻。也许你会难以掩饰内心的激动,但且慢,还有最后一些工作仍待完成的。这就是——
9.5.1 帮助用户认识新框架
帮助新用户认识你的新框架,可以通过在论坛或者在开源平台上发表你的框架。向大众简短地解释你的框架可用于何种开发,它的特色是什么,以及你所提倡的理念和价值观。然后,耐心地回答他们的提问。
同时,将代码发布在Github上是一个明智的选择。这样可方便世界各地的人都能看到你的框架,以及参与维护。
9.5.2 文档
没有人会看一堆源代码来反推它是如何使用的。因此,你要为你的框架编写相应的开发文档。很多人都觉得编写文档是一件痛苦的事情,甚至无众下手。不管怎样,文档就像代码,写得越多,就越熟练了。不要害怕一开始写得蹩脚就退却,谁一开始写代码时不会有Bug的?你就当文档的错误是一个Bug,发现了,修复便是了。
一开始,也许会因为没有整体的思路而东拼西凑,文档章节和结构会略显凌乱。这些问题都不大,重要的是敢于迈出第一步,有了文档草稿,后续会慢慢再调整。
还可以在回答开发用户常见问题后,将问题总结以及新用户可能碰到的问题整理到文档中,方便后续其他用户查阅。文档维护也是一个持续的过程,当然,最好一开始就要有。
9.5.3 Hello World示例
开源框架有很多,为什么开发人员会使用这个框架,而不会使用那个框架?
这是因为开发人员经常使用的框架,通常就那么几个。一旦他们熟悉了,除非新的场景系统开发,他们通常不愿意频繁变更已经熟悉的开发框架。所以,在开发人员在尝试寻找新的框架解决新的问题时,我们就要在他们短时间的决策内,让他们喜欢上我们的框架。
这里会涉及一点心理学的内容。有研究表明,人们会对自己拥有的东西评分较好。虽然是同样一份糖果,如果它是属于你的,你的评分会更高。因此,这里的诀窍就是怎么把我们的框架变成开发用户的框架?
让他们做得事情吧!
最好的方式,新的开发用户可以通过做一些简单的事情,让他们觉得这个框架是受他们控制的,这样自然而然他们就会觉得这个框架是属于他们的了。作为程序员,最好的方式莫过于写一段代码。可以恰当地引导新的开发用户进行下载,然后通过简单的安装便可以看到运行效果,这是最能打动人的。如果能再引导新用户写一段Hello World代码,效果会更佳!
在完成这些步骤后,如果开发人员没有遇到明显的困难障碍,他们会得到一个良好的开发体验,从而爱上你的框架。
本章小节
设计接口框架涉及的方面有很多,在这一章,我们学习了接口框架的定位与命名,在开始研发之前需要注意的设计,以及在开发过程中要注意的SOLID原则、协作方式和面向对象编程范式的应用。在完成框架的开发后,还需要继续打磨,让其拥有可测试性、良好的性能以及高质量的代码。最后,还要想办法帮助新用户认识我们的新框架,把我们的框架变成他们的框架。
这本书已接近尾声,让我们休息一下。然后翻阅到下一章——