无论设计原生手机App,或是前面文章曾提及过的“变脸式应用”(一种无网页刷新的多页面Web应用),都需要后端应用服务器提供业务支持。于是,如何设计后端服务接口是开发前必须考虑清楚的一件事。
谈及接口设计,我们需要从两个维度来考虑:协议(Protocol)及原型(Prototype),简称2P维度。
原型定义了一个调用的抽象形式。假定要做上门送餐业务,每个“商户”是个对象,取名为”Store”,那么一个“查询商户列表”接口,可以设计其原型为:
Store.query() -> tbl(id, name, dscr)
原型中,描述了调用名为Store.query
,参数为空,返回Table类型的数据表示一张表(下文有对此类型介绍),表中的每行有id及name等几列,代表一个商户对象的属性。
接着,需要设计协议来实现它。可以这样来实现上述调用:客户端对服务端的请求基于HTTP协议,使用HTTP GET或POST方法,将调用名放在URL末尾,将参数放在URL参数中(此处没有),服务端返回数据使用JSON格式来描述。于是,客户端需要像这样发出HTTP请求:
GET /api/Store.query
这便是筋斗云框架的服务接口设计。事实上,筋斗云框架是对DACA架构的实现,DACA全称“分布式访问和控制架构”,其中就有定义客户端-服务器如何通讯,即对上例中的设计方案进一步规范化,称为BQP协议(业务查询协议);DACA还规定了客户端如何调用服务接口,称为客户端公共调用接口,如下文将介绍的callSvr
调用。
BQP协议的设计风格介于RESTful和RPC之间,故称为REST-RPC风格。Leonard Richardson 和 Sam Ruby 在他们的著作 RESTful Web Services 中引入了术语 REST-RPC 混合架构(中文版译文),它不像SOAP或XML-RPC那样使用额外的信封格式来包装调用名和参数,而是直接通过HTTP传输数据,这与 REST 样式的 Web 服务是类似的;但是它不使用标准的HTTP PUT/DELETE等方法操作资源,而且在URI中存储调用名(例子中是Store.query
)。
我们考察协议设计的主要原则有:
- 清晰易懂
- 易实现
- 传输及处理效率高
对照这些原则,RESTful风格清晰易懂,但像HTTP PUT/DELETE等方法的兼容性并不好,不论服务端或客户端在实现上都会遇到障碍;而使用RPC风格的设计,不仅可读性差很多,而且封包解包效率较低。所以,从实用的角度,筋斗云的设计思想认为,REST-RPC是目前更好的选择。
在BQP协议中,业务接口分为函数调用型接口(如login
调用)和对象调用型接口(如Store.query
调用)。函数调用型接口可以自由设计原型,而对象调用型接口其实是一种特殊的函数调用,用于操作业务对象,有相对固定的原型,设计者可以对它加以裁减或扩展。两类接口在通讯协议及客户端使用上没有太大区别,其主要区别在于后端服务的实现模型不同。
本文只讨论对象调用型接口。BQP协议定义了一个对象的五种标准操作:查询列表(query),获取明细(get),添加(add),更新(set)和删除(del)。下文将详细举例说明,我们先假定有“商户”(Store)这个对象,其数据模型描述如下:
@Store: id, name, addr, tel, dscr
这表示商户表Store,有id, name等字段。注意:DACA规范建议,在设计数据模型时,应以id作为主键。
DACA规范要求客户端应提供callSvr
方法来调用服务接口,在筋斗云前端中,该接口为JS函数,其原型为
callSvr(ac, param?, fn?, postParam?, userOptions?) -> XMLHttpRequest
或
callSvr(ac, fn?, postParam?, userOptions?) -> XMLHttpRequest
其中ac
表示调用名(action),param
和postParam
分别为通过URL和POST内容传递的参数,如果没有param
,可以忽略该参数(即第二种原型)。fn
为回调函数,调用格式为fn(data)
,其中参数data
为返回的JSON对象,类型参考接口原型中的返回值描述。
带问号的参数表示可缺省。
函数返回XMLHttpRequest对象,与jQuery中的$.ajax返回值相同。
以上调用为异步方式,即该函数执行后立即结束,待服务端数据返回再回调函数fn
。也可以做同步调用,只要将函数名callSvr
改为callSvrSync
,即意味着该函数将等服务端返回数据才结束,而且,其返回值不再是XMLHttpRequest对象,而是服务接口返回的JSON对象。我们在Chrome控制台窗口测试接口时,常用同步调用以方便看到结果。
只要熟悉这几个客户端接口,就可以根据设计文档中的接口原型调用任何接口了,不必再对BQP底层协议细节有深入了解。
添加对象
BQP协议中定义对象添加操作的原型如下:
{object}.add()(POST fields...) -> id
一般在原型定义中参数部分只用一个括号,表示参数通过URL或POST内容传递都可以。而这里出现了两个括号,就表示URL参数和POST参数不可混用,两个括号依次表示URL参数和POST参数。
这样,我们添加一个商户,可以用:
var postParam = {name: "华莹小吃", addr: "银科路88号", tel: "13712345678"};
callSvr("Store.add", api_StoreAdd, postParam);
function api_StoreAdd(data) {
// 根据原型定义中的返回值,data是id值。
alert("id=" + data);
}
由于没有URL参数,所以callSvr
的第二个参数可以省略。如果想写完整,会像这样:
callSvr("Store.add", null, api_StoreAdd, postParam);
调用成功,则会调用指定的回调函数,如果调用失败,则前端框架会接管错误处理,调用者一般不必关心。
更新对象
原型为:
{object}.set(id)(POST fields...)
其中未指定返回值,表示调用成功时无特定返回值。筋斗云后端会返回字符串”OK”。
假如要更新id=8
这家商户对应的联系电话:
var param = {id: 8};
var postParam = {tel: "13812345678"};
callSvr("Store.set", param, api_StoreSet, postParam);
function api_StoreSet(data)
{
alert("更新成功");
}
注意:要更新的字段一定要放在POST参数中。
置空一个字段
在BQP协议中,设置一个字段为空串一般是被服务端忽略的,但在set操作中,如果在postParam中设置某个字段为空串(或特定字符串"null"
),则表示清空该字段。
要清空某商户的地址:
var postParam = {addr: ""};
// 或者 var postParam = {addr: "null"};
callSvr("Store.set", {id: 8}, api_StoreSet, postParam);
下次用Store.get
获取该商户时,可见属性addr
值为null
(注意:不是字段串"null"
)
删除对象
原型为:
{object}.del(id)
调用很简单,假如要删除id=8
对应的商户:
callSvr("Store.del", {id: 8}, function (data) {
alert("删除成功");
});
获取对象详情
原型为:
{object}.get(id, res?) -> {fields...}
其默认返回对象对应主表中的字段,设计时也可以为返回内容增加子对象或虚拟字段(实现方法参考筋斗云后端文档)。
假定在设计“获取商户”接口时,增加一个子对象“商品列表”名为items
,设计接口原型为:
Store.get(id, res?) -> {id, name, addr, tel, @items=[item]}
item:: {id, name, price}
(注意:在设计接口原型时,用的是“蚕茧表示法”层层解析和描述对象类型,不在本文讨论范围内,详见相关文章。)
根据接口,要获取一个商户的详情可以这样调用:
callSvr("Store.get", {id: 8}, api_StoreGet);
function api_StoreGet(data) { ... }
返回数据data
像这样:
{
id: 8,
name: "华莹小吃",
addr: "银科路88号",
tel: "13812345678",
items: [
{id: 1001, name: "鲜肉小笼", price: 10.0},
{id: 1002, name: "大肉粽", price: 8.0}
...
]
}
在URL中的可选参数res
它表示”result”,即返回字段的列表,多个字段中间用逗号分隔。如果你不想返回默认的字段,可以通过该参数指定想要哪些字段。
例:获取商户详情,只返回店名和电话:
callSvr("Store.get", {id: 8, res: "name,tel"}, api_StoreGet);
function api_StoreGet(data)
{
// data示例:{name: "华莹小吃", tel: "13812345678"}
}
查询对象列表
查询操作是标准操作中最灵活和最复杂的,它的可选参数很多,原型有两种(返回内容的格式不同):
{object}.query(res?, cond?, orderby?, distinct?=0, _pagesz?=20, _pagekey?, _fmt?) -> tbl(field1,field2,...)
{object}.query(wantArray=1, ...) -> [{field1,field2,...}]
第一种原型返回特别的Table类型(下文介绍,可以转成对象数组),好处是数据精练,而且支持分页;第二种原型多了wantArray
参数的设置(其它参数用法相同),返回类型变成对象数组,支持子对象,然而它不支持分页操作,一般使用较少。
Table类型
如果未指定参数wantArray
(第一种原型),则返回的内容为Table类型,这种格式不可以返回子对象(如上节get操作中的子对象商品列表items),比如取商户列表:
callSvr("Store.query", api_StoreQuery);
function api_StoreQuery(data) { ... }
回调函数api_StoreQuery
中的data参数格式为:
{
h: [ "id", "name", "addr", "tel"]
d: [
[ 8, "华莹小吃", "银科路88号", "13812345678"],
[ 9, ... ]
...
]
nextkey: 998
}
其中属性h
为列名数组,d
表示数据行数组,每行的值数组与列名数组中元素一一对应。
如果存在属性nextkey
,则表示这只是一部分数据,要取下一页数据,可以用同样的查询,带上参数_pagekey
设置为该值,如
callSvr("Store.query", {_pagekey: 998});
这种Table结构设计有利于传输效率的提高,同时便于分页机制的设计。
筋斗云前端提供函数rs2Array
,可将这个数据转换成通常用的对象数组:
var arr = rs2Array(data);
得到的arr
像这样:
[
{id: 8, name: "华莹小吃", addr: "银科路88号", tel: "13812345678"},
{id: 9, ...}
...
]
查询参数
对象查询支持灵活的查询条件(通过参数cond
- condition),排序方法(参数orderby
),返回字段(参数res
,与get操作一样)。
如果你了解SQL语句,则会发现这些参数用起来很简单。
- 参数
res
指定返回字段, 多个字段以逗号分隔,例如, res=”field1,field2”. - 参数
cond
指定查询条件,其语法类似SQL语句的”WHERE”子句,例如”field1>100 AND field2=’hello’”,注意字符串值要加上单引号。 - 参数
orderby
指定排序条件,语法可参照SQL语句的”ORDER BY”子句,例如:orderby=”id desc”,也可以多字段依次排序:”tm desc,status” (按时间倒排,再按状态正排)
例如,要查询所有id
小于10且名字中以”华莹”开头的商户,返回结果按名字(name)排序:
var cond = "id<10 and name like ‘华莹%‘";
var param = {res: "id,name,addr", cond: cond, orderby: "name"};
callSvr("Store.query", param, api_StoreQuery);
function api_StoreQuery(data)
{
// 先用rs2Array将table类型的数据转成对象数组
var arr = rs2Array(data);
// 遍历每个商户
arr.forEach(function(store) {
// 由于指定了res参数,store对象类型为:{id, name, addr}
});
}
尽管这些参数值类似SQL语句,但它们有一些安全限制:
- res, orderby只能是字段(或虚拟字段)列表,不能出现函数、子查询等。
- cond可以由多个条件通过and或or组合而成,而每个条件的左边是字段名,右边是常量。不允许对字段运算,不允许子查询(不可以有select等关键字)。
像参数cond
中出现以下情况都不允许:
left(type, 1)=‘A‘ -- 条件左边只能是字段,不允许计算或函数
type=type2 -- 字段与字段比较不允许
type in (select type from table2) -- 子表不允许
分页支持
参数_pagesz
和_pagekey
用于支持分页。_pagesz
指定每次返回多少条数据(默认一次返回20条)。
下面是一个获取所有商户的例子。第一次查询:
callSvr("Store.query")
返回数据像这样:
{nextkey: 10800910, h: [id, ...], d: [...]}
其中的nextkey
表示数据未返回完,要查询下一页时需填写_pagekey
字段。
第二次查询(下一页):
callSvr("Store.query", {_pagekey=10800910});
返回:
{nextkey: 10800931, h: [...], d: [...]}
仍返回nextkey
字段说明还可以继续查询,再查询下一页:
callSvr("Store.query", {_pagekey=10800931});
返回:
{h: [...], d: [...]}
返回数据中不带nextkey
属性,表示所有数据获取完毕。
如果想在首次查询时返回总记录数,可以设置_pagekey=0
:
callSvr("Store.query", {_pagekey: 0})
这样会返回
{nextkey: 10800910, total: 51, h: [id, ...], d: [...]}
多了total
字段表示总记录数。由于缺省页大小为20,所以可估计总共有51/20=3页。
对象列表导出
在对象查询接口中设置参数_fmt
,可以输出指定格式,一般用于将列表导出到文件。参数支持以下值:
- csv:逗号分隔UTF8编码文本
- txt:制表符分隔的UTF8文本
- excel:与csv类似,但使用GB2312编码中文,以便不支持UTF8编码的MS Excel可以直接打开。
注意,由于默认会有分页,要想导出所有数据,一般指定一个很大的页大小,如_pagesz=9999
。
例如,要导出商户列表,可以这样写:
var url = makeUrl("Store.query", {_fmt: "excel", _pagesz: 9999});
location.href = url; // 下载Excel文件
注意:一定要使用框架提供的函数makeUrl
生成URL,不要手写URL。它的用法与callSvr
类似,传入调用名ac
与URL参数param
。