Web接口服务框架设计

需求和设计目标:

1 概要设计

1.1 文件结构

概念:

注:以下标记*表示不需要开发者修改的框架实现源文件或工具。

[根目录]

DESIGN.wiki
主设计文档。其它文档在doc目录下。
build_web.sh
Web部署工具。

产品设计文档包括:

[后端应用 - server目录]

api.php
接口应用程序,提供Web服务接口。例如调用接口Ordr.query可访问http://myserver/mysvc/api.php/Ordr.query;该文件包含其它实现文件,以及应用内共享的数据。其它应用可包含它从而直接以内部调用方式访问API接口。
app.php
应用共享库。存放所有应用或工具共享的数据和函数。所有应用一般都应包含它。它包含common.php,app_fw.php,conf.php,conf.user.php等。
php/conf.user.php
产品配置文件,一般不加入代码库(指git等版本控制系统),在部署时根据实际环境配置。被app.php包含,因而所有应用或工具都间接包含它。
conf.php
被api.php包含,保存易变的程序逻辑。
php/api_functions.php
API接口应用中的函数实现部分。被api.php包含。
php/api_objects.php
API接口应用中的对象访问实现部分。被api.php包含。

框架实现部分:

php/common.php*
通用共享库。基础公共函数部分,可适用一切php项目。
php/app_fw.php*
应用框架库。为所有应用提供框架支持(以app_开头表示适用于所有应用,fw表示framework),被app.php包含。
php/api_fw.php*
API接口应用的框架实现(以api_开头表示属于API接口应用)。被api.php包含。

[工具 - tool目录]

upgrade.php*
数据部署工具。创建或更新数据库表,导入数据等。
webcc.php*
Web部署工具。用户上传或更新线上Web产品目录。
cmdtool.template.php
使用app.php创建数据操作工具的示例,如用于特殊数据导入等。

[回归测试 - rtest目录]

rtest.php
回归测试内容。(TODO: 类似api应用,可拆分为 rtest.php, php/rtest_fw.php, php/rtest_group1.php, php/rtest_group.php等)
run_rtest.pl*
回归测试执行工具。
client.php*
手工测试工具。模拟前端调用API接口。

内部实现部分:

WebAPI.php
测试应用框架库。

[前端应用]

如果有筋斗云前端应用,一般放置在以下目录:

m2/
移动端H5应用,如用户端、员工端应用。
web/
桌面版H5应用,如后台管理端应用。

1.2 运行环境

[部署环境]

[开发环境]

[演示版]

演示版用于快速开发原型及测试演示,对运行环境低要求,部署极其简单。

1.3 配置项

在应用设计时,一般使用前缀名为"P_"的环境变量供使用时扩展。

一般用在浏览器中打开URL tool/init.php进行配置。之后,可手工修改配置文件php/conf.user.php。一般使用putenv设置环境变量。

[数据库连接]

例:

putenv("P_DB=localhost/mysvc");
putenv("P_DBCRED=bGo6aWhxZ19VR0xH");

[URL路径]

如部署路径为 http://myserver.com/mysvc/ ,则应设置

putenv("P_URL_PATH=/mysvc");

如果不设置,应用将自动判断(但如果服务器上使用了符号链接,则会判断失误)。

注意:

常用环境变量如下:

[测试模式 - TEST_MODE]

[模拟模式 - MOCK_MODE]

这时,对外部系统的依赖(如短信模块,微信接口,支付宝支付等)都将模拟运行,只生成日志到 ext.log 中。

可通过工具 tool/log.php 查看日志。

1.4 命名规范

变量/函数名/数据库表的字段名使用驼峰式(首个单词小写,其余单词首字母大写),如getCarModel, svcId。

WebAPI的调用名和传入参数也采用驼峰式,由于目前实现时不区分大小写, 所以调用时也可以用全部小写(习惯上,url里常常只用小写字母,且不带下划线。) 例如,调用接口名queryOrder,传入参数为modelId, storeId等。

类名,或数据库表名,用大驼峰式(或叫Pascal命名,所有单词首字母大写), 如OrderStatus.

2 数据库设计

根据[系统建模]设计数据库表结构。

[通用规则]

字段名的类型可在字段后标示,例如:status(2)表示2字符长度的字符串(nvarchar(2)), 创建时间(dt)表示date类型。

标记 类型
s small string(20)
l long string(255)
t text(64K)
tt mediumtext(16M)
i int
n numeric(decimal)
date date
tm datetime
flag tiny int
数字 指定长度的string
不指定 自动判断

如果未指定类型,则根据命名规范自动判断,比如以id结尾的字段会被自动作为整型创建,以tm结尾会被当作日期时间类型创建,其它默认是字符串(长度50),规则如下:

规则 类型
以"Id"/"Cnt"结尾 Integer
以"Price"/"Total"/"Qty"/"Amount"结尾 Currency
以"Tm"/"Dt"结尾 Datetime/Date
以"Flag"结尾 TinyInt(1B) NOT NULL

例如,"total", "docTotal", "total2", "docTotal2"都被认为是Currency类型(字段名后面有数字的,判断类型时数字会被忽略)。

也可以用一个类型后缀表示,如 retval&表示整型,规则如下:

后缀 类型
& Integer
@ Currency
# Double
! Float

注意: - 一些名字由于与某些数据库系统关键字冲突应避免,如不使用"desc", 改用"dscr" (description).

TODO: define unique-key, index, not null, default value

3 通讯协议设计

3.1 通用原则

客户端通过HTTP协议与服务端交互,调用服务端WebAPI。

参数未加说明的, 可以选择通过URL或POST传参.

注意Content-Type需要设置正确, 少数例外情况会特别指出,比如upload方法,它使用"Content-type: multipart/form-data"。

以下面的WebAPI描述为例:

根据id取车型信息:
getModel(id) -> {id, name, dscr}

它包含以下信息:

之后的示例中,返回内容将被简化描述为:

    {id: 100, name: "myname", dscr:"mydscr"}

[错误码定义]

常用错误码如下:

const E_OK=0;
const E_PARAM=1;
const E_AUTH=2;
const E_DB=3;
const E_SERVER=4;
const E_FORBIDDEN=5;

$ERRINFO = [
    E_PARAM => "参数不正确",
    E_AUTH => "未认证",
    E_DB => "数据库错误",
    E_SERVER => "服务器错误",
    E_FORBIDDEN => "禁止操作"
];

[关于空值]

假如有参数"a=1&b=&c=hello", 其中参数"b"值为空串。 一般情况下,被当作未赋值处理,即与"a=1&c=hello"意义相同。

只有在对象保存上下文中(典型的是通用对象接口的set操作),且出现中POST内容的"a="表示将该字段置null(与"a=null"语义相同), 注意:不是置空字符串。

3.1.1 常用返回类型描述

{id, name}

一个简单对象,有两个字段id和name。e.g. {id: 100, name: "name1"}

[id...] or [id]

一个简单数组,元素为id。e.g. [100, 200, 400], 每项为一个id

[id, name]

一个简单数组,e.g. [100, "liang"],第一项为id, 第二项为name

[ [id, name] ] 或 varr(id, name)

简单二维数组,又称varr, 如 [ [100, "liang"], [101, "wang"] ].

[{id, name}] 或 objarr(id, name)

一个数组,每项为一个对象,又称objarr。e.g. [{id: 100, name: "name1"}, {id: 101, name: "name2"}]

tbl(id, name)

table对象。其详细格式为 {h: [header1, header2, ...], d:[row1, row2, ...]},例如

{
  h: ["id", "name"],
  d: [[100, "myname1"], [200, "myname2"]]
}

table对象支持分页机制(paging),返回字段中包含"nextkey"等。 详情请参考下一章节"分页机制".

注意:

[可选参数]

如果API的参数表示为:

fn(p1, p2?, p3?=1)

它表示:

[复杂类型序列化为字符串描述]

有时用一个字符串字段表示复杂的结构,这时常以下类型描述方式:

3.1.2 分页机制

如果一个查询支持分页(paging), 则一般调用形式为

Ordr.query(pagekey?, pagesz?=20) -> {nextkey, total?, @h, @d}
或
Ordr.query(page, rows?=20) -> {nextkey, total, @h, @d}

