你的位置:首页 > ASP.net教程

[ASP.net教程]后台API服务的设计考虑

我在《写在最前》里说过,后台API的文档至关重要。不过,文档只是外在表现形式,设计才是真正的灵魂。我在这篇博文主要介绍的就是我在后台开发过程中,设计API时的考虑。我只说他是考虑,因为很多东西未必是正确的,更不会是绝对了。

 

首先,我要声明的是,我主要是参考下面这篇文章(以下简称最佳实践)里的理念:

 

  http://www.cnblogs.com/yuzhongwusan/p/3152526.html  [标题: RESTful API 设计最佳实践]

 

这是一篇翻译过来的博文,原文地址是:

 

  http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api

 

下面是我基于那篇文章的一些补充;也就是说,读者得先读一读那一篇:

 

1. 我的接口设计中并没有形如/tickets/12/messages/5的接口,因为在我的资源管理中,id是全局唯一的,不需要使用12-5这样的联合id。我的替代方案是:/ticket_messages/112255

 

2. 就像最佳实践博文里的所提的那样,我的List接口中也使用了诸如sort、分页等机制,搜索这块也使用了简明的q参数。我还加入了include和with_total, just_total等控制。

 

include: 这是解决典型的N+1问题的方案。例如我们定义Ticket资源,同时每个Ticket资源绑定到(belongs_to)一个User资源。通过调用接口/tickets可以访问Ticket列表。此时只会返回Ticket的信息,而不会嵌入Ticket的所有者User的信息,只是嵌入一个user的id字段,如下:

[  {     ...ticket1...,    user_id: 1  },  {     ...ticket2...,    user_id: 2  },  ...]

这里会返回N个Ticket,调用了一次接口。另外,如果想要进一步知道每个Ticket的User的信息,需要调用N次/users/:user_id接口,如下:

  /users/1

  /users/2

    ...

  /users/N

这就是著名的N+1问题。这样不仅浪费带宽,而且也可能会影响数据库查询效率。更重要的是,这无疑增加了前端的工作量。使用include参数的模式是在接口调用中声明要包含的子资源,通过一次接口调用实现N+1此接口调用的效果。调用/tickets?include=user,返回的形式是:

[  {    ...ticket1...,    user: {      id: 1,      ...user1...    }  },  {    ...ticket2...,    user: {      id: 2,      ...user2...    }  },
...]

这个就是我们后台API目前的设计形式。其中include字段可以配置包含多个相关资源,用逗号分隔即可。

再说明以下这个设计的一个小缺陷。就是出现不一致的情况。如果返回的结果存储在tickets中,那么第一个Ticket是tickets[0],那么没有设置include参数时,取user的id是tickets[0].user_id;当设置include=user时,取user的id是tickets[0].user.id。在取Ticket的User的id时,出现了调用的不一致,不好。

很抱歉,我居然没有意识到《最佳实践》里已经有个类似字段embed了。有些概念讲重复了。

 

with_total, just_total:一般来说,我在设计"列出..."的API接口时,仅仅返回的是你需要的数组。这里面包含能够匹配的资源的总数信息。最常用的情形是做分页的时候,这个总数信息可以导出总页数,进而判断当前的请求页是否是最后一页,从而在此时能够很好地灰选下一页按钮。我一开始不想添加这样的功能,因为我认为用瀑布流刷数据是更酷的方式,这时候通过返回一个空数组指示没有更多数据即可。仅仅返回一个数组,便于前端开箱即用返回数据,也算是一个小小的便利吧。最后还是添加了这个功能,毕竟全部瀑布流的方式并不能完全适应前端的设计,而且有时候确实需要知道一个新闻的关注总量这样的信息。这时候添加两个控制参数with_toal, just_total,它们的使用是互斥的。它们是控制字段,不用显示赋值为true,只要这个字段存在即可。

 

例如/tickets?with_total, /tickets?with_total=true, /tickets?with_total=false都是返回如下结果(总数存储在total字段中,数据数组存储在list字段中):

{  total: 1234,  list: [    ...  ]  }

just_total仅仅返回总数信息,抹去了list字段,这个时候就不要做一些分页控制了,因为没效果。即:

{  total: 1234}

 

