分享 一次无后端的供应链系统开发实践 (上篇): 前后端分离的 Restful 接口设计

ylt · 2016年12月07日 · 5322 次阅读

本人在去年底加入了一家医疗保险行业的创业公司‘亿保健康’,公司的技术是基于 Java 体系的。而这次的分享是今年做的一个新的供应链项目。当时项目组缺后端 java 程序员,所以我用 ruby 写了一个自动化的后端。本文分享供应链项目采用的后端自动化技术,该技术极大提升了项目的开发效率。

1. 缘起

我们在 2016 年初上线了一个网上药房:老白网 laobai.com。 半年多的时间,老白网的官网销售额在全国自营的网上药房里已经排名前 10 了。电商的后端需要有一套供应链管理系统,但是由于药品的特殊性,药品采购/仓储/物流等需要符合 GSP 规范,导致我们目前外购并同时使用两套供应链系统,一套通用版满足基本的功能需求,一套主要是药品的 GSP 审核的需要,两个系统之间还需要数据交换。此外,这些外购的系统也无法满足我们自己的一些定制化开发的需求。所以就迫切需要自己开发一个满足 GSP 规范的药品行业供应链系统。

外购的第三方的供应链系统有 1000 多张表,功能上的复杂度主要在数据表多且有关联,对性能/并发上到是没有太多的要求。因为可以参考第三方的数据库结构设计,也可以参考第三方的界面设计,所以数据库/前端都没有太多的不确定性。 当时这个项目的团队情况是有一个靠谱的 DBA,有几个前端开发,但是缺少后端程序员。功能很多,需要开发的人力缺口很大,正是在这种情况下催生了本文提到的技术方案。方案的主要考虑是:

  1. 数据库设计完成后,前端要求可以直接开始开发,不能等后端接口。
  2. 后端的开发工作量要尽可能少,多用代码生成技术,因为没有专职的后端程序员。

2. REST 的优点与不完备性

关于前后端之间的接口规范,主要有三种风格:RPC vs REST vs GraphQL。 RPC 风格的接口规范是最传统的,但是不太合适基于 Web 的系统,所以本方案中不予考虑。 GraphQL 调研了一下,它能够满足我们的第一个需求,前端可以定义接口,对后端接口依赖少。但是无法满足第二点,采用 GraphQL 的后端开发工作量很大,测试也麻烦,而且 GraphQL 太新,整个团队都没有使用的经验。 REST 有很多可用的代码生成系统可参考,比如 rails scaffold / django admin,不需要从零开始。所以综合考虑还是采用 REST 风格。

按照 Fielding 博士的说法,REST 只适用于应用软件的架构,不包括操作系统、网络软件和一些仅仅为得到系统支持而使用网络的架构风格 (例如,进程 控制风格)。应用软件代表的是一个系统的“理解业务”(business-aware) 的那部分功能。

按照 REST 架构设计的 Web API 一般被成为 Restful API。 REST 在数据库类应用中使用广泛。针对数据库中的一张表,Restful 的 api 通过约定来定义好 CRUD 的全部接口 api,不需要前后端之间沟通接口的设计,而且各种语言都有针对单表的 CRUD 后端自动代码生成,能够减少后端的开发工作量。约定大于配置是一个很好的软件工程实践,能够大大减少软件开发的复杂性。下面就是一个 restful 的 api 约定:

操作 HTTP Method URI
获取列表数据 GET /表名 (.:format)
添加新数据 POST /表名 (.:format)
修改已有数据 PUT /表名/:id(.:format)
查看已有数据 GET /表名/:id(.:format)
删除已有数据 DELETE /表名/:id(.:format)

很多知名的软件和框架都采用了类似这套的约定,比如 rails 开发框架/ElasticSearch 搜索等。