[参数]

pagesz
Integer. 页大小,默认为20条数据。
pagekey
String (目前是数值). 一般某次查询不填写(如需要返回总记录数即total字段,则应填写为0),而下次查询时应根据上次调用时返回数据的"nextkey"字段来填写。
page/rows
为支持jquery-easyui而设置, 与pagekey/pagesz类似, 区别在于: 每次均返回total字段; 强制采用"limit"算法(默认如果没有用非主键排序,会采用"部分查询"算法), 意味着nextkey即下一页页码.

[返回值]

nextkey
String. 一个字符串, 供取下一页时填写参数"pagekey". 如果不存在该字段,则说明已经是最后一批数据。
total
Integer. 返回总记录数,仅当pagekey指定为0时返回。
h/d
实际数据表的头信息(header)和数据行(data),符合table对象的格式,参考上一章节tbl(id,name)介绍。

[示例]

第一次查询

Ordr.query()

返回

{nextkey: 10800910, h: [id, ...], data: [...]}

其中的nextkey将供下次查询时填写pagekey字段;首次查询还会返回total字段。由于缺省页大小为20,所以可估计总共有51/20=3页。

要在首次查询时返回总记录数,则用pagekey=0:

Ordr.query(pagekey=0)

这时返回

{nextkey: 10800910, total: 51, h: [id, ...], data: [...]}

第二次查询(下一页)

Ordr.query(pagekey=10800910)

返回

{nextkey: 10800931, h: [...], d: [...]}

仍返回nextkey字段说明还可以继续查询,

再查询下一页

Ordr.query(pagekey=10800931)

返回

{h: [...], d: [...]}

返回数据中不带"nextkey"属性,表示所有数据获取完毕。

3.1.3 分页机制实现原理

分页有两种实现方式:分段查询和传统分页。

分段查询性能高,更精确,不会丢失数据。但它仅适用于未指定排序字段(无orderby参数)或排序字段是id的情况(例如:orderby="id DESC")。 系统将根据orderby参数自动选择分段查询或传统分页。

[分段查询]

分段查询的原理是利用主键id进行查询条件控制(自动修改WHERE语句),pagekey字段实际是上次数据的最后一个id.

首次查询:

Ordr.query()

SQL样例如下:

SELECT * FROM Ordr t0
...
ORDER BY t0.id
LIMIT {pagesz}

再次查询

Ordr.query(pagekey=10800910)

SQL样例如下:

SELECT * FROM Ordr t0
...
WHERE t0.id>10800910
ORDER BY t0.id
LIMIT {pagesz}

[传统分页]

传统分页只需要通过SQL语句的LIMIT关键字来实现。pagekey字段实际是页码。其原理是:

首次查询

Ordr.query(orderby="comeTm DESC")

(以comeTm作为排序字段,无法应用分段查询机制,只能使用传统分页。)

SQL样例如下:

SELECT * FROM Ordr t0
...
ORDER BY comeTm DESC, t0.id
LIMIT 0,{pagesz}

再次查询

Ordr.query(pagekey=2)

SQL样例如下:

SELECT * FROM Ordr t0
...
ORDER BY comeTm DESC, t0.id
LIMIT ({pagekey}-1)*{pagesz}, {pagesz}

3.1.4 HTTPS服务

服务端支持HTTPS服务。客户端默认应使用HTTP协议与服务器通信;对于个别敏感的API,如涉及用户密码的登录、注册、修改密码等操作,应使用HTTPS协议进行通信。

注意:

3.1.5 调试等级(_debug)

在服务端配置为测试模式时,可用特殊的URL参数"_debug"定义调试等级, 默认为0. 如果为1-9的数字, 将添加调试信息到结果数组中.

_debug=9: 输出SQL

通过设置环境变量P_DEBUG, 可以让本系统使用的测试工具(如client.php及rtest.php)请求时指定调试等级. 如:

set P_DEBUG=9
client.php callsvr usercar.query

3.1.6 应用标识(_app)

特殊的URL参数"_app"用于定义当前应用. 缺省值为"user"(对应客户端应用). 注意: 每个请求都必须带此标识, 它决定session对应的cookie项的名字. 如果不加该参数, 则可能出现未预料的错误.

常用应用标识如下:

user
缺省值. 一般为用户端。
emp
员工端。
admin
超级管理端。开发者或IT管理员使用。

由于不同应用(如客户端, 商户端与管理端)共用一个api.php页面, 当在同一浏览器中打开多个应用时可能相互影响, 比如商户端与管理端同时使用时, 商户端会自动以管理端的权限操作.

解决方法是, 为不同应用使用不同的sessionId以区分. 实现时有两种方式, 一是访问不同的服务端页面, 如商户端访问api.php, 管理端访问api0.php(其中指定一个不同的sessionId); 另一种是商户端所有的请求都带一个参数指定sessionId. 我们将采用后一种解决方法.

URL参数_app可用于指定所属应用, 如不指定默认值为"user". 它隐含着session的名称为"{_app}id"如请求

GET /api.php?_app=emp

第一次访问将返回

SetCookie: empid=xxxxxx

3.1.7 使用PATH_INFO模式的URL

以下URL等价:

http://localhost/mysvc/api.php/login?phone=137&pwd=1234
->
http://localhost/mysvc/api.php?ac=login&phone=137&pwd=1234

对于对象的CRUD,URL像这样:

http://localhost/mysvc/api.php/Ordr.query?res=id,dscr
->
http://localhost/mysvc/api.php?ac=Ordr.query&res=id,dscr

3.1.8 客户端版本(_ver)

URL参数"_ver"值为客户端版本。取值参考表ApiLog.ver字段。目前只有安卓客户端设置该参数为"a/{ver}" (如"a/2"), 其它客户端版本根据userAgent自动获取。

3.1.9 权限说明

要访问每个API,必须定义相应的权限。权限中包括登录类型一般用AUTH_XXX表示,一般权限用PERM_XXX表示。

员工登录后可能获得以下一个或多个权限: - PERM_MGR: 操作商户的所有内容, 包括订单, 物料, 员工等(但不能设置员工权限).

其它权限: - PERM_TEST_MODE: 测试模式下可用。 - PERM_MOCK_MODE: 模拟模式下可用。

3.2 通用对象接口

以下接口提供对象的基本增删改查(CRUD)以及列表查询、统计分析、导出等服务,为通用对象接口原型,在设计时应以此为基础形式,再按业务逻辑定义专用接口,描述权限和业务逻辑等。 下面用Obj代指对象实际名称。

添加:
Obj.add()(POST fields...) -> id
Obj.add(res?)(POST fields...) -> {fields...} (由res指定)

更新:
Obj.set(id)(POST fields...)

获取:
Obj.get(id, res?) -> {fields...}

删除:
Obj.del(id, force?)

查询列表(默认压缩表格式):
Obj.query(res?, cond?, distinct?=0, pagesz?=20, pagekey?, fmt?) -> tbl(fields...) = {nextkey?, total?, @h, @d}

查询列表 - 对象列表格式:
Obj.query(fmt=list, ...) -> {nextkey?, total?, @list=[obj1, obj2...]}

后端内部查询支持以下额外参数:@res2?, join?, @cond2?, union?, subobj

分组统计:
Obj.query(gres, pivot?, pivotCnt?...) -> tbl(fields...)

导出文件:
Obj.query(fmt=csv/txt/excel, fname?...) -> 文件内容

批量更新:
Obj.setIf(cond)(POST fields...)

批量删除:
Obj.delIf(cond)

批量添加(导入):
Obj.batchAdd(title?)(数据或文件)

缺省这些操作只对超级管理员角色开放,其他角色默认无权操作。超级管理员可对所有对象的所有字段操作。

对象是否开放出来,或是开放哪些操作及字段,一般按用户角色进行权限控制,如用户登录后可操作某些对象,或员工登录后可操作另一些对象,请查阅设计文档中相应的专用接口定义。 在专用接口定义中应描述允许的角色、允许的操作类型(如只能get/set,不能add/del)、只读字段、隐藏字段等,以及应实现的业务逻辑。

系统中的表设计(表名, 字段等)参见"数据库设计"章节. 一般表都设计为使用整形字段id作为主键. 该字段创建后变只读, 不允许被修改.

