概述
本文提出了一种应用系统中,使用REST方法,构建后端API的应用模式的方法。可以看成是一种规范性的框架。实践表明,使用这个框架,可以帮助开发和处理规范化,在日常开发工作中,可以大幅度降低开发过程中的沟通和错误排查成本,从而提高开发效率和正确率。
REST
REST(Representational State Transfer,表示层状态转移) 是一种网络应用操作指令和信息的表示风格,它可以用于架构各种可扩展和灵活的Web应用。笔者理解,这里面包括两个方面的主要内容。
首先是表示层状态,就是如何表达资源。HTTP使用URL方式对资源进行标识,但除了规定所使用的字符集合之外,其实没有限制信息表达的方式,由应用开发者自行决定。这样就造成了我们可以看到很多不同的实现和风格。最常见的当然就是目录方式,这个风格应该是来源于在各种操作系统中使用的标准的文件系统的风格。可能是在最早的Web服务器实现中,可以直接映射文件系统的缘故吧。
还有一种就是queryString,它的标准格式为"?k1=v1&k2=v2",可以和目录区分出来,并且表达多个参数和值。当然也可以两者混合使用,HTTP本身并没有什么严格的限制,只是大家约定俗成使用这种方式来表达和处理这种参数方式而已。因此,其实如果开发者愿意,也可以完全自己定义参数的构成形式,如使用竖线分隔,分号分隔等等,那样显然就需要自己编写参数解析的方法。
在PUT和POST方法中,使用额外的数据体来承载业务数据。它们本质上也是一类业务参数,但它们的表达和组织那就更自由了,开发者可以使用任意方式来表示这些参数。当然,由于一些历史残留的原因,比如POST表单,请求提交的数据,习惯和约定上,会把它们也编码成queryString的模式,作为POST请求数据体的内容提交。
然后是状态转移。其实在HTTP协议的定义和应用中,已经有了很好的规范和样例。HTTP的Method就是用于语义化的表达这些状态转移的行为和动作。它将所有的资源状态转移都抽象成为四种行为,即GET-获取,POST-创建,PUT-更新和
DELETE-删除,从而在广义上实现任意类型的资源状态转移和业务需求。
REST风格作为其中的一种应用风格和约定,笔者的理解(再次强调,其实这里没有严格的标准)其要点是:
- 主要使用路径方法,来表达资源和其层次逻辑
- 遵从HTTP的语义模型,对资源使用一致的访问方式,HTTP方法+资源路径
- 无状态和会话过程,简单的请求-响应模型
- 原则上,不使用queryString,而是使用路径来表示操作和参数的组合
- 原则上,不使用文件名风格,即不在最后一个路径字段使用xxx.yyy模式
下面是一些示例,来帮助我们理解这个风格:
GET /users - 获取所有用户
POST /users - 创建新用户
GET /users/123 - 获取ID为123的用户
PUT /users/123 - 更新ID为123的用户
DELETE /users/123 - 删除ID为123的用户
可以看到,REST其实强调简洁,清理,高效和容易理解,这样可以简化软件接口的设计,也有可能带来更好的性能和可灵活性。我们在现在觉得这些特点都是理所当然,但如果了解其原有设计时代的一些竞争性的技术,比如SOAP、XML等等,就可以理解笔者为什么要这样说了(图为SOAP结构定义)。
基础结构
在通用层面,REST可以提供最大的灵活性和兼容性。但在具体的业务实现层面,这一范围过于宽泛,反而有可能造成开发实践中的困难和困难。为此,笔者基于日常工作和应用开发中的经验,结合常见而实际的业务场景,提出了一个稍微改进的版本,其实可以看成是一个简单的REST框架和规范。
这里的设计暂时只针对API接口设计而言。我们设想的一个REST URL接口由固定的以下部分组成:
|METHOD|: [HOST:PORT]/[APPROOT]/[MODULE]/[ACTION]/[DATA]
- METHOD:
标准的HTTP语义定义的请求方法。在业务层面,应当实现包括和实现以下三种方式GET、POST和DELETE。
其中GET用于获取和查询数据。查询的参数由DATA来控制; POST用于修改和更新数据。参数在BODY中,URL的DATA为空。DELETE用于删除数据。删除的参数由DATA来控制。
- HOST:PORT (端点主机)
从业务客户端或者前端程序,访问REST应用的端点地址。这个地址可能有负载均衡服务(如Haproxy)和应用网关来提供,和业务应用没有直接关系,只作为在应用内部处理路径的一个参考。
- APPROOT (应用程序路径):
AppRoot是应用的标准API的根路径,考虑到单个Web服务或者网关可能会支撑多个应用,所以需要一个命名空间将不同的应用进行隔离开来,当然APPROOT也可以是"",这样Web的根路径就是API的根路径。
- MODULE (模块路径):
Module是程序模块名称,通常为业务模块和授权隔离的依据。逻辑上,不同的MODULE是给不同角色或者身份的请求来服务的。但根据具体业务,也可以方便的在模块内进行角色和授权检查。
- ACTION (行为路径):
Action是业务行为,这是业务模块内部的功能化和模块化的基础,不同的业务行为,操作不同的数据集合和数据处理。
- DATA (数据路径):
Data是业务行为和模型的参数。在方法为POST时, DATA应当在Body中(URL Data本身为空)。而在方法为GET和DELETE时,DATA应该作为一个整体,体现在URL中。为了满足单个URL区段,可以支持可扩展的结构化参数的应用需求,并支持各种编码方式和字符集(如UTF8,业务需要),我们规定DATA使用Base64URL的编码方式(详见后面的编码和转换章节)。
这一模式的方便和优势如下:
- 路径的一致性,开发时很容易进行判断和配置
- 处理方式的一致,无需考量不同字符集、信息结构和数据格式的差异
- 满足和符合HTTP协议,方便互操作和扩展
- 对于查询可缓存和提高性能
- Base64编码的查询参数,对于管理类的业务系统而言,还能带来一个潜在的好处就是安全性较好,因为就是无法对流量进行直接的关键字检索和分析
Base64URL编码和转换
Data区段的目的是使用一个单一URL路径区段,来承载业务参数。这些参数可能有比较复杂的结构,如包括层次结构和数组等等。现实业务场景中的请求参数其实是多种多样的,所以笔者认为无论是路径分段或者查询字符串风格的表达方式,可能都没法很好的满足稍微复杂一点的应用需求。
显然,现实世界里已经有了一个现成而且非常好的技术就是-JSON,它可以方便而易用理解的表达层次和结构化的信息,也可以方便高效的转换为字符串(JSON.stringify)并且回转。主要的问题是它的内容不能直接用在URL中,不符合规范,我们就需要将其再次转换为Base64URL字符串形式就可以了。
为什么是Base64URL? 如果在前面的data区段,直接使用Base64,则容易在编码后的内容中出现路径符号"/",因为它是base64编码的标准字符。这一会破坏规范化的路径组织方式,造成无法正确处理URL结构。解决的方法是使用Base64URL编码。
使用Base64URL编码,同样可以使用一个url友好的字符串,来表达一个结构化的数据。Base64URL是标准的Base64编码的简单变种,它使用减号替换'+', 下划线替换"/",并去除用于补位的=号。无论在Nodejs环境,或者浏览器环境中,使用Javascript标准库,都可以对其进行方便的转换和处理。
1 UTF8转Base64
不使用第三方库,在浏览器环境中,将UTF8字符串转换为base64,可以使用:
b64str = btoa(String.fromCharCode.apply(null,new TextEncoder().encode("China中国")));
还有一种方式:
btoa(unescape(encodeURIComponent(str)));
如果是nodejs环境,更简单:
b64str = Buffer.from("China中国").toString("base64");
2 Base64转UTF8
nodejs环境:
str = Buffer.from(b64str,"base64").toString();
使用TexcDecoder:
str = new TextDecoder().decode(new Uint8Array([...atob(b64str)].map(c=>c.charCodeAt(0))))
3 Base64转Base64URL
就是简单的替换:
base64url = base64.replace(///g, '_').replace(/+/g,"-").replace(/=/g,"")
如果特别看重处理效率,则应当改写默认的Base64实现,直接在字节数组层面来映射字符,而不是编码后进行字符串替换。
Fastify中的实现
下面我们来看看,上面这些设计,是如何在fastify中实现的。fasitify是一个nodejs web应用开发框架,和express类似,关于其具体应用,读者可以参考其网站和文档。
fastify中提供了路由和相关的模块,可以定义URL的路由结构,识别URL中的路径模式,并且在后续处理中,直接使用映射的变量。
笔者通常使用如下的方式,来配置fastify web服务实例:
// 创建fastify web应用实例
const app = require('fastify')();
// 定义api路径和参数模板
const
appath = "/api",
pathGet = "/:module/:action/:qparam",
pathPost = "/:module/:action";
// 配置get和post方法
app
.get(appath + pathGet, (q,p)=>{ /* request handle code */ } )
.post(appath + pathPost, (q,p)=>{ /* request handle code */ } );
// 使用路由参数 in handle code
switch(q.params.module) {
case "somemodule":
// require module
break;
}
// qparam 处理
app.addHook("preHandler", (q,p)=>{
if (q.raw.method == "GET" && q.params.qparam) {
let sparam = q.params.qparam.replace(/-/g,"+").replace(/_/g,"/");
let buf = Buffer.from(sparam,"base64");
// base64 string to json if exist
if (buf[0] == 0x7b && buf.slice(-1)[0] == 0x7d) { //
q.body = {...JSON.parse(buf.toString("utf8")) };
} else { // raw string
q.body.raw = buf.toString();
};
q.body._ip = q.ip;
}
});
这里简单说明和解释一下:
- fastify可以使用字符串模板,来定义路径模型
- 在请求时,如果符合模板定义的模式,则会自动将路径内容,注入到q.params对应变量中
- 在具体业务实现模块中,可以通过request对象(q)的params来访问这些内容
- 在所有业务模块中,都使用相同的method,action,body对象和处理模式
- 此处,使用了预处理方法,来处理GET请求中URL的参数,将其从base64字符串转换为json对象,并放入body中
- 后续,对于所有业务模块,参数都是一种格式,而且都在body中,和请求的方式和地址都无关了
- 类似的,可以做很多预处理的工作可以统一进行,比如处理ip地址(访问控制),处理token,校验参数,访问控制等等,不需要在业务模块中单独处理,使业务模块可以专注于业务处理