App项目实战之路(二):API篇

原创文章,转载请注明:转载自Keegan小钢
并标明原文链接:http://keeganlee.me/post/practice/20160812
微信订阅号:keeganlee_me
写于2016-08-12


App项目实战之路(一):概述篇
App项目实战之路(二):API篇
App项目实战之路(三):原型篇
App项目实战之路(四):UI篇
App项目实战之路(五):服务端篇
App项目实战之路(六):数据库篇


确定功能需求

概述篇发布出去后,收到很多人的大力支持,也收到了几点关于功能需求的建议,主要在于几点:

  1. 只有微信登录在App Store那边审核很可能通不过;
  2. 调用微信获取用户头像和昵称的接口需要企业微信号才行;
  3. 就算微信登录也存在需要修改头像和昵称的需求。

关于第1点,细想一下就知道,只有第三方账号登录的确是通不过审核的。因为提交审核时必须提供测试账号给App Store的审核人员。审核人员是不会使用自己的账号进行测试的,不管是自己的微信、微博还是手机号。之前我是掉过这个坑的,提交了一款以手机号+短信验证码登录的App,但没有提供测试账号,结果被打回来了。所以,还是需要建立自己的用户体系,这一点无法偷懒了。

关于第2点,则是因为微信对这部分接口做了权限控制,只有通过了开发者资质认证才有权开通此接口。但微信的开发者资质认证并不支持个人开发者。另外,还要交每年300元的审核费用。其实,未认证的开发者建立的App只有分享的权限,根本没有登录的权限。所以,微信登录这条路根本通不了。因此,我决定不用微信登录了,改用Github登录。毕竟,面向的用户群是程序猿,而程序猿基本是人手一个Github账户。没有Github账户的,称不上合格的程序猿。也不考虑再加入微博、QQ、Facebook、Twitter等社交账户登录。因为选择太多容易混乱,我自己在某些平台登录时,就经常不记得上一次是用哪个账户登录的。

关于第3点,毫无疑问,修改头像和昵称的功能需要保留。

因此,最终的功能需求应该如下:

  1. 手机号 + 短信验证码注册
  2. 手机号 + 短信验证码登录
  3. Github登录
  4. 上传图片
  5. 修改头像
  6. 修改昵称
  7. 设置用户技术栈标签
  8. 获取同栈之猿的内容列表
  9. 获取关注之猿的内容列表
  10. 获取同栈的用户列表(未有关注之猿时获取)
  11. 发布问题
  12. 发布分享
  13. 关注某条内容
  14. 取消关注内容
  15. 获取内容的评论列表
  16. 添加评论
  17. 回复评论
  18. 点赞评论
  19. 关注某用户
  20. 取消关注某用户
  21. 获取某人详细资料
  22. 获取某人的发布内容
  23. 获取某人关注的人
  24. 获取某人的粉丝列表
  25. 获取我的消息
  26. 提交意见反馈
  27. 退出登录

需求确定,接着就可以开始设计API了。

REST API

关于什么是REST,我就不在这里赘述了,直接推荐REST作者的经典论文:

下面我只想用一些实例描述几种架构风格在API定义方面的不同。

假如现在要定义登录、退出登录、注册、查询用户资料的接口,那么,可以这样定义:

接口 方法 Endpoint
登录 POST /user/login
退出登录 POST /user/logout
注册 POST /user/register
查询用户资料 GET /user/queryInfo

使用这种风格的貌似很多。也有些不是在URI中定义接口,而在参数中用method或action之类的参数名区分不同接口,示例如下:

接口 方法 参数
登录 POST method=login
退出登录 POST method=logout
注册 POST method=register
查询用户资料 GET method=queryUserInfo

最后,再看下面这种接口的定义:

接口 方法 Endpoint
登录 POST /sessions
退出登录 DELETE /sessions/{session_id}
注册 POST /users
查询用户资料 GET /users/{user_id}

这三种定义有什么区别呢?其实,前面两种可以认为都是 RPC(Remote Procedure Call) 风格的,而最后这种则可认为是 REST 风格的。

RPC和REST区别在哪呢?

最直接的区别就是:RPC抽象的是过程,REST抽象的是资源。过程是以动词为核心,而资源是以名词为核心。也可以简单类比为:RPC是面向过程的,REST是面向对象的。