对于add操作,默认返回id, 如果想多返回其它字段,可设置res参数,如

Ordr.add()(status="CR", total=100) -> 809
Ordr.add(res="id,status,total")(status="CR", total=100) -> {id: 810, status:"CR", total: 100}

3.2.1 通用查询、导出与分组统计

query可以用参数cond指定查询条件, 如

cond="type='A' and name like '%hello%'" 

URL编码后为

cond=type%3d%27A%27+and+name+like+%27%25hello%25%27

query返回支持多种形式, 缺省返回压缩表类型如:

{
    "h": ["id", "name"],
    "d": [[1, "liang"], [2, "wang"]]
}

如果指定fmt=list则返回:

{
    "list": [{"id": 1, "name": "liang"}, {"id": 2, "name": "wang"}]
}

注意:

[分页]

pagesz
Integer. 指定页大小, 默认一次返回20条数据。
pagekey
String. 指定从哪条数据开始,应根据上次调用时返回数据的"nextkey"字段来填写。

分页只适用于query接口,详细请参考章节"分页机制".

[参数]

fields
每个字段及其值.
res, cond (get/query方法), orderby(query方法)
String. 指定返回字段及查询条件, 例如, res="field1,field2", cond="field1>100 AND field2='hello'", orderby="id desc", 注意使用UTF8+URL编码, 目前格式参照SQL语法, 字符串值应加上单引号. 字段前不可加表名或别名(alias),如"t0.id"; 在res中允许使用函数"sum"与"count", 这时必须指定字段别名, 如"count(id) cnt"

特别地,res支持枚举列表,即自动将枚举值转化为可读字符串,例如res=id 编号,status 状态=CR:创建;PA:已付款,这样返回的status字段会自动转换成相应的值。

distinct
Boolean. 如果为1, 生成"SELECT DISTINCT ..."查询.

[限程序内使用的参数]

res2, join, cond2 (get/query方法)
这几个字段只由内部使用,没有安全限制. res2, cond2为额外的字段及条件, 必须为数组; join可以为字符串或字符串数组.注意增加join表后, 指定主表字段时最好加上主表的固定别名"t0". 例如 res2=["b.name AS brandName", "s.name as storeName"], join="INNER JOIN CarBrand b ON b.id=t0.brandId", cond2=["t0.field1=100 and b.id IN (1,2,3)"]
subobj

目前仅限后端内部使用, 要求主对象必须有id字段(未指定res/res2参数或其中有id字段). 格式为数组, 每行指定一个子对象的查询, 每行格式为: {sql, wantOne?}. "sql"指定查询语句, 其中用"%d"表示主表id; "wantOne"表示返回对象而非对象集合(数组), 缺少是对象集合. 例:

$_REQUEST["subobj"] = [ "items" => ["sql"=>"SELECT * FROM OrderItem WHERE orderId=%d", "wantOne"=>false]];

union
仅限后端内部使用. 指定union查询内容, 该内容将会在以下位置影响SQL查询: "SELECT ... FROM .. WHERE ... { UNION ... } ORDER BY ...". 注意: union的结果与res参数中字段指定必须匹配; where条件必须在union中自行指定, 不可通过cond/cond2参数; orderby参数可应用到union后的最终结果.

对res, cond, orderby的安全限制:

[导出文件]

fmt
Enum(csv,txt,excel). 导出Query的内容为指定格式。其中,csv为逗号分隔UTF8编码文本;txt为制表分隔的UTF8文本;excel为逗号分隔的gb2312编码文本(因为默认excel打开Csv文件时不支持utf8编码)。注意,由于默认会有分页,要想导出所有数据,一般可指定pagesz=9999。
fname
String. 导出文件名。默认为对象名。

[分组统计]

gres
String. 用于groupby的字段列表。如果使用了gres字段,则res参数中每项应该带统计函数,如"sum(cnt) sum, count(id) userCnt". 最终返回列数=gres参数指定的列+res参数指定的列; 如果res参数未指定,则默认值不再是"*", 而是空(即只返回gres字段指定内容)。

例:统计2015年2月,按状态分类(如已付款、已评价、已取消等)的各类订单的总数和总金额。

Ordr.query(gres="status", res="count('A') totalCnt, sum(amount) totalAmount", cond="tm>='2016-1-1' and tm<'2016-2-1'")

返回内容示例:

[
    h: ["status", "totalCnt", "totalAmount"],
    d: [
        [ "PA", 130, 1420 ],  // 已付款,共130单,1420元
        [ "CA", 29, 310 ], // 取消的订单
        [ "RA", 1530, 15580 ], // 已评价的订单
    ]
]
pivot
String. 指定转置列,将行值显示到列中。
pivotCnt
Integer. 指定统计列的个数。默认值为1,即最后一列为统计列,若想最后两列为统计列,可设置pivotCnt=2。

例:统计2015年2月,按状态分类(如已付款、已评价、已取消等)的各类订单的总金额,并将状态转置到列上。

Ordr.query(gres="status", res="sum(amount) totalAmount", cond="tm>='2016-1-1' and tm<'2016-2-1'", pivot: "status")

返回内容示例:

[
    h: ["PA", "CA", "RA"],
    d: [
        [ 1420, 310, 15580 ]
    ]
]

例:统计2015年2月,按状态分类(如已付款、已评价、已取消等)的各类订单的总数和总金额(两个统计列),并将状态转置到列上。

Ordr.query(gres="status", res="count('A') totalCnt, sum(amount) totalAmount", cond="tm>='2016-1-1' and tm<'2016-2-1'", pivot: "status", pivotCnt: 2)

返回内容示例:

[
    h: ["PA", "CA", "RA"],
    d: [
        [ [130, 1420], [29, 310], [1530, 15580] ]
    ]
]

[操作特殊属性flags和props]

flags为单字母表示的标志位集合,如"vg";props可以以多字母表示标志位,中间以空格分隔,如"suv mpv". 一般flags由应用内部定义;而props扩展性更强。

query操作支持形如flag_{flag}prop_{prog}的虚拟属性。

get/query操作中如果返回了flags/props,还会返回相应的虚拟属性;例如flags值为"vg",则多返回虚拟属性flag_v=1flag_g=1

set操作支持以下方式设置flags/props属性:

注意:

[例: 添加商户]

添加商户, 指定一些字段:

Store.add()
    name=华莹汽车(张江店)
    addr=金科路88号
    tel=021-12345678

注:

操作成功时返回id值:

8

[例: 获取商户]

取刚添加的商户(id=8):

Store.get(id=8)

操作成功时返回该行内容:

{id: 8, name: "华莹汽车(张江店)", addr: "金科路88号", tel: "021-12345678", opentime: null, dscr: null}

可以像query方法一样用POST参数res指定返回值, 如

Store.get(id=8
    res=id,name as storeName,addr
)

操作成功时返回该行内容:

{id: 8, storeName: "华莹汽车(张江店)", addr: "金科路88号"}

[例: 查询商户]

查询"华胜汽车"在"浦东"的门店, 即查询名称含有"华胜汽车"且地址中含有"浦东"的商户, 只返回id, name, addr字段:

Store.query()
    res=id,name,addr
    cond=name like '%华胜%' and addr like '%浦东%'

操作成功时返回内容如下:

{
    "h": [
        "id",
        "name",
        "addr"
    ],
    "d": [
        [
            "100064",
            "华胜汽车(金桥店)",
            "上海市浦东区金桥路2622弄59号3号门"
        ]
    ]
}

[导出商户]

Store.query()
    res=id,name,addr
    fmt=excel
    pagesz=9999

可导出gb2312编码的csv文件。使用较大的pagesz以尽量返回所有数据。

[例: 更新商户]

为商户设置描述信息等:

Store.set(id=8)
    opentime=8:00-18:00
    dscr=描述信息.

操作成功时无返回内容.

[例: 删除商户]

Store.del(id=8)

操作成功时无返回内容.

3.2.2 批量更新与删除

Obj.setIf/delIf

一般使用cond参数指定查询条件,允许使用虚拟字段;也可以使用与query接口相同的其它自定义参数。