当然针对实际的软件开发需求,REST 的规范还是太简单了。标准的 Restful 接口只有四个动作,CRUD。一个最常见的扩展(或者说是误用),就是使用更多的动作。因为常见的后端开发框架是 controller+action 的模式,一个 controller 对应一个资源,里面配置多个 action 对应前端用户的多个动作。典型的,比如一个帖子,点赞/取消赞是两个动作。然后随着业务的发展,还有锁帖/解锁操作,回复帖子/查看所有 0 回复帖子等等需要。于是就有了下面的这种 url 设计:

POST /topics/follow
POST /topics/unfollow
POST /topics/lock
POST /topics/unlock
POST /topics/reply
GET /topics/no_reply

慢慢的,接口越来越复杂,离原来的 Restful 风格越来越远。当然,有人觉得这种风格也不错。而 DHH 的观点是这种风格 url 需要改造为 Restful 的风格,比如对于点赞的场景,可以认为有一个资源是 topics/follows,然后这个资源有添加和删除两个操作。 关于 DHH 对这种风格的讨论,可参考这个链接, 这里是中文翻译版

今年的 rubychina 大会上也有一场针对类似问题的分享,可以参考 [这个链接](https://speakerdeck.com/mechiland/reconsider-rest-chong-gou-jian-da-xing-railsying-yong-de-fang-shi

这还只是对单表资源的 CRUD 操作,我们碰到的 Restful 规范主要是缺失下面的一些部分:

  1. 查询/分页/排序的支持。Restful 接口只有一个列表的接口,对查询相关的功能没有约定。
  2. 批量操作的支持。Restful 接口默认只支持单个资源的操作,而实际的业务场景中经常需要有批量操作的需求,比如商品的批量上架、数据的批量删除等。
  3. 有关联的数据表的支持。Restful 只支持单个资源的 CRUD,而实际业务中经常有主子表的级联保存,关联表的查询等。

所以本方案主要考虑两点:

  1. 扩展 REST,针对上述三种场景约定好接口规范。目的是让前端程序员只需要知道接口约定,就可以针对所有的数据库表进行接口开发。无需提供详细的接口说明文档。
  2. 如何通过自动代码生成的方式实现这些规范,以减少后端开发的工作量。

3. Restful 扩展:单表批量操作(添加/更新/删除)

3.1 批量新增接口

REST 规范没有约定如何实现批量操作,也没有说明提交参数和返回值的格式。实践中,elasticsearch 提供了批量操作的入口/_bulk,统一处理所有的批量操作,可以在一次请求里完成索引/更新/删除等多个操作。本方案对批量操作的要求更严格,只支持单表数据的一种操作。对于批量新增接口,我们重用 Restful 的新增接口,只是提交的数据格式不一样。

单个新增的话,提交的是一个单个的 hash 对象

{
    "表名单数": {id:id, field:value,...}
}

批量新增的话,提交的里层数据是一个数组

{
    "表名复数": [{id:id1, field:value,...},{id:id2}...]
}

而且约定在所有的输入输出中,如果是集合对象,名字采用复数形式;如果是单个对象,名字采用单数形式。当然,还有一个约定是所有的主键的字段名都是“id”。请求中的 field 直接对应数据库的列名。如果 id 不传,则由数据库提供自动生成的主键。

3.2 批量修改接口

对于如何定义批量修改接口,有点左右为难。首先,无法按照批量新增的模式重用修改接口,因为 Restful 的单条数据修改接口“PUT /表名/:id”和单个 id 绑定了。如果严格参考 DHH 的做法,批量修改也需要抽象成一个资源,类似于点赞,而不应该增加一个动作。

但是我对这个做法不习惯,批量修改我认为还是和 CRUD 一个性质的。目前还是决定增加了一个动作 batch_update,也是整个约定里唯一增加的动作。批量修改接口的 url 地址是“/表名/batch_update.json”,提交的 json 数据格式和批量新增接口一致。以后也有可能统一提供一个类似 elasticsearch 的 bulk 接口。

3.3 批量删除接口

批量删除接口也没有按照 DHH 的说法抽象成资源,而是重用了 Restful 的删除接口,只是 id 的格式不一样。批量删除接口,一次传入多个 id,id 之间以英文逗号“,”分割。

所以针对批量操作,本方案增加了两个约定如下:

操作 HTTP Method URI
批量修改数据 POST /表名/batch_update(.:format)
批量删除数据 DELETE /表名/:id,:id

4. Restful 扩展:单表查询

4.1 查询格式

单表的查询,uri 直接重用 rest 规范,但是要约定好查询的参数的传递规范。我们定义了下面这些查询的请求格式

s[field]=value
s[like[field]]=value
s[date[field]]=value
s[range[field]]=value
s[in[field]]=value
s[cmp[field,field]]=

分别代表精确查询/like 字符串模糊查询/date 日期范围查询/range 范围查询/in 枚举查询/cmp 比较查询。这些查询基本满足了 OLTP 业务的常见需求,报表统计类需求有专门的报表系统。

如果有多个查询条件,条件之间是逻辑与的关系。

s[field1]=value1&s[like[field2]]=value2

查询的 field 直接对应到数据库的字段。如果 field 有逗号“,”,则表示同时查询多个字段,其中一个满足条件即可,也就是 OR 查询。

"/warehouses.json?s[like[company,address]]=测试"

上面这个查询的意思是查找所有 company 包含‘测试’或者 address 包含‘测试’的所有仓库。

针对 date/range/in 查询,支持 value 中包含逗号“,”。以 range 查询为例,“1,5”代表范围是 1 到 5,",5"代表小于等于 5,"3,"代表大于等于 3。例如:

"/warehouses.json?s[range[id]]=1,5"
"/warehouses.json?s[range[id]]=,5"
"/warehouses.json?s[range[id]]=3,"

4.2 分页/排序/Count 计数

分页参数 page/per,排序参数 order,计数参数 count 之间都是可以自由组合的。例如:

"/warehouses.json?page=1"
"/warehouses.json?page=1&per=100"
"/warehouses.json?page=1&order=id+desc"
"/warehouses.json?page=1&per=100&count=1"

5. Restful 扩展:关联表支持

5.1 外键查询的支持

上一节提到的单表查询规范,其中的 field 字段,增加对特殊符号"."的支持,用于外键查询。这样 Field 可以包含三种类型:

单个key;
多字段的key,格式:"key1,key2,..."
外键的key,格式:“key1.key2”。

其中,多字段的 key 的格式表示多个字段的 or 查询,上文已经提及。外键查询要求被查询的表有对应的外键字段。比如仓库属于公司,那么 warehouses 表有一个外键 company_id,company 有 id、name 字段,那么可以有下面的查询:

"warehouses.json?s[company.name]=测试公司"
"warehouses.json?s[range[company.id]]=1,5"

这两个查询的含义是显而易见的。

5.2 关联表查看

本系统支持在查看一条数据时,自动带出关联的父表的数据。同时也支持带出给定的几个关联子表数据:传递参数 many=表 1[,表 2],比如:

GET warehouses/1.json?many=stores

查看编号为 1 的仓库的基本信息,同时给出这个仓库下面的所有库位的信息。这个查询要求 stores 表有一个外键指向 warehouses 表。

5.3 子表的级联新增和删除

主子表的级联保存

有很多业务场景需要支持在一个事务里保存主表和关联的多个子表的数据。级联保存的接口和批量保存的接口类似,只是提交的参数有区别。主子表新增的话,提交的数据格式如下:

{
    "主表单数": {id:id, field:value,...} ,
    "子表复数": [{id:id1, field:value,...},{id:id2,}...]
}

6. 小结

这篇文章主要介绍了项目采用的一种扩展的 restful 接口协议,基于该协议,前端程序员在知道数据库结构的情况下就可以开始项目开发了。由于供应链项目是一类成熟的项目,可以且必须参考已有系统的数据库设计(因为要保证旧系统的数据可以方便的迁移到新系统),所以数据库设计实际上是提前完成的。这和通常的 rails 项目不太相同,常见的 rails 项目数据库结构是在代码中定义的,随着开发的进行不断完善的,而不是提前给定的。

本文的下篇则会介绍到代码生成技术。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号