从上面的例子就可以看出,前面两种定义,每个接口分别用了一个操作性的词语去定义;而最后一种定义,登录和退出登录都属于 /session 资源,注册和查询用户资料都属于 /user 资源,然后分别用POST、DELETE、GET等方法对同个资源定义不同操作。

我发现,还有些定义是RPC-REST混合的,例如,可能会这样子定义:

接口 方法 Endpoint
登录 POST /users/login
退出登录 POST /users/logout
注册 POST /users/register
查询用户资料 GET /users/{user_id}

如果再加个修改用户资料的接口,可能是这样子的:

接口 方法 Endpoint
修改用户资料 POST /users/{user_id}/update

给我的感觉就是:好混乱!这种大部分都是在对REST有过很初浅的了解,但却缺少正确理解的情况下做出的设计。或者是对于部分接口不知道该如何抽象为资源,所以就直接用RPC方式去定义了。

其实,使用REST风格设计API,我觉得难点就在于如何抽象资源。使用RPC则相对容易很多。这时,也许有人就会提出疑问了。既然使用RPC比用REST更容易抽象出接口,那为何还要用REST呢?要解答这个疑问,可以从面向过程和面向对象的角度去思考。我们知道,面向过程的思考方式处理问题更直接简单,那为什么我们还要使用面向对象呢?至于这个问题的答案,我就不再展开了。

API定义

本项目的API是打算使用REST方式定义的。那么,首先,就是资源的Endpoint定义。根据前面的功能需求整理出以下资源,可能会有些遗漏:

Endpoint 资源
/files 文件
/files/{file_id} 某个文件
/sessions 会话
/sessions/{session_id} 某个会话
/users 用户
/users/{user_id} 某用户
/users/{user_id}/posts 某用户发布的内容
/users/{user_id}/following 某用户关注的人
/users/{user_id}/followers 某用户的粉丝
/posts 发布的内容
/posts/{post_id} 某条内容
/posts/{post_id}/comments 某条内容的评论
/me 当前用户
/me/posts 我发布的内容
/me/stars 我星标的内容
/me/following 我关注的人
/me/followers 我的粉丝
/me/messages 我的消息

定义资源的Endpoint时,需要分清楚不同资源的层级关系。一个定义良好的URI,应该具有可读性,即从URI本身即可知道它所代表的资源。另外,对于URI中的一些变量值,如{file_id}、{session_id}、{user_id}、{post_id}等,在传值的时候必须确保不能为空,可以设置默认值。

接着,就需要对每个资源定义操作的方法了。我倾向于使用以下四个方法:

方法 描述 示例 示例说明
POST 创建新资源 /posts 创建新内容
GET 查询资源 /posts 查询内容列表
PUT 修改资源 /posts/{post_id} 修改某条内容
DELETE 删除资源 /posts/{post_id} 删除某条内容

不过,并不是所有资源都会开放这四个方法。例如,对/post是不开放PUT和DELETE方法的。对于以上资源,具体需要定义哪些方法,这里就不再列出来了。

然后,还要加入版本控制。毕竟,接口不是一成不变的,需要不断改动升级版本应对各种变化。那么,版本号要加在哪里好呢?关于这个问题,网上有很多讨论,有些人喜欢直接加在URI中,像这样:

http://api.domain.com/v2.1/posts

有些人喜欢加在参数里,像这样:

http://api.domain.com/posts?version=2.1

也有些人喜欢加在Header里,像这样:

Accept: application/json;version=2.1

或者自定义Header

api-version: 2.1

不喜欢第一种方式的人,大部分理由是,URI表示资源,应该与版本无关。而第二种方式和第一种方式本质上是一样的。大部分人建议使用第三种方式。不过,发现好多开放API都是采用第一种方式。在我看来,加在哪里其实影响不大。在本项目中,我打算和大部分开放API一样采用第一种方式即可。另外,如果版本号不提供,则默认为采用最新版本的接口。

最后,再定义下响应的数据协议。初期打算使用JSON,后期可能会考虑使用Protocol Buffers。数据结构则如下:

{
    code:200,
    message: "success",
    data: { key1: value1, key2: value2, ... }
}
  • code: 错误码
  • message: 描述信息,成功时为"success",错误时则是错误信息
  • data: 成功时返回的数据,类型为对象或数组