3. 筛选控制

 

所谓筛选,是指通过条件查询返回匹配的资源。例如只返回address在’上海‘的User资源。《最佳实践》中是用一个类似'address=上海'这样的参数来控制条件查询。我没有采用这种分散的方式,而是把所有的查询相关条件都定义在cond这一个参数中。在上面的例子中,大致应该是cond={address: '上海'}。注意这里我用了javascript语法已减少输入,您可以把它看成等效的JSON语法。也就是说,cond参数的类型应该是一个对象,在其中定义所有的查询逻辑。更具体地说,cond参数应该是一系列的键值对,键是要配置的字段名,如address,值是匹配的规则,如设置成'上海',就是说这个字段要完全等于'上海'这个字符串。例如在'address=上海&age=18'这样的多字段匹配的情况下,用cond语法就应该是cond={address: '上海', age: 18}。现在在我的项目里我确实也只做了这些,诸如其他的age>18,age<18都没有实现。好在现在项目规模很小,还没用到这些。

 

将所有查询控制封装在一个cond参数中方便接口的统一性,也便于我统一实现。

 

不过,接下来我重点要说的是,这里面有坑。此坑在于,GET方法(上面所说的那些是用于HTTP GET METHOD)中,没有请求体,也就不能指定Content-Type=application/json。在cond中定义一个键值对集合,用json是很自然的方式,不过不能传递json。GET方法中如果要传递参数,只能附加在url中的query string当中。我不确定能不能传递json格式的数据,但我实际没有成功过。一般来说,query string的格式是field1=value1&field2=value2&...这样的形式。有一个不成文的约定,当传递数组时,使用array[]=v1&array[]=v2&...这样的形式,当传递对象,例如上面的cond中的对象,形式是cond[address]=上海&cond[age]=18. 这个约定不是放之四海皆准。项目的前端使用的是AngularJS,如果调用Resource的方法时,将下面的对象作为请求参数(我感觉这样对前端来说是最自然的),会让后端收到的query string那段非常地不正常:

{
   ..., //首先可以有分页,排序等参数 cond: { address: '上海', age: 18 } }

最后前端使用了一种不自然的调用方式,是下面:

{
   ..., //首先可以有分页,排序等参数 'cond[address]': '上海', 'cond[age]': 18}

我在考虑下面两种解决方案:

将cond参数的对象转化成JSON字符串,这样

{   ..., //首先可以有分页,排序等参数   cond: JSON.parse({     address: '上海',     age: 18   })}

或者使用POST方法来查询数据,接口是POST /users/query 查询条件可以用json格式写在请求体中。

前一种方式并不能让我满意,后一种实现起来较为繁琐。要处理GET /users和POST /users/query两种情况。

 

4. 错误返回

 

我的设计里是包含出错情况的。当需要返回用户一个出错消息的时候,首先会返回一个状态码。就像《最佳实践》里面说的,有些是我必然用到的,它们是:

  • 400 Bad Request (错误的请求) - 请求是畸形的, 比如无法解析请求体
  • 401 Unauthorized (未授权) - 当没有提供或提供了无效认证细节时。如果从浏览器使用API,也可以用来触发弹出一次认证请求
  • 403 Forbidden (禁止访问) - 当认证成功但是认证用户无权访问该资源时
  • 404 Not Found (未找到) - 当一个不存在的资源被请求时
  • 422 Unprocessable Entity (无法处理的实体) - 出现验证错误时使用
  • 500 Internal Server Error (服务器内部错误) - 服务器总会出现未知错误的

相信随着项目的发展,更多的状态码会用起来。

接着返回体中会返回一个json格式的错误消息。它的样子是:

{  message: 'something is wrong'}

不过,我更想要的样子是:

{  code: code, //更细致的错误编号,必要时提供  error: error, //错误消息,面向API的调用者,必要时提供  message: message //错误消息,此消息可以让前端使用alert之类的方法显示给终端用户,必要时提供}

做这个更改,是因为我发现前端有时需要判断错误的类型以做出相应的动作。另外,有时出错消息可以很轻易地归纳出面向终端用户的提示消息,但有时又只能提供给API的调用者一个宽泛的错误提示。并且,API调用者和用户的关注点是不一样的。