本文主要讨论的内容是接口的输入,包括接口参数验证、签名、验证、权限等安全性的话题。在这中间,我们还扩展延伸了配置优于实现这一思想的探讨。接口的输入,是客户端与服务端之间的桥梁,也是两者之间的安检环节。我们既要保证两端之间通讯的友好性,又要保证服务端不受非法请求的攻击或伤害。
1 接口参数验证
我对接口参数验证的心路
在我第一次正式实习的时候,就开始参与了接口系统的开发工作,并且在那时正式接触了接口参数这一概念。当时我发现一个非常有趣的现象,那就是接口项目代码中,到处充斥着对接口参数验证、解析、处理、原始的重复代码。而参与维护这些代码的同事,不知道是没有意识到这些代码异味,还是未能察觉接口参数验证背后的共性,对此部分的实现既不改进优化,也不总结提炼。
然而,秉持着KISS原则和DRY原则,以及出于对接口参数验证这一细小领域的兴趣,那时我就开始尝试对这部分进行思考、分析和设计。我希望自己能合理、恰当地封装接口参数的全部操作,包括判断参数是否传递、默认值的设置 、类型的转换、数据合法性的检测,甚至包括接口参数的描述与文档的结合。经过一段时间的努力,对接口参数验证的封装已初见雏形,我将它应用在日常的项目开发中,极大消除了重复的代码,同时更优雅地完成了对接口参数的操作。
当然,我也希望自己对接口参数验证的提炼也能提供或开放给更多的技术开发人员使用。他们只要简单地写一行代码,甚至不需要编写代码只需简单配置一下,就能实现他们期望的操作。而在这背后,接口参数验证会自动,且略带智能地完成剩下的事情。如果可以,我更希望这些可以成为一种规范,一种被广为接受的约定。
怀着这样的初心,从实习到毕业设计,从全职工作于各商业项目和系统的开发到正式投身开源社区,最初的接口参数验证,经过不断演进,逐步完善,现在成为了PhalApi开源接口开发框架中不可缺少的一部分。在PhalApi中提供的参数规则,友好地提供了对接口参数规则的定义、配置和描述等。通过对参数的规则配置,而不是对参数进行原始化的代码编写,明显提升了开发的效率和质量,受了众多开发人员的喜欢。除此之外,这些参数规则,还可以用于自动生成接口在线文档,一举多得。正因为如此,这些参数规则成为了PhalApi开源框架中规范和标准,甚至还有开发同学将此模块抽离应用在其他的项目和框架中。
下面,我们通过一个简单的登录接口为例,粗略感性认识一下PhalApi框架的参数规则。如果使用的是PhalApi 2.x 版本,在接口实现类中,可以使用以下配置示例对登录接口的账号和密码这两个参数进行控制。
<?php namespace App\Api; use PhalApi\Api; class User extends Api { public function getRules() { return array( 'login' => array( 'username' => array('name' => 'username', 'require' => true, 'min' => 1, 'max' => 50, 'desc' => '用户名'), 'password' => array('name' => 'password', 'require' => true, 'min' => 6, 'max' => 20, 'desc' => '密码'), ), ); } }
这里,针对/?s=User.Login接口服务,配置了两个参数,一个是账号,一个是密码。根据上面的配置,不难理解,账号和密码都是必传参数,并且账号的最小长度为1,最大长度为50;密码的长度则介于6和20之间。
class User extends Api { …… public function login() { $username = $this->username; // 账号参数 $password = $this->password; // 密码参数 // 更多其他操作…… } }
配置好参数规则后,对参数的获取就非常简单了。因为,只需要使用类属性的方式,就能获取了。正如上面看到的$this->username
和$this->password
,这些类成员属性名称与参数规则中的数组下标值对应。
若客户端传递了合法的账号和密码,相应的参数会自动填充到类成员属性。如果客户端什么参数也不提供,则接口服务会返回类似这样的错误提示:
{ "ret": 400, "data": [ ], "msg": "客户端非法请求:缺少必要参数username" }
除了参数的解析和验证外,通过参数规则,还可以指定数据来源,例如限定来自POST参数、GET参数或者COOKIE等。如果现有的参数规则类型不能满足项目的开发需要,还可以自行扩展添加所需要的参数类型。与其他开源框架一样,PhalApi的参数规则也能支持回调函数的配置与调用。概括起来,共有9种内置参数类型,分别是:
字符串 string
整型 int
浮点 float
布尔值 boolean
日期 date
数组 array
枚举 enum
文件 file
回调 callable/callback
更酷的是,框架会自动根据配置的参数规则自动生成在线文档。刚刚配置的账号和密码参数,对应自动生成的文档片段类似如下:
图1 账号与密码参数
以上,都是参数规则的外在表现,但我更想和大家分享的是背后的实现,设计和思路。可以说,整套参数规则体系的构造都是围绕着重用性、易用性和友好性开展的。从大到小,自顶而下,关键的环节扼要总结有:
1、对参数规则的入口调度
2、多重参数规则的合并
3、根据规则获取参数
4、统一格式化操作
5、具体格式化实现类
首先,我们要在框架的核心执行过程的合适位置,添加对参数规则的入口调度。这个时机是在接口类基类PhalApi\Api::createMemberValue()方法内,它要完成的任务是,根据配置的参数规则把客户端传递的参数转换成接口类的成员属性。
图2 PhalApi 2.x的核心时序图
打开PhalApi 2.x的源代码,可以看到这里的实现只是一个foreach循环,这是概念视角的高度抽象,在它背后则是规约视角和实现视角的细节。先来看下入口源代码的写法:
<?php namespace PhalApi; class Api { /** * 按参数规则解析生成接口参数 * 根据配置的参数规则,解析过滤,并将接口参数存放于类成员变量 * @uses Api::getApiRules() */ protected function createMemberValue() { foreach ($this->getApiRules() as $key => $rule) { $this->$key = DI()->request->getByRule($rule); } } }
注意到,这里面依赖了另外两处的实现,一个是多套参数规则的合并, 另一个是根据规则获取参数。将多套参数规则的合并的实现放到接口类内是经过精心设计的。因为参数规则的配置本身就直接来源于接口类的getRules钩子方法,即来自接口实现子类本身,再进一步,这里的参数规则又可再细分为通用接口参数规则和指定接口参数规则。其次,还有一个原因是因为在接口类内处理配置文件./config/app.php中的apiCommonRules公共参数,也更为贴切。最后,对于配置了白名单的接口服务,还可以接口类这一层做临时的动态调整。细细品来,这是一个微妙的设计。
接下来,让我们把放大镜挪到PhalApi\Request::getByRule(),继续探索根据规则获取参数的奥妙。此刻,会在请求类Request内完成参数获取的操作,为什么放到Request类也是有原因的。第一,请求类蕴含了各种来源的参数,例如:$_POST、$_GET、$_COOKIE、$_SERVER、$_REQUEST,可以直接从中提炼出需要的数据源;第二,如果缺少必要参数,则可以在这一环节直接抛出异常,给客户端相应的错误提示;最后但不是最重要的,我们还可以对服务端配置的参数进行合法性的校验,以及完成对底层的调用。下面源代码很好诠释了这三层意思。
<?php namespace PhalApi; class Request { /** * 根据规则获取参数 * 根据提供的参数规则,进行参数创建工作,并返回错误信息 * @param $rule array('name' => '', 'type' => '', 'defalt' => ...) 参数规则 * @return mixed * @throws BadRequestException * @throws InternalServerErrorException */ public function getByRule($rule) { $rs = NULL; if (!isset($rule['name'])) { throw new InternalServerErrorException(T('miss name for rule')); } // 获取接口参数级别的数据集 $data = !empty($rule['source']) && substr(php_sapi_name(), 0, 3) != 'cli' ? $this->getDataBySource($rule['source']) : $this->data; $rs = Parser::format($rule['name'], $rule, $data); if ($rs === NULL && (isset($rule['require']) && $rule['require'])) { throw new BadRequestException(T('{name} require, but miss', array('name' => $rule['name']))); } return $rs; } }
继续把镜头放大,拉进我们与第4个环节统一格式化操作的距离。考虑到在大部分情况下,字符串string是使用频率最次也是最为通用的类型,因此如果没有指定参数类型,默认类型就是字符串。而对于没有指定参数默认值的话,则默认值为NULL。完成这些必要的初始化和准备工作后,就到了体现PHP动态脚本语言优势的地方了。根据开发人员配置的参数规则中的类型,可以组装成既定格式的格式化具体实现类名称,结合依赖注入,可以实现单例模式下对格式化类实例的资源管理,最后调用统一接口规约下的接口PhalApi\Request\Formatter::parse()。出于对章节完整性的考虑,请允许我再贴下相关的源代码片段。
<?php namespace PhalApi\Request; /** * Parser 变量格式化类 */ class Parser /** * 统一格式化操作 */ public static function format($varName, $rule, $params) { …… return static::formatAllType($type, $value, $rule); } /** * 统一分发处理 */ protected static function formatAllType($type, $value, $rule) { $diKey = '_formatter' . ucfirst($type); $diDefautl = '\\PhalApi\\Request\\Formatter\\' . ucfirst($type) . 'Formatter'; $formatter = \PhalApi\DI()->get($diKey, $diDefautl); if (!($formatter instanceof Formatter)) { throw new InternalServerErrorException( \PhalApi\T('invalid type: {type} for rule: {name}', array('type' => $type, 'name' => $rule['name'])) ); } return $formatter->parse($value, $rule); } }
最后一步,我们终于迎来到了剧情的大结局。前面有说到,PhalApi 2.x内置了9种参数类型,并且默认类型是字符串。所以,以字符串的格式化实现类StringFormatter为例,举一反三,从而了解另外8种格式化实现类的思路和自定义扩展格式化类的开发规范。
<?php namespace PhalApi\Request\Formatter; /** * StringFormatter 格式化字符串 */ class StringFormatter extends BaseFormatter implements Formatter { /** * 对字符串进行格式化 */ public function parse($value, $rule) { $rs = strval($this->filterByStrLen(strval($value), $rule)); $this->filterByRegex($rs, $rule); return $rs; } }
这一层是属于实现视角,更多是实现细节,包括但不限于对参数进行类型转换,范围检测和反序列化解析。
整套设计最终映射到代码模型,涉及到的类、依赖关系和包嵌套层级,可以参考使用PHPDoc生成的图形。
图3 参数规则格式化类图
至此,我们就已经快速领略了PhalApi 2.x中参数规则的实现思路和微架构设计。在《初识PhalApi》一书中,我更多介绍的是如何使用参数规则,在这里,我重点分享的是如何站在框架的角度为技术开发人员设计一套友好、易用的参数规则。如果你也打算这么做,可以稍微参考一下这里的做法。
Yii的声明验证规则与Symfony的验证
当然,除了重复造轮子,更推荐的做法是直接使用开源框架提供的验证器。例如Yii的声明验证规则,包括CFormModel表单验证和和CActiveRecord模型验证;另外又如Symfony的Validation验证。从某种程度上说,验证这块是个非常值得深入研究的细分领域,也已经不少优秀的资料对此进行介绍和总结。
下面,我们再来简单回顾或了解下Yii和Symfony对参数验证的使用方式。
摘自Yii 1.1 权威指南,有以下示例:
class LoginForm extends CFormModel { public $username; public $password; public function rules() { return array( array('username, password', 'required'), array('password', 'authenticate'), ); } public function authenticate($attribute,$params) { …… } }
在登录表单中,为账号和密码配置了相应的规则,其中账号和密码都是必须的,并且密码还会通过LoginForm::authenticate()再做进一步的验证。
PhalApi的参数规则,很大的灵感来自Yii。因为Yii中有很多可以通过配置就能实现的功能,除了表单验证外,还有模型、视图组件等。这些配置都很强大,但强大到有点复杂,甚至难以记忆和使用。每次我在使用Yii开发时,都要到官网搜索一遍,找到类似的示例代码或者查看类手册才能知道该具体如何配置。所以,在设计PhalApi参数规则时,我在基于Yii的灵感上做了一些创新,尽量做到易用和简单。顺便分享一下,很多出名的画家,都是从临摹他人的作品开始的,只有在融汇贯通后,再加入自己的思想,方能设计出更加精湛的艺术品。同样的道理,在设计框架时,也应该这样,集百家之精华于一身,博取众长,方能研发出更为贴心动人的项目。
public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('myField', TextType::class, array( 'required' => true, 'constraints' => array(new Length(array('min' => 3))) )) ; }
纵使未曾使用过Symfony框架,也不难理解上面配置的意思:对于字段myField,指定了文本类型,并且是必须字段,最短长度是3。多么通俗易懂啊。逆向还原容易,同样正向使用也平易近人。
2 惯例优于配置,配置优于实现
配置的魅力如此之大,以至于我不得不单独用一节的篇幅再对其进行总结和分享。简而言之,本节基于我们都认可这样的价值观为前提,即致力于编写人容易理解的代码。而配置则是通过此价值观的绿色通道。
首先,惯例优于配置。
Ruby是一门优秀的编程语言,而且也处处体现了惯例优于配置的理念。通过简单的代码示例,可以体会这一约定。如普通的操作:
def say_hello(name) # 先打声招呼 puts "hello #{name}" end
对于返回布尔类型的操作,则在方法名后面添加一个问号。
def more_money?() # 假设有点钱 return true end
对于一些危险的、可能会抛出异常的操作,则会在方法名后面追加一个叹号。
def borrow_me!(money) # 假设钱不够 raise "Not enough momey!" end
为了串联进来,假设有这么一个用户故事:你好某某人,有钱吗?借一点给我! 则对应的代码片段可以表达成:
say_hello '某某人' borrow_me! 100 if more_money?
对应运行的效果类似如下:
hello 某某人 ./test.rb:13:in `borrow_me!': Not enough momey! (RuntimeError)
就这么一个简单的案例,可以体会到Ruby元编程语言下约定成俗的做法,而这些大大小小的约定,不仅没有增加开发人员的认知负担,反而构成了Ruby开发人员的共同开发语言。无需过多的解释,Ruby程序员都有快速解读这些符号和约定背后的含义。
其次,配置优于实现。
可以说,拥有惯例的开发团队,会有更高效的合作以及更为畅快的沟通。因为大家都能快速明白简明代码所体现的意图和目的,不存在混淆和错乱。如果缺少开发语言的特性支持,或者所在的开发团队缺少约定编程的氛围,可以退而求其次,采用配置优于实现的做法。
关于配置的做法,在以使用注解的JAVA为例,可以看到其身影,如需要运行一组单元测试,可以这样:
@RunWith(Suite.class) @Suite.SuiteClasses({ Test1.class, Test2.class, Test3.class, TestSuite2.class }) public class TestSuite1 { }
相当于以下JUnit 3中的实现代码:
public class TestSuite1 { public static Test suite() { TestSuite suite = new TestSuite("Test for package1"); suite.addTest(new JUnit4TestAdapter(Test1.class)); suite.addTest(new JUnit4TestAdapter(Test2.class)); suite.addTest(new JUnit4TestAdapter(Test3.class)); suite.addTest(new JUnit4TestAdapter(TestSuite2.class)); return suite; } }
通过配置,而是不编码实现,可以更容易传达所需要做的事情,而且配置的背后则是更为规范一致的约定,以及更为严格的检测,从而减少人为的失误的机会。最后,不仅是效率的回报,还是高质量上的获益。
而这一点,和我们在PhalApi、Yii、Symfony中使用配置所达到的效果是类似的。以PhalApi的参数规则为例,除了可以用规则配置取代重复实现的代码外,开发人员还可以使用参数规则库来维护整个项目的规则,甚至还可以将这些配置进行序列化存储,再通过可视化的管理后台进行在线维护和管理。这都得益于配置的应用。
在过往的开发中,我也曾遇到类似的场景,在开发活动系统的功能时,我发现可以把类似常用的活动功能开发抽离成可配置的形式,最后也印证了采用配置而不是实现编程,可取得效率上的提升以及高质量的回报。在曾经关于设计模式的分享里,我把这一模式总结为:开发-配置-使用模式 (DEVELOP-CONFIG-USE PATTERN)。它概括起来是:
问题:对已有的功能模块,仍然通过编码实现来重复调用。
解决方案:一次开发后,通过配置而不是代码实现,来使用或定制已有且可重用的功能。
效果:最大限度减少人为编码的错误,并统一规范的检测、验证、解析过程。
已知应用:nginx配置、Yii表单验证规则。
在配置的基础上,我们可再向前一步,向声明式编程靠拢。配置式编程固然是好,但视不同的上下文而定,有时还可以再进一步,进入到声明式编程的范畴。
考虑以下表单,
图4 登录表单
对登录表单数据进行验证的JavaScript代码片段如下:
validator.init([{ dom: iptMobileDom, rules: [{ strategy: 'isNotEmpty', errorMsg: '手机号码不能为空' }, { strategy: 'isMobile', errorMsg: '手机号码格式错误' }] }, { dom: iptAuthcodeDom, rules: [{ strategy: 'isNotEmpty', errorMsg: '验证码不能为空' }] }]);
上面的代码不难理解,作用是对手机号和验证码进行验证,并且通过配置规则可以快速实现对表单的验证。但这样依然有两个缺点:一是具体的规则不能直观说明需要验证的内容,二是当其他场景需要进行类似验证时需要重复编写相同的规则。也就是说,虽然我们有了规则配置,但还是“嫌”它过于繁琐,那有没有更“懒人”的做法呢?
答案是:有的!我们可以采用声明式编程,即:我们应该告诉代码(同时传达给我们的同伴),我们需要验证什么,而不是着重于具体要怎么验证。听起来,就是第四代编程语言。
首先,可以定义一个规则集合常量:
const rules = { mobile : { isNotEmpty : { strategy : 'isNotEmpty', errorMsg : '手机号码不能为空' }, format : { strategy : 'isMobile', errorMsg : '手机号码格式错误' } }, authcode : { isNotEmpty : { strategy : 'isNotEmpty', errorMsg : '验证码不能为空' } } };
接着,使用上面元规则进行自由地组合使用,声明需要验证的内容。调整后的代码为:
validator.init([ { dom : iptMobileDom, rules : [rules.mobile.isNotEmpty, rules.mobile.format] }, { dom : iptAuthcodeDom, rules : [rules.authcode.isNotEmpty] } ]);
这样之后,再来看下其他类似的场景,我们即使不细看扩展丰富后的规则,也可以很容易理解明白下面代码的意图。
validator.init([ { dom : iptPasswordDom, rules : [rules.password.isNotEmpty, rules.password.format] }, { dom : iptMobileDom, rules : [rules.mobile.isNotEmpty, rules.mobile.format] }, { dom : iptAuthcodeDom, rules : [rules.authcode.isNotEmpty] } ]);
在上面常见的表单验证的场景中,可以发现其中一些微妙的关系。需要待验证的DOM节点是可变的,因为不同的场景界面会有不同的class,同时需要进行验证的条件也是可变的,即会存在组合的情况。但是各个规则条件又是可以共用的,最后每一条规则条件都是可重用的一条元规则。故此,把不变的元规则抽取成规则集合,既方便重用,又能增进开发人员之间跨时空的理解。
稍微提炼一下,便可得到以下的设计模型:
图5 表单规则的设计模型
为什么说惯例优于配置,配置优于实现呢?因为对于要做一件事件,惯例可以说是不用写任何代码就能轻松实现的;配置是需要简单地写一些任何语言都能识别的普通字符就可以了;而实现则是需要根据不同的编程语言而进行具体的开发。
再次回顾概念视角、规约视角和实现视角这三种面向对象的视角,具体的实现代码则是对应了实现视角,再说得通俗一点(但不一定是对的),惯例为上策,配置为中策,实现为下策。
3 签名、验证、权限与安全性
如果说客户端提供的参数是不可信任的话,那么对于客户端的身份和来源则更要谨慎判别,最大程度全面提升接口服务系统的防护能力和安全性。至少有以下这几个方面,我觉得是有必要认真考虑的。
渠道接入方
签名校验
登录态检测
加密通讯
权限控制
下面分别简单说之。
不同于网站,不同于直接面向终端用户的系统,接口服务系统的直接客户实际上是手机设备、第三方系统、嵌入式硬件等,而不是活生生的人群。因此,首先我们要规划好等接入的渠道接入方,以便管控各个渠道的访问、版本控制等。如果开发的接口主要为移动设备的应用提供服务,那么我们就要区分好是iOS还是Android,是App 1.0 还是App 2.0,是内测版还是公测版。如果客户端主要是公司内部业务系统,就要区分好是哪个业务系统,为不同的接入渠道分配单独的凭证,方便在出现问题或者流量异常时快速定位业务相关方。如果我们搭建的是一个开放式的接口平台,那么渠道接入方的管理就更不可少了。这些管理包括渠道的有效日期、各自的密钥、主体信息、请求统计,甚至还包括计费系统。
HTTP可以说是一个公开透明的通讯协议,每次请求所传递的参数都是一览无遗,就像一个光秃秃的小屁孩,走在大街上,毫无隐私可言。如果不采取任何保护措施,我们的接口服务就会很容易受到伪造的请求。因此,我们引入了电子签名这一概念。签名是指客户端根据接口服务系统分配的唯一凭证和密钥,根据约定的签名算法进行身份验证。只有当签名核对正确后,才认为是合法的请求。签名验证的算法设计很简单,在PHP在使用ksort、http_build_query、md5等函数就能快速设计一个简单可靠的方案了。
HTTP协议除了是公开透明外,还是一个无状态的请求,不带记忆功能。虽然也可以通过COOKIE或SESSION来增强会话的能力,但通常不建议在接口服务系统中使用这一方案,这也是为什么PhalApi开源框架一直都没有提供对SESSION的封装和接口。更好的做法是,在用户成功登录后,为用户当前会话分配一个TOKEN登录凭证,然后后续在请求接口服务时带上此TOKEN参数。就可以在确保是合法的客户端请求的同时,确保是真实的用户本人。
话说回来,一个光秃秃的小屁孩穿梭在人群中,难免不雅,因此我们可以帮他穿上衣服,或者在别人看到这个短视频前打上马赛克。同样在接口使用HTTP通讯的过程中,我们也可以对客户端的请求以及服务端返回的结果进行加密通讯。例如使用RSA加密算法,这一做法在银行提供的接口服务系统中应用得较为广泛。但在一般的普通商业项目中,过重的加密通讯反而会影响客户端的易用性,这一点需要权衡。
最后,是接口服务系统的权限控制。权限控制在系统层面是针对渠道接入方的权限控制,可以指定特定渠道接入方可以使用哪一类或哪部分接口服务。在业务层面,还可以细分控制当前已登录的用户具备哪些操作的能力或权限,例如分为游客、普通会员、高级会员等。在设计时,可以参考基于角色权限控制的RBAC,或访问权限控制的ACL。
综合渠道接入方的区分、签名校验、登录态检测、加密通讯、权限控制,都是从不同的侧面增强接口服务系统的安全性。但要注意的是,没有绝对的安全,只有相对的安全。纵使全部这些都做到了,对于客户端的重复请求、爬虫的抓取,还是有所欠缺的。在开发接口系统过程中,要综合考虑,做出合适的设计。