筋斗云框架:REST-RPC风格服务接口实例分析

无论设计原生手机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),parampostParam分别为通过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

你可能感兴趣的:(应用服务器,Web应用,移动框架,协议设计,筋斗云)