示例,下面query接口定义了虚拟字段storeName和非标查询参数q:

Item.query(q?, cond?) -> tbl(id, ..., storeName?)

- q: "my"-表示我发布的商品. 默认返回所有商品。
- storeName: 商户名,关联到Store表。

批量更新示例:

Item.setIf(cond: "status='CR')(status=ST)
Item.setIf(cond: "t0.id IN (3,5,7)")(status=ST)
Item.setIf(q:"my", cond:"storeName LIKE "上海%")(status=ST)

批量删除示例:

Item.delIf(cond: "status='ST')
Item.delIf(cond: "t0.id IN (3,5,7)")
Item.delIf(q: "my", cond:"storeName LIKE "上海%")

3.2.3 批量添加(导入)

批量添加(导入)。返回导入记录数cnt及编号列表idList

Obj.batchAdd(title?)(...) -> {cnt, @idList}

在一个事务中执行,一行出错后立即失败返回,该行前面已导入的内容也会被取消(回滚)。

支持两种方式上传:

  1. 直接在HTTP POST中传输内容,数据格式为:首行为标题行(即字段名列表),之后为实际数据行。 行使用""分隔, 列使用""分隔. 接口为:

    {Obj}.batchAdd(title?)(标题行,数据行) (Content-Type=text/plain)

前端JS调用示例:

var data = "name\taddr\n" + "门店1\t地址1\n门店2\t地址2\n";
callSvr("Store.batchAdd", function (ret) {
    app_alert("成功导入" + ret.cnt + "条数据!");
}, data, {contentType:"text/plain"});

或指定title参数:

var data = "门店名\t地址\n" + "门店1\t地址1\n门店2\t地址2\n";
callSvr("Store.batchAdd", {title: "name,addr"}, function (ret) {
    app_alert("成功导入" + ret.cnt + "条数据!");
}, data, {contentType:"text/plain"});
  1. 标准csv/txt文件上传:

上传的文件首行当作标题列,如果这一行不是后台要求的标题名称,可通过URL参数title重新定义。 一般使用excel csv文件(编码一般为gbk),或txt文件(以""分隔列,一般为utf-8编码)。 接口为:

{Obj}.batchAdd(title?)(csv/txt文件)
(Content-Type=multipart/form-data, 即html form默认传文件的格式)

后端处理时, 将自动判断文本编码(utf-8或gbk).

前端HTML:

<input type="file" name="f" accept=".csv,.txt">

前端JS示例:

var fd = new FormData();
fd.append("file", frm.f.files[0]);
callSvr("Store.batchAdd", {title: "name,addr"}, function (ret) {
    app_alert("成功导入" + ret.cnt + "条数据!");
}, fd);

3.2.4 通用子表对象操作

详细可参考文档 子表对象设计

假设有主表对象Ordr,一对多关联子表对象OrderItem,数据模型如下:

订单:
@Ordr: id, dscr, amount

物料:
@Item: id, name, price

订单明细
@OrderItem: id, orderId, itemId, itemName, price, qty, amount

[在add接口中指定子表项]

Ordr.add()(dscr, amount, @items=[{itemId, price, qty}])

示例:

var items = [{itemId:1, price: 200, qty: 1.0}, {itemId:2, price:100, qty:3.0}];
callSvr("Ordr.add", $.noop, {dscr: "订单1", amount: 500, items: items}, {contentType:"application/json"});

[在get接口中返回子表]

Ordr.get(id, res_items?) -> {id, ..., @items?}

接口返回的items为OrderItem子表数组内容。调用示例:

var id = 1;
callSvr("Ordr.get", {id: id, res:"*,items"})

返回示例:

{id: 1, dscr: "订单1", amount: 500, items: [
    {id: 100, itemId:1, orderId:1, price:200, qty:1, itemName:"item 1", amount:200},
    {id: 101, itemId:2, orderId:1, price:100, qty:3, itemName:"item 2", amount:300}
]}

我们知道通过res参数可指定返回项,而对于通用子表接口,通过res_{子表显示名}可指定子表项的返回项,在本例中,用参数res_items可指定子表项items的返回项,调用示例:

callSvr("Ordr.get", {id: id, res:"id,items", res_items:"id,itemName,amount"})

返回示例:

{id: 1, items: [
    {id: 100, itemName:"item 1", amount:200},
    {id: 101, itemName:"item 2", amount:300}
]}

示例2,指定字段显示名:

callSvr("Ordr.get", {id: id, res:"id 订单编号,items 订单明细", res_订单明细:"id,itemName 物料,amount 金额"})

返回示例:

{订单编号: 1, 订单明细: [
    {id: 100, 物料:"item 1", 金额:200},
    {id: 101, 物料:"item 2", 金额:300}
]}

[在set接口中追加、修改、删除子表项]

Ordr.set()(..., items={id?, itemId?, price?, qty?})

注意更新时对子项采用PATCH机制,即不必传所有子项,如果在子项中指定了id,则做更新子项操作(特别地,如果有_delete参数,表示删除该子项),否则做追加子项操作。

更新子项示例:更新id=100子项的,数量设置为2,同时更新子表和主表的amount字段

callSvr("Ordr.set", {id: id}, $.noop, {items:[{id:100, qty: 2, amount: 400}], amount:700} , {contentType:"application/json"})

追加子项:追加{itemId:1}的子项,并更新相关字段

callSvr("Ordr.set", {id: id}, $.noop, {items:[{itemId:1, qty:2, price:50, amount:100}], amount:800} , {contentType:"application/json"})

删除子项:删除id=100子项,指定_delete为1

callSvr("Ordr.set", {id: id}, $.noop, {items:[{id:100, _delete:1}], amount:300} , {contentType:"application/json"})

3.3 API调用监控

所有对API的调用(请求与响应)均记录到表ApiLog中供分析。

对一个session, 监控其API调用是否有异常,避免自动化操作行为,这时将返回 E_FORBIDDEN 错误。安全类异常将记录到日志文件 secure.log

注意: - 自动化测试时,API监控不工作(这时_test参数值为2)。 - 应避免客户端一次取多张图片时有问题

3.4 批量请求

BQP协议支持批量请求,即在一次请求中,包含多条调用。 在创建批量请求时,可以指定这些调用是否在一个事务(transaction)中,一起成功提交或失败回滚。

前端接口示例:

var batch = new MUI.batchCall();
// var batch = new MUI.batchCall({useTrans: true}); // 使用同一事务时,可指定useTrans=true

// 调用一
var param = {res: "id,name,phone"};
callSvr("User.get", param, function(data) {} )

// 调用二
var postParam = {page: "home", ver: "android", userId: "{$1.id}"};
callSvr("ActionLog.add", function(data) {}, postParam, {ref: ["userId"]} );

batch->commit();
// batch->cancel();

还有一种方式更简单:

MUI.useBatchCall(); // 在本次消息循环中执行所有的callSvr都加入批处理。
// MUI.useBatchCall({useTrans:1}, 20); // 表示20ms内所有callSvr都加入批处理, 且启用事务。
callSvr(...);
callSvr(...);
callSvr(..., {noBatch: 1}); // TODO:使用noBatch参数可以强制单独执行,不加入批处理。

其中,调用二中参数userId引用了调用一的返回结果,通过在callSvr后指定参数ref标明。userId的值"{$1.id}"表示取第一次调用值的id属性。 注意:引用表达式应以"{}"包起来,"$n"中n可以为正数或负数(但不能为0),表示对第n次或前n次调用结果的引用,以下为可能的格式:

    "{$1}"
    "id={$1.id}"
    "{$-1.d[0][0]}"
    "id in ({$1}, {$2})"
    "diff={$-2 - $-1}"

花括号中的内容将用计算后的结果替换。如果表达式非法,将使用"null"值替代。

数据传输格式:

提交使用JSON格式,示例如下

POST api/batch

[
    {
        "ac": "User.get",
        "get": {"res": "name,phone"}
    },
    {
        "ac": "ActionLog.add",
        "post": {"page": "home", "ver": "android", "userId": "{$-1.id}"},
        "ref": ["userId"]
    }
]

数组中每一项为一个调用,其格式为: {ac, %get?, %post?, @ref?}, 只有ac参数必须,其它均可省略。

get
URL请求参数。
post
POST请求参数。
ref
使用了batch引用的参数列表。

如果使用事务,只是URL上加个参数:

POST api/batch?useTrans=1

batch的返回内容是多条调用返回内容组成的数组,样例如下:

[0, [
    [ 0, {id: 1, name: "用户1", phone: "13712345678"} ],  // 调用User.get的返回结果
    [ 0, "OK" ]  // 调用ActionLog.add的返回结果
]]

3.5 服务端信息反馈/X-Daca头

BQP协议规定,以下服务端信息应通过HTTP头反馈给客户端。

服务端API版本号如果可以获取,应发送给客户端:

X-Daca-Server-Rev: {value}

其中value为最多6位的字符串。

如果服务运行于测试模式或模拟模式,应设置:

X-Daca-Test-Mode: {value}
X-Daca-Mock-Mode: {value}

其中value为非0,一般为1.

4 前端应用接口

在完整的APP设计文档中,应包括对前端应用接口的描述。

每一个筋斗云前端应用均应定义一个唯一的应用标识(app),如"emp", "emp-store"等。在调用交互接口时,框架会自动将应用标识作为参数传给后端。 应用标识中的主干部分称为应用类型(app type),例如应用"emp", "emp-store", "emp2"的主干(字符"-"之前的部分,不包括尾部数字),都是相同的应用类型"emp"。 应用类型常用于登录类型与权限控制。

例如,定义客户端应用标识app=user,其应用类型也是"user",在login交互接口中,对应用类型"user"将作用户登录处理(如查询用户表),登录成功后赋予其用户权限。 定义员工端应用标识为app=emp,它的应用类型是"emp",在后端将作员工登录处理(比如查询的是员工表),登录成功后赋予其员工权限。 定义管理端应用app=emp-adm,它与应用emp-store是相同类型,因而登录方式和权限是相同的,即应使用员工信息登录。

可见,不同的应用可以是相同的应用类型。在实现交互接口时,不同的应用标识也会使用不同的cookie名称,以避免多个应用同时使用时相互干扰。

下面以筋斗云前端示例应用为例,介绍常见的前端应用接口描述方式。

4.1 移动端应用

筋斗云的移动应用可做为Web应用在浏览器中运行, 也可以接入微信公众号或支付宝服务窗, 也可以通过cordova框架包装在应用容器中提供android/ios应用程序.

移动端应用按惯例放在m2目录下。以用户端为例,其相关文件有:

客户端:

此外还有文件:

移动应用的对外接口包括页面URL,允许的入口页面(entry),URL参数等。下面举例说明前端应用接口的描述方式:

4.1.1 客户端(app=user)

URL地址:

m2/index.html

应用标识为"user"。

[进入移动客户端并显示指定订单]

m2/index.html#order(orderId)

这表示可以请求这样的URL:

m2/index.html?orderId=32#order

其中: m2/index.html是页面地址, "?"后为参数(使用URL编码方式), "#"后为入口点, 表示允许进入的逻辑页面.

上例中的访问表示: 打开移动客户端的订单页面, 参数为orderId=32, 即显示32号订单.

4.2 桌面应用

桌面应用按惯例放在web目录下。其常用文件与移动应用类似,以管理端应用为例:

常见的桌面应用示例如下。

4.2.1 管理端应用(app=emp-adm)

一般由商户员工使用,管理员工、订单等:

web/store.html

该应用的应用标识定义为"emp-adm",它与员工端(emp-store)的应用类型是相同的,都是"emp", 因而都用员工信息进行登录。

4.2.2 超级管理端应用(app=admin)

一般由超级管理员使用,甚至可执行SQL语句:

web/adm.html

该应用的应用标识定义为"admin",使用超级管理员帐号登录。注意:超级管理员帐号在用户配置文件conf.user.php中由P_ADMIN_CRED环境变量设定。

4.2.3 桌面应用查询用法

在查找对象的对话框中,可支持多种灵活的匹配方式:

例如对字段a, 填写以下值:

如果同时对多个字段填写了搜索值,则表示这些条件需要同时满足,即AND关系。

详细可参考文档 API参考 -> 筋斗云前端(桌面Web版) -> WUI.getQueryCond

4.2.4 通用参数

移动应用和桌面应用的框架支持以下通用参数:

_debug?=0
Integer. 设置本次调用的服务端调试等级. 调试信息可在调用交互接口的返回内容中查看(返回内容的第三项): [code, ret, debugInfo]. 常用值:0-无额外信息; 1-基本信息, 9-所有信息, 包含数据库查询语句.

以下参数适用于移动应用:

cordova?=0
Integer. 0表示普通Web应用. 当网页通过[应用容器]以原生android/ios应用方式运行时, 值为非0, 表示容器的版本号.

注意: 一旦设置为非0, 则该值会被记住, 下次打开时即使未指定也会有值, 必须重新设置cordova=0清除(或在控制台中调用delStorage("cordova")).

以下参数适用于桌面应用:

autoLogin
Boolean. 如果为1, 则记住登录token, 下次打开时可以自动登录.

4.2.5 全局变量

移动应用和桌面应用使用以下JS全局变量:

g_args
Object. 应用打开时的URL参数. 由框架自动设置.
g_data
Object. 通用全局变量, 存储各项配置或应用数据. 常用项为userInfo, 表示登录后获取的用户信息.
g_cfg
Object. 全局配置。

以上变量可通过控制台手工调节部分参数.

4.2.6 管理端用法技巧

注:在Mac OS中一般用Command键替代Ctrl键。

其它隐含用法(一般用于调试):

5 测试设计

[测试需求]

所有测试内容存放在rtest目录下。

环境变量"SVC_URL"可设置使用的URL, 如

> set SVC_URL=http://115.29.199.210/mysvc
> run_rtest.pl all

5.1 手工测试

除可通过浏览器(如Chrome插件Postman)等工具进行测试外,还提供client.php工具,可分别测试每个API,如

> client.php queryseries 100

也可直接调用callsvr方法调用任意api, 例如以下调用等价于前面例子:

> client.php callsvr queryseries brandId=100

再如通用的对表的查询: 格式callsvr command [paramstr] [poststr], 其中paramstrpoststr应使用URL编码.

(get item with id)
> client.php callsvr item.get id=1 

(set item with id)
> client.php callsvr item.set id=1 "price=434&dscr=hehe"

注意:

5.2 回归测试

5.2.1 使用方法

[前提条件]

运行服务端和回归测试:

> cd rtest
> run_server.bat

(设置服务URL,也可以手工设置)
> setTestEnv.bat 
> set SVC_URL=...

(运行所有case)
> run_rtest.pl all

(运行一个case)
> run_rtest.pl testcase1

日志"rtest.log"记录所有HTTP request和response,用于分析业务逻辑失败的原因。

run_rtest是对phpunit进行了封装的工具,下面介绍。

[run_rtest.pl]

phpunit可以执行多个case, 不能自动分析依赖关系. run_rtest.pl工具就是用于简化对个别Case的测试, 它可运行一个多个或全部测试用例。

(执行所有用例)
> run_rtest.pl all

(执行一个用例, 用例名参考rtest.php中的test系列函数, 名称可忽略大小写; 工具将自动先执行依赖的用例)
> run_rtest.pl testupload

(执行多个用例,工具将自动调整各用例执行顺序)
> run_rtest.pl testatt testupload

下面是一些特殊配置:

通过设置环境变量P_DEBUG, 可以让本系统使用的测试工具(如client.php及rtest.php)请求时指定调试等级. 如:

set P_DEBUG=9
client.php callsvr usercar.query

等级9将打出服务端SQL语句,并且通过自动设置URL参数"XDEBUG_SESSION_START=netbeans-xdebug"触发服务端php调试器(必须安装php-xdebug).

指定app名称(间接指定session名). 对应系统URL参数"_app" (参考章节"应用标识(_app)). 在多种客户端同时登录时用于区分每个会话.

rtest缺省会在测试前后创建和删除cookie, 测试时会自动找一个用户测试, 测试用例中也包括创建新用户.

如果设置了环境变量P_SHARE_COOKIE, 则不会创建和删除新cookie, 而是用当前已登录的用户测试(其中会用到API whoami来确定当前用户).

例如, 你想借助rtest为特定的用户创建订单, 可以这样:

> set P_SHARE_COOKIE=1
> client.php login 13712345678 1234
(登录用户为13712345678)

(用当前登录用户添加order)
> run_rtest.pl testaddorder

(用当前登录用户运行所有测试, 将忽略注册, 登录等接口测试)
run_rtest.pl all

[phpunit用法]

运行所有回归测试:

phpunit rtest.php

phpunit其它常用参数如下:

运行一个或多个Case(注意:名称大小写必须正确,被依赖的case必须先执行)

    run_rtest --filter testGeneralQuery
    run_rtest --filter testUpload|testAtt

just scenario test:

    run_rtest --group scenario

just sanity test:

    run_rtest --exclude-group scenario

5.2.2 API测试和用例测试

[原则]

[实现]

参考file rtest.php:

static private $isIT =false;
static private $skipIT = false;
static private $skipAll = false;
private $isCritical = false; 

5.3 单元测试

必要时对某些类进行专门测试,存放在"rtest/test"目录,手工运行它们:

cd test
phpunit xxxTest.php

6 登录类型与权限管理

我们使用的权限控制模型为: "用户-权限(即角色/原子权限组)-原子权限(基本权限)". (注:权限组也称"角色", 所以某些系统中也称为"用户-角色-权限"模型.)

在我们系统中, 用户主要有User和Employee两类.

权限有两类: authorization(或称permission), 以及data ownership. - 前者控制用户可以访问的表及列, 以及对该对象(甚至粒度到字段)可以有哪些操作(读/写). 例如, 在我们系统中, User登录后可以添加订单(Ordr.add, 且只允许设置指定的字段), 不可以删除订单. 实现时, 通过AC1_Ordr类控制了操作类型, 字段等. - 后者控制用户可以访问的行, 例如, 虽然用户可以访问订单对象, 但只能操作自己的订单, 但不能操作别人的订单. 实现时, 在AC1_Ordr类的onValidateId(检查set/del/get操作)/onQuery(检查query操作)中, 均做了控制.

在后台系统中, 对象操作类的原子权限定义是通过AccessControl类簇实现的, 每一个类(如AC1_Ordr)即是一个权限定义(包括了对象权限, 列权限及行权限). 而对于函数操作类权限是通过checkAuth(角色)显示定义: 只有指定角色的用户才能调用.

权限定义参考章节权限说明.

7 服务端部署与升级

7.1 初始化配置

项目初始化步骤:

如果需要重新配置,可删除配置文件 php/conf.user.php后再运行本工具。

在配置文件中,很多帐户口令、密码采用base64等方式保存,可以用在线工具 http://{server}/{app}/tool/tool.php进行编解码。

7.2 升级管理

tool/upgrade.php - 升级管理

[原理]

它根据主设计文档中(DESIGN.wiki)中的数据模型定义,创建或更新数据库表,定义数据模型示例:

@ApiLog: id, tm, addr

主设计文档中可以包含其它设计文档,指令如下: (支持版本: v3.1)

@include sub/mydesign.wiki

如果使用了插件,一般应包含所有插件文档,以便插件中的表也可被创建。在DESIGN.wiki中目前默认就有这样一行:

@include server/plugin/*/DESIGN.wiki

注意:

[数据库连接]

upgrade.php与api.php配置方式相同,都是通过环境变量来指定,可以使用conf.user.php中的配置,数据库支持mysql和sqlite.

环境变量P_DB可为升级工具指定数据库. 如

环境变量P_DBCRED指定连接数据库的用户名密码。

7.2.1 用法

upgrade.php

缺省进入命令行交互.

7.2.1.1 交互命令

一般命令格式与函数调用类似, 也支持直接的sql语句, 如

> addtable("item")
> addtable("item", true)
> quit()

对于无参数命令可不加括号
> quit

支持直接的sql语句
> select * from item limit 10
> update item set price=333 where id=8

[help]

参数: [command]

显示帮助. 可以指定command名称, 全部或部分均可.

例:

> help
> help("addtable")
> help("table")

[initdb]

自动添加所有表. 等同于updatedb命令.

[updatedb]

自动添加或更新所有表. 相当于对所有表调用addtable命令. 如果某张表已存在, 则检查是否有缺失的字段(注意: 只检查缺失, 不检查字段类型是否变化), 有则添加, 否则对该表不做更改.

[execsql]

参数: {sql} [silent=false]

对于select语句, 返回结果集内容; 对于其它语句, 返回affectedRows.

例:

> execsql("select * from item limit 10")
> execsql("update item set price=10 where id=3")

注: - 支持直接输入SQL语句, 会自动调用execsql()执行. 程序通过以select等关键字识别SQL, 如

> select * from item limit 10
> update item set price=10 where id=3

[quit]

退出交互. 可简写为"q".

例:

> quit
或
> q

[export]

v5.1新增. 参数: {type?=0}

[TODO: upgrade]

TODO 自动根据版本差异,执行升级脚本,升级数据库. 如果字段cinf.ver不存在, 则重建DB(但会忽略已有的表, 不会删除它再重新创建). 升级完成后设置cinf.ver字段.

[showtable]

参数: {table?="*", checkDb=false}

查看某表的metadata以及SQL创建语句. 参数{table}中可以包含通配符。

例:

> showtable("item")
> showtable("*log")

[addtable]

参数: {table} [force=false]

根据metadata添加指定的表{table}. 未指定force参数时, 如果表已存在且未指定force=true, 则检查和添加缺失的字段; 如果指定了force=true, 则会删除表重建.

例:

> addtable("item")

(删除表item并重建)
> addtable("item", true)

[TODO: reload]

重新加载metadata. 当修改了DESIGN.wiki中的表结构定义时, 应调用该命令刷新metadata, 以便showtable/addtable等命令使用最新的metadata.

[addcol]

addcol {table} {col}

添加字段{table}.{col}

[getver]

取表定义的version.

[getdbver]

取数据库的version.

[import]

参数: {filename} {noPrompt=false} [encoding=utf8]

将文件内容导入表,如果表不存在,会自动创建表(根据metadata),如果表已存在,会删除重建。文件编码默认为utf8. (TODO: 支持指定编码)

noPrompt
默认导入表之前要求确认,如果指定该项为true,则不需要提示,直接导入。

一个文件可以包含多个表,每张表的数据格式如下:

# table [CarBrand]
id  name    shortcut
110 奥迪  A
116 宝骏  B
103 宝马  B
...

"#"开头为注释,一般被忽略;特别地,"table [表名]"会标识开始一个新表,然后接下去一行是header定义,以tab分隔,再下面是数据定义,以tab分隔。

这种文件一般可以在excel中直接编辑(但注意:excel默认用本地编码,也支持unicode即ucs-2le编码,但不直接支持utf-8编码)

注意: - 如果字段值为空, 直接写null.

例:导入车型测试数据 先生成测试数据:

initdata\create_testdata.pl
(生成到文件brands.txt)

再在upgrade.php中用import导入:

> import("../initdata/brands.txt")

注意:如果列名以"-"开头,则忽略此列数据,如

# table [CarBrand]
id  name    -shortcut
110 奥迪  A
...

将不会导入shortcut列。

TODO: 带关联字段导入:

# table [Figure]
name    bookId(Book.name)   ref
黄帝  史记  本纪-五帝

上例数据中,表示根据Book.name查找Book.id,然后填入Figure.bookId。如果Book中找不到相应项,会自动添加一项。

关联表导入:

# table [Svc]
id  name    Svc_ItemType(ittId,svcId)
1   小保养 1,2,6
2   大保养 1,2,3,4,7

上例有个字段表述为"Svc_ItemType(ittId,svcId)", 它表示该字段关联到表 Svc_ItemType.ittId字段,而本表的id对应关联表字段svcId。其内容为以逗号分隔的一串值。以上描述相当于:

# table [Svc]
id  name
1   小保养
2   大保养

# table [Svc_ItemType]
ittId   svcId
1   1
2   1
6   1
...

还可以这样设置:

# table [Svc]
id  name    Svc_ItemType(ittId,svcId,ItemType.name)
1   小保养 机油;机油滤清器

上例中"Svc_ItemType"多了一个参数"ItemType.name", 它表示下面内容是关联到ItemType.name字段,即需要先用"SELECT id FROM ItemType WHERE name=?"查询出Svc_ItemType.ittId(第一个参数),再同上例进行添加。

7.2.1.2 非交互命令

更新指定表(addtable命令):

upgrade.php car_brand car_series car_model

更新所有表(initdb命令):

upgrade.php all

版本差量升级(TODO:upgrade命令):

upgrade.php upgrade

7.2.2 TODO: 写升级脚本

当表结构变化时,

upgrade.php

ver = getver();
dbver = getdbver();
if (ver <= dbver)
    return;

if (dbver == 0) {
    initdb();
    return;
}

if (dbver < 1) {
    addcol(table, col);
    execsql('update table set col=col1+1');
}
if (dbver < 2) {
    addtable(table);
    importdata('data.txt');
}
if (dbver < 3) {
    addkey(key);
}
if (dbver < 4) {
    altercol(table, col);
}

update cinf set ver, update_tm

7.3 版本发布

版本发布又称“部署”或“版本上线”,是将开发版本进行构建和优化后,上传线上服务器的过程。

筋斗云框架使用webcc组件进行版本发布,对构建后的版本,也要求git进行代码库管理。 (注:v5.2起,常常使用另一种更简单的方式,不使用webcc也不创建online版本库,直接使用源码上线,参考下一小节介绍)

webcc提供的功能主要有:

版本发布的配置包括:

发布或上线过程很简单,直接在git bash中运行 build_web.sh 即可。 (注意:它会用到curl, bash等工具,好在git工具包中已包含这些。)

[开发版本号与发布版本号]

在构建后,online文件夹中会自动生成文件revision.txt,代表当前开发版本号。下次构建时通过检查该版本与最新开发版本间的差异,可实现差量构建(注意:其中还包含依赖文件管理)。

在上传服务器后,会自动在服务器上生成文件revision_rel.txt, 代表当前发布版本号。下次上传时,只会进行差量上传。

7.3.1 直接源码部署

使用webcc可以对应用进行较好的打包与发布(类似webpack,但可以更灵活的处理打包的逻辑页数),其缺点是:

源码部署指的是不使用webcc,直接将源码通过git push推送到服务器就实现上线,且可直接在线上修改或提交代码。

[创建线上代码库和部署web目录]

习惯上部署用户使用builder, web服务器用户(即apache用户)为www。注意以下操作中,应设置相关访问权限,实现两者创建的目录或文件相互可读或可写。 以builder用户在apache用户主目录下(一般是/var/www)创建src目录,使用git_clone.sh工具创建git代码库。 假设项目名为myproject:

mkdir /var/www/src
cd /var/www/src
git_clone.sh myproject

需要实现当推送新版本后,前端应可直接热更新(即自动重新刷新)。在使用webcc时,会自动生成源码版本文件revision.txt,框架会读该文件获取版本号实现热更新。 在使用源码部署后,应实现推送后创建和更新该文件。方法是在线上设置版本库:

cd /var/www/src/myproject
vi .git/hooks/post_update

找到下面一句打开注释:

git log -1 --format=%H > server/revision.txt

在web目录下(一般是/var/www/html)为应用创建链接:

cd /var/www/html
ln -sf /var/www/src/myproject/server ./myproject

这样就可以直接访问项目如:http://myserver/myproject/。注意server目录才是线上目录,要用它来创建链接。

在builder主目录下创建链接,便于推送:

ln -sf /var/www/src ./

这样要上线,就只要使用git直接推送到地址: myserver:src/myproject

[缓存设置]

即设置apache的.htaccess文件。

使用webcc编译优化时,移动端的缓存策略是:

  1. m2/.htaccess: m2目录下的html文件不允许浏览器缓存(即要求浏览器每次都获取,apache发现文件未更新,应返回304),其它文件均可缓存;
  2. m2/page/.htaccess: page目录下的逻辑页对html/js/css均不允许缓存。

在不使用webcc时,应将m2/.htaccess中设置为对html/js/css均不允许缓存。 否则更新js/css后,前端(尤其是微信)无法得到更新。(如果出现此种情况,只能手工修改html中对css/js的引用链接,比如加参数)。

做法可以直接拷贝web/.htaccess到m2/.htaccess,注意修改后应提交代码库。

[移动端加载优化]

使用webcc时,会对html/js/css/小图标等做压缩优化,以加快移动端加载速度。

使用源码部署后,可酌情做部分优化。

示例:对index.html文件,将不常修改的css/js文件(比如第三方库、jdcloud框架库等)合并压缩。

先在html去掉要优化合并的js/css文件,改为放置:

<!--link rel="import" href="lib.html" /-->
<link rel="stylesheet" href="lib.min.css" />
<script src="lib.min.js"></script>

将要合并压缩的js/css列举在m2/Makefile中。运行make命令生成上面引用的三个文件(内部使用了tool/mergeJsCss.sh工具),别忘记将它们加到代码库。 其中lib.min.css和lib.min.js即是压缩后的代码。而lib.html用于直接调试合并前的js/css源码,使用时打开该行注释,并注释掉下面两行。

注意当任何被优化的js/css文件修改后,应重新运行make更新相关文件并提交代码库。

7.4 客户端自动升级

对于Web应用,每次浏览器打开时均已是最新版本;但是如果浏览器一直未关闭,则需要手工刷新页面才能更新。

在手机上,特别是将Web应用打包为手机原生应用后,当服务器升级后,用户必须将应用重新打开才能获得最新版本,这相当于在浏览器中刷新。 如果用户一直不退出应用(这在手机上很常见,应用会在后台一直缓存着),必须有机制能保证版本更新后可自动刷新。

筋斗云框架支持客户端自动升级,原理如下: - 服务端API自动将版本号通过HTTP头X-Daca-Server-Rev发送给客户端。版本号通过全局变量API_VER设定,或从文件revision.txt读取(注意该文件由webcc发布时自动生成),版本号最多为6位。 - 客户端在访问API时,检查版本号是否与本地之前缓存的版本号一致,如果不一致则自动刷新回首页。

通过以上过程,用户不必退出应用再重新打开,就能实现版本自动升级。

8 工具接口

本节介绍目录 server/tool/ 下的工具。

注意:server/tool/目录下的工具随项目一起发布,一般通过网络访问。而tool/目录下的工具一般是命令行工具,不发布。

8.1 tool/log.php

log.php(f?=ext, sz?=2500)

查看日志(只显示最新的若干条,倒序排列)。

[参数]

f
String. 指定日志类型,缺省查看模拟接口的日志(ext.log), 还可以为"trace".
sz
Integer. 最多读取文件大小。log.php从文件结尾处读缺省3k字节,可以用sz来修改,单位为B.

[示例]

log.php

log.php?f=trace
(查看trace log)

8.2 tool/init.php

init.php(ac?)

数据库初始化或配置文件初始化。可用的ac参数见源文件内部文档。

项目初始化方法参考前面章节"服务端部署与升级"->"初始化配置".

8.3 tool/tool.php

tool.php(ac?)

具体参数见源程序内部文档。

工具包。目前支持base64编码、解码,md5编码等。

8.4 tool/upgrade/

tool/upgrade/index.php(diff?=0)

访问该链接,将根据server/tool/upgrade/META文件自动创建或升级数据库。

META文件可在tool目录下运行:

make meta

获得更新。其实质是通过upgrade.php工具解析设计文档(DESIGN.md)生成META文件。

php upgrade.php export 

该工具已集成到tool/init.php中,用于线上自动升级数据库。

9 定期任务

例如, 想每天执行一次某任务(假如名为task1), 可以在server/tool/task.php中添加一个名为ac_task1的函数:

function ac_task1()
{
    // 执行任务
}

注意: 与api_functions.php等在Web服务器环境下执行不一样, 它是作为命令行工具执行, 例如手工执行名为db的任务:

php ./task.php task1

由于包含了api.php, 它也可以用queryOne/execOne等函数直接操作数据库, 或用callSvcInt等函数内部调用服务器接口. 注意由于它是未登录的环境, 若是调用有登录权限检查的接口会因未登录而出错, 以确保安全可靠的前提下, 可以通过以下方式调用:

$_SESSION["uid"] = 1; // 模拟id=1的用户登录.
$rv = callSvcInt("XX.query"); // 这样就可以调用AC1_XX类的接口.

当然也可以用system函数调用外部命令.

[部署]

通过Linux的crontab机制设置定期执行任务。推荐的部署方法是:

在计划任务执行时, 本来输出到屏幕的内容会输出到日志文件 tool/task.log, 检查日志以查看任务执行情况.

具体设置参照task.crontab.php中的注释说明.

[数据库备份任务:db]

作为示例, task.php中自带一个db任务, 在task.crontab.php中设置每天执行一次, 用于备份数据库。 备份脚本是backup_db.sh, 修改其中的数据库连接设置即可实现数据库日备份.

10 数据安全

[需求]

考虑以下灾难场景及恢复方式:

[备份建议]

[目前方案]

[注意]

11 插件机制

筋斗云框架从v3.0开始支持插件,即plugin下的各目录,因与框架项目在一起,称为内置插件(下一章节介绍); 到v5.5开始支持外部独立插件,便于独立维护,新的插件都应以这种方式创建和维护,本节只介绍新的插件机制。

工具tool/jdcloud-plugin.sh用于安装、卸载和创建插件。文件plugin.dat用于记录插件安装信息。

11.1 安装与卸载插件

用法:须在项目目录下调用,假设项目目录为myproject, 插件为jdcloud-plugin-notify. 两个目录必须都使用git管理。

# 用git下载插件,注意与项目目录平级
git clone server-pc:src/jdcloud-plugin-notify
cd myproject

# 安装
./tool/jdcloud-plugin add ../jdcloud-plugin-notify

# 删除
./tool/jdcloud-plugin del jdcloud-plugin-notify
也可以用
./tool/jdcloud-plugin del ../jdcloud-plugin-notify

插件的目录结构与项目目录一致。

安装时,将插件目录下的文件复制到项目相应目录下,或对于项目中已有的文件,则会将内容合并到相应文件中,然后将安装信息写入plugin.dat文件中,并将相关文件均添加到git。 删除插件时,根据plugin.dat中的相应数据,删除文件或删除共享文件中插件的内容。 目前不支持直接更新插件,可先删除再安装。

特别地,插件下的README.md文件将被复制到项目下的路径server/plugin/{插件名}.README.md

plugin.dat格式如下:

{pluginName}    #{git版本}
{pluginName}    {newFile}
{pluginName}    +{appendfile}

版本行只用于记录版本,无处理逻辑。标记为+的文件在删除插件时,会自动将追加的内容删除掉,而不是删除整个文件。

11.2 创建插件

假如要创建名为jdcloud-plugin-url的插件,须先在plugin.dat中注册插件需要的所有文件,如:

jdcloud-plugin-url  #init
jdcloud-plugin-url  server/url.php
jdcloud-plugin-url  +server/plugin/index.php

其中第1行表示插件名;第2行表示将指定文件添加到插件;第3行中,文件名前有加号,表示只需要该文件将特定标识内的内容添加到插件。

特定标识为{插件名} BEGIN{插件名} END,其中的内容即为插件内容,特定标识一般包在注释内,示例:

/*! jdcloud-plugin-url BEGIN */
function api_getShareUrl()
{
    ...
}
/*! jdcloud-plugin-url END */

创建一个说明文件,路径为server/plugin/{插件名}.README.md。这里创建server/plugin/jdcloud-plugin-url.README.md。 该文件将对应插件目录下的README.md文件。

接下来,创建独立的插件目录和代码库,它与项目平级:

git init jdcloud-plugin-url

然后回到项目目录,调用命令,根据plugin.dat的内容创建插件到指定路径:

./tool/jdcloud-plugin create ../jdcloud-plugin-url

然后进入插件目录,提交文件到git库即可。

若要更新插件,也是相同步骤:更新plugin.dat,调用jdcloud-plugin create命令,提交文件到插件git库。

12 插件机制(旧)

支持版本: v3.0

12.1 插件目录结构

plugin/  - 插件总目录

plugin/index.php - 插件配置文件,指定应用使用哪些插件

plugin/{pluginName}/  某插件的主目录
plugin/{pluginName}/DESIGN.wiki 插件设计文档
plugin/{pluginName}/plugin.php 插件配置及服务端接口
plugin/{pluginName}/m2/page/{page}.[html|js] 插件前端逻辑页面,可直接在应用程序中使用
plugin/{pluginName}/m2/plugin.js 可选,插件前端全局逻辑,文件名字任意,在plugin.php中通过return语句指定使用。
TODO: plugin/{pluginName}/m2/plugin.css 可选,插件前端全局样式,文件名字任意,在plugin.php中通过return语句指定使用。

插件设计文档的结构可参考主设计文档,一般也包括 概要设计(需求),数据库设计,交互接口,前端应用接口这些部分。

插件设计文档中的表应在升级时自动创建,主设计文档中有以下指定用于此目的:

@include server/plugin/*/DESIGN.md

插件中对外部表及字段的依赖,应使用@see指令标注:

依赖数据接口:

@see @User: id, phone
@see @Store: id, name, dscr
@see @Ordr: id, storeId

Ordr.storeId = Store.id

这些被依赖的字段,看似名字固定,其实是可配置的,详参后端接口文档,查询 PluginBase.$colMap 关键字。

[插件配置文件 plugin/index.php]

该文件被后端应用框架自动包含,其内容示例如下:

<?php

Plugins::add([ "plugin1", "plugin2" ]);

表示当前应用使用两个插件"plugin1"和"plugin2", 分别对应目录 plugin/plugin1和plugin/plugin2. 如果该文件不存在,则后端应用不加载任何插件。

[插件后端 plugin/{pluginName}/plugin.php]

文件会被自动包含到应用api.php中。在这里实现插件的交互接口,在文件的最后可以返回插件的配置,如

<?php

// 实现交互接口 svcinfo
function api_svrinfo()
{ ... }

// 返回插件配置
return [
    "js" => "m2/plugin.js" // 前端需要包含的文件
];

详参后端接口文档 模块api_fw -> 插件机制 章节。

[插件移动WEB前端 plugin/{pluginName}/m2/page/]

和应用的 m2/page/ 目录一样,包含插件的每个逻辑页面。

12.2 插件安装

将插件直接复制到plugin目录下,在plugin/index.php中添加该插件名即可。

TODO: 安装相关API和交互接口

TODO: 相关表更新 用upgrade.php

12.3 接口

12.3.1 后端PHP API

Plugins.add($pluginList);  // 添加插件
Plugins.exists($pluginName); // 判断插件是否存在

TODO: 服务端安装插件:

Plugins.install('plugin1@1.1'); -- 注册的插件, 下载到本地,解压到plugin目录下,再自动更新plugin/index.php文件。
Plugins.uninstall('plugin1'); -- 删除插件目录,再更新plugin/index.php。

12.3.2 前端JS API

MUI.initClient(); // 前端初始化,如需调用以下接口,须在muiInit事件中调用。

Plugins.exists(pluginName); // 判断插件是否存在
Plugins.list(); // 返回当前应用的插件列表

示例:

$(document).on("muiInit", myInit);
function myInit()
{
    MUI.initClient(); // 初始化客户端环境,包括插件
    ...
}

// 判断和使用插件前端页面
if (Plugins.exists('plugin1')) {
    MUI.showPage('#plugin1-page1');
}

12.3.3 交互接口

返回插件列表:

initClient() -> { @plugins? }

plugins:: { name => {js?} }

TODO: 安装与卸载

addPlugin(name)
delPlugin(name)

12.4 发布与上线

应用专属插件可直接存放在plugin目录下,使用与主应用相同的代码库。

在上线时,不同的项目分别创建一个build_web.sh,根据配置不同选择不同的插件更新到服务器。 文件plugin/index.php不上线,必须手工上传服务器。

build_web.sh

export CFG_PLUGINS=plugin1,plugin2
tool/make_install.sh

通用插件使用专门的代码库维护版本。如果要加到工程中,也可以放到plugin目录下作为子模块加到应用代码库中。

注意: