这次的分享是今年做的一个项目的总结。
我们在2016年初上线了一个网上药房:老白网laobai.com。 半年多的时间,老白网的官网销售额在全国自营的网上药房里已经排名前10了。电商的后端需要有一套供应链管理系统,但是由于药品的特殊性,药品采购/仓储/物流等需要符合GSP规范,导致我们目前外购并同时使用两套供应链系统,一套通用版满足基本的功能需求,一套主要是药品的GSP审核的需要,两个系统之间还需要数据交换。此外,这些外购的系统也无法满足我们自己的一些定制化开发的需求。所以就迫切需要自己开发一个满足GSP规范的药品行业供应链系统。
外购的第三方的供应链系统有1000多张表,功能上的复杂度主要在数据表多且有关联,对性能/并发上到是没有太多的要求。因为可以参考第三方的数据库结构设计,也可以参考第三方的界面设计,所以数据库/前端都没有太多的不确定性。 当时这个项目的团队情况是有一个靠谱的DBA,有几个前端开发,但是缺少后端程序员。功能很多,需要开发的人力缺口很大,正是在这种情况下催生了本文提到的技术方案。方案的主要考虑是:
- 数据库设计完成后,前端要求可以直接开始开发,不能等后端接口。
- 后端的开发工作量要尽可能少,多用代码生成技术,因为没有专职的后端程序员。
关于前后端之间的接口规范,主要有三种风格: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对这种风格的讨论,可参考这个链接, 这里是中文翻译版。
这还只是对单表资源的CRUD操作,我们碰到的Restful规范主要是缺失下面的一些部分:
- 查询/分页/排序的支持。 Restful接口只有一个列表的接口,对查询相关的功能没有约定。
- 批量操作的支持。 Restful接口默认只支持单个资源的操作,而实际的业务场景中经常需要有批量操作的需求,比如商品的批量上架、数据的批量删除等。
- 有关联的数据表的支持。Restful只支持单个资源的CRUD,而实际业务中经常有主子表的级联保存,关联表的查询等。
所以本方案主要考虑两点:
- 扩展REST,针对上述三种场景约定好接口规范。 目的是让前端程序员只需要知道接口约定,就可以针对所有的数据库表进行接口开发。无需提供详细的接口说明文档。
- 如何通过自动代码生成的方式实现这些规范,以减少后端开发的工作量。
REST规范没有约定如何实现批量操作,也没有说明提交参数和返回值的格式。实践中,elasticsearch提供了批量操作的入口/_bulk,统一处理所有的批量操作,可以在一次请求里完成索引/更新/删除等多个操作。本方案对批量操作的要求更严格,只支持单表数据的一种操作。对于批量新增接口,我们重用Restful的新增接口,只是提交的数据格式不一样。
单个新增的话,提交的是一个单个的hash对象
{
"表名单数": {id:id, field:value,...}
}
批量新增的话,提交的里层数据是一个数组
{
"表名复数": [{id:id1, field:value,...},{id:id2}...]
}
而且约定在所有的输入输出中,如果是集合对象,名字采用复数形式;如果是单个对象,名字采用单数形式。当然,还有一个约定是所有的主键的字段名都是“id”。请求中的field直接对应数据库的列名。如果id不传,则由数据库提供自动生成的主键。
对于如何定义批量修改接口,有点左右为难。首先,无法按照批量新增的模式重用修改接口,因为Restful的单条数据修改接口“PUT /表名/:id”和单个id绑定了。如果严格参考DHH的做法,批量修改也需要抽象成一个资源,类似于点赞,而不应该增加一个动作。
但是我对这个做法不习惯,批量修改我认为还是和CRUD一个性质的。目前还是决定增加了一个动作batch_update,也是整个约定里唯一增加的动作。批量修改接口的url地址是“/表名/batch_update.json”,提交的json数据格式和批量新增接口一致。以后也有可能统一提供一个类似elasticsearch的bulk接口。
批量删除接口也没有按照DHH的说法抽象成资源,而是重用了Restful的删除接口,只是id的格式不一样。批量删除接口,一次传入多个id,id之间以英文逗号“,”分割。
所以针对批量操作,本方案增加了两个约定如下:
操作 | HTTP Method | URI |
---|---|---|
批量修改数据 | POST | /表名/batch_update(.:format) |
批量删除数据 | DELETE | /表名/:id,:id |
单表的查询,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,"
分页参数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"
上一节提到的单表查询规范,其中的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"
这两个查询的含义是显而易见的。
本系统支持在查看一条数据时,自动带出关联的父表的数据。同时也支持带出给定的几个关联子表数据:传递参数many=表1[,表2],比如:
GET warehouses/1.json?many=stores
查看编号为1的仓库的基本信息,同时给出这个仓库下面的所有库位的信息。这个查询要求stores表有一个外键指向warehouses表。
有很多业务场景需要支持在一个事务里保存主表和关联的多个子表的数据。级联保存的接口和批量保存的接口类似,只是提交的参数有区别。主子表新增的话,提交的数据格式如下:
{
"主表单数": {id:id, field:value,...} ,
"子表复数": [{id:id1, field:value,...},{id:id2,}...]
}
关联表之间如果存在数据库外键约束,单独删除主表的数据是不能成功的。此时就需要把依赖于该主表的所有子表数据也删除。在删除的接口增加一个many参数,用于处理这种情况,传递格式“many=表1[,表2]”,比如:
warehouses/1234.json?many=stores
关联表删除和批量删除是一个接口, 可以一次性删除。比如: /1,2,3,4.json?many=table1s,table2s 代表批量删除“1,2,3,4”四个数据,其中每个数据都级联删除两个子表“table1s,table2s”的所有关联数据。
上面讲的所有关于强约定的Restful接口,目的是给定数据库的情况下,前端就可以独立完成供应链系统的开发。而后端则需要在给定了数据库的情况下,自动生成实现所有上述接口约定的代码。完成这个艰巨任务的前提是数据库结构符合一定的规范。
数据库命名首先是采用了标准的rails数据库约定:
- 表名用英文复数形式,多个单词之间用下划线“_”分割。表名/字段名除了字母/数字/下划线,不能包含其它特殊字符。
- 主键的名字都是”id“
- 外键的名字都是“表名单数_id”,非外键字段不能以"_id"结尾
- 每张表可以添加默认的创建时间/修改时间字段, 字段名称必须是"created_at" "updated_at",类型是datetime
然后是本项目特定的数据库约束:
- 如果一张表里有多个关联到另外一张表的外键,命名规则是“前缀_表名单数_id”。比如一张库存调拨单,会有来源仓库和目的仓库,都需要外键关联到仓库表,此时就需要通过前缀"from_warehouse_id"和"to_warehouse_id"来区分;
- 如果要使用多个数据库,不同的数据库之间不要有同名的表;
本方案并不是通过获取数据库的外键约束来获取数据表之间的关联,而是通过命名约定来获取关联信息。这样的好处是方案更灵活,且支持跨数据库的外键关联。
通常情况下,一个外键有三种可能:一对一,一对多,多对多。为了简化实现,本方案只采用一对多。“一对一”是“一对多”的特例,“多对多”则可以用两个“一对多”来表示。
关联关系是双向的,对于两个表table1s和table2s,如果table1有一个字段table2_id,那么table1是多方,table2是一方,用rails来描述就是:
Table1 belongs_to table2
Table2 has_many table1s
通过外键的命名规则扫描所有数据库,建立表关联的过程如下:
对每一张表,查看所有以“_id”结尾的字段名,把该字段的去掉“_id”的前缀匹配表名,如果找到了,就建立两个表的关联关系。匹配表名的时候是不区分数据库的,所以表关联支持跨数据库的关联。
对于以“_id”结尾的字段名,如果上一步没有匹配到表名,那么进一步去掉以“_”分割的前缀,把剩下的中间部分去匹配表名
上面扫描得到的两个关系:belongs_to和has_many,组织成两个hash表,key是表名,value是对应关系的表名的数组,然后把这两个hash对象序列化成YAML文件,文件名分别是belongs.yaml和many.yaml。Rails程序启动时,会读取这两个文件,然后重新反序列化得到belongs_to和has_many的两个hash表。
代码生成系统不是从零开始开发的,而是重用了 rails框架提供的scaffold功能。Rails框架提供了比较完善的单表CRUD功能。整个代码生成系统基本按照下面的流程实现的:
- 第一步是给每张表提供基础的CRUD功能,直接使用了rails的scaffold功能。
- 然后是实现单表的查询功能,根据url中传递的查询参数,在模版controller中增加对应的where条件。
- 接着是批量操作相关的CUD功能,基本上就是在一个循环里实现单个表的CUD逻辑。
- 然后是关联操作相关的CRUD功能,和批量操作的区别在于要验证关联关系。
- 接着实现关联表的查询功能,和单表查询的区别在于两点:1要验证关联关系,2要做两个表之间的join
- 最后修改rails的启动代码,实现表关联数据的预加载,增加对json格式的验证等。
本次分享介绍了一个基于REST协议的后端自动代码生成系统。重点讲了REST接口的设计。代码实现部分涉及到很多的dirty code,而且不同的语言和框架实现部分差别很大,所以主要讲了思路。整套系统的优缺点很明显。
| 优点 | 提升开发效率,节省人力成本 | | 缺点 | 性能问题 / 安全性问题 |
所以这套系统的使用场景是针对企业内部应用。后端利用自动生成的接口同时配合部分定制开发的接口,前后端分离方式的开发,可以极大提升整体项目的开发效率。