之前,我是喜欢将请求状态码和业务错误码分开处理的。因此,这里的code我之前喜欢将其定义为业务错误码。但是,如果按照REST风格来设计,还是有统一的code更合适。因此,我这次尝试下改变习惯。

API安全设计

安全设计方面,首先,我打算全面使用HTTPS。使用HTTPS,虽然牺牲了性能,但可以解决大部分安全问题。另外,苹果在之前的WWDC上就已宣布,从2017年1月1日起,所有iOS应用将强制使用HTTPS。这其实也意味着,从2017年起,所有App都将会使用HTTPS,不只是iOS。除非有个别比较奇葩,非要搞HTTP和HTTPS两套。至于HTTPS的优化,则需要慢慢搞了。至于证书,自己弄个自签名证书即可。后期需要支持Web版的话再找个靠谱的CA注册证书。

其次,用户鉴权方面则打算采用Token方式。用户登录之后分配一个accessToken和一个refreshToken,accessToken用于发起用户请求,refreshToken用于更新accessToken。accessToken会设置有效期,可以设为24小时。而用户退出登录之后,accessToken和refreshToken都将作废。重新登录之后会分配新的accessToken和refreshToken。

然后,我还打算在App层级分配AppKeyAppSecret,Android和iOS分别分配一对。每次向服务端发送请求时,AppKey都必须带上,服务端会对相应的AppKey进行校验。而AppSecret则需要安全保存在客户端,也不能在网络上进行传输,防止泄露。AppSecret只用于加密一些安全性级别较高的数据,以及为URL生成签名。URL签名算法步骤如下:

  1. 将所有参数按参数名进行升序排序;
  2. 将排序后的参数名和值拼接成字符串stringParams,格式:key1value1key2value2...;
  3. 在上一步的字符串前面拼接上请求URI的Endpoint,字符串后面拼接上AppSecret,即:stringURI + stringParams + AppSecret;
  4. 使用AppSecret为密钥,对上一步的结果字符串使用HMAC算法计算MAC值,这个MAC值就是签名。

另外,如果为了再加强安全性,参与签名的参数列表中可以再添加个timestamp字段,值为发送请求时的时间戳,每次请求的时间戳都将不同,这样不止增加了签名的不可预测性,也可以防止重放攻击。服务端收到请求后先检查时间戳离当前时间是否过久,如果过久则不予处理。不过,这还涉及到客户端和服务端时间同步的问题。这个很难保持一致,就算使用长连接不断获取服务器时间,也会因为网络原因而存在延迟,而且在移动网络延迟可能还会比较高。

还有另一种方案,就是使用nonce字段,值为一个较长的随机数,而不是时间戳。每次请求的随机数也都会不同,可以达到同样的效果。不过,采用这种方案的话,那服务器需要保存以前发送过的nonce。每次收到请求后先检查nonce是否已存在,存在则不予处理。这样,时间久了,nonce的量将会非常大。也有一种优化方案,那就是每次请求的nonce值由服务端生成并发送给客户端。即是说,客户端每次发送正式请求之前,需要先向服务端请求nonce值。这样的话,服务端则可以在有请求过来的时候才生成nonce,请求处理完之后则可以删除nonce。不过,弊端也很明显,本来一次的请求变成了两次。

不过,在我的这个项目中,初期我只要求加强签名的不可预测性即可,而nonce方案具备更高的不可预测性。因此,我将采用的方案是:在客户端自己生成nonce,但服务端不保存nonce,只要检查请求中是否存在nonce即可。

URL签名在每次发送请求时都需要附加在参数中,服务端接收到请求后会使用同样的签名算法计算签名值,只有服务端计算出来的签名值和接收到的签名值一致时才认为请求是安全的。

写在最后

自此,API部分的设计就完成了。在此总结一下:

  1. 采用REST风格定义API,接口抽象成对资源的操作;
  2. 添加API版本控制,版本号嵌在URL中;
  3. 响应统一使用code、message、data的JSON数据格式;
  4. 全站采用HTTPS;
  5. 使用Token方式对用户鉴权;
  6. 使用AppKey方式对应用鉴权;
  7. 使用URL签名对请求鉴权;
  8. 参数中添加nonce值增强签名的不可预测性。

扫描以下二维码即可关注订阅号。