故事从一个小需求开始,有一天我们美丽可爱的运营MM拿出来了一份Excel表格,希望在一些特定场景能让小蜜按照她定制的样子来交互,并且每一条问题或答案都带一些值来影响处理过程最终能对用户看到的结果起到一定作用。Excel里包含两个sheet页,一个sheet页描述了一颗交互流程树(她用xmind写好导出成excel的),另一页描述每个选项要带的值。所以LZ设计了一个json的格式来表达这颗树,并要做一个后台管理页面来维护每一个交互流程。
Implementation
我们的新版后台管理系统是基于egg框架的node应用,以前只听过没用过,这次终于有机会试一试了。我理解egg的本质上是koa,所以先从koa开始
关于koa
开场不得不贴上koa经典的洋葱图
这个图可以展示koa对于req和res的处理。比koa传统一点的express框架是用比较典型的web框架思路,req和res来做参数调用中间件的,而koa的不同就是使用了ctx。做为koa的中间件来处理ctx的就是Generator函数。简单解释下,当一个request过来的时候,会依次经过各个中间件进行处理,而处理过程中肯定是需要跳转的,所以跳转需要一个信号或者标识,这个信号就是yield next,当到某个中间件后,该中间件处理完不执行yield next的时候,然后就会反向执行前面那些中间件剩下的逻辑。就像洋葱图里,所有层是被切片的,有点像AOP。举个官方的栗子:
var koa = require('koa');
var app = koa();
// response-time
app.use(function *(next){
var start = new Date;
yield next;
var ms = new Date - start;
this.set('X-Response-Time', ms + 'ms');
});
// logger
app.use(function *(next){
var start = new Date;
yield next;
var ms = new Date - start;
console.log('%s %s - %s', this.method, this.url, ms);
});
// response
app.use(function *(){
this.body = 'Hello World';
});
app.listen(3000);
其中,我们可以把response-time,logger,response分别作为三个层。不错,这很洋葱。
那么问题来了,如果多层,执行顺序要怎么控制呢?稍微改一下输出,比较方便看结果,代码如下:
var koa = require('koa');
var app = koa();
app.use(function* function1(next) {
console.log('function1 -> before next');
yield next;
console.log('function1 -> after next');
});
app.use(function* function2(next) {
console.log('function2 -> before next');
yield next;
console.log('function2 -> after next');
});
app.use(function* function3(next) {
console.log('function3 -> before next');
this.body = 'hello world';
console.log('function3 -> after next');
});
app.listen(3000);
关于这个执行顺序,画个图比较容易看明白:
yield
so 执行结果是这样的:
function1 -> before next
function2 -> before next
function3 -> before next
function3 -> after next
function2 -> after next
function1 -> after next
并不是想象中串行执行function1,2,3的。
关于ES6-Generator
其实这里讲述的顺序有点乱,因为上面算是结果,导致结果的过程是因为我最早光去看代码是不知道function后面的那个星号是什么意思的,去查了才开始了解Generator,于是才有了上面的执行顺序的探索。其实星号和yield可以理解成Generator的特征。看《ECMAScript 6 入门》里Generator这一章就足够详细了,里面有一句话,大概是讲Generator函数可以理解成一个状态机,它封装了多个内部状态。执行Generator函数时会返回一个遍历器对象,可以依次遍历Generator函数内部的每一个状态。回到刚才的栗子,作为状态机Generator对象一定会维护一个状态属性,就是GeneratorStatus。回到刚才的栗子,GeneratorStatus的值从suspended变为closed后,就不会再改变了。也就是说,Generator对象在一个环境中,只能执行一遍,上面的Generator的执行就有点类似于function1(function2(function3({})))
理解了这些,基本上就可以获得koa新手村玩家的荣誉称号了。作为混新手村的一员,进阶玩法还在不断探索中,共勉。
关于egg
再说egg,我理解其实egg是框架的框架,它最大的价值是给开发者定义了规范,做了MVC做的事情。正好这周猿来如此也听了不四大神的分享。他说,egg更像java的spring框架。新手村刚刚使用过egg框架的玩家听完由衷的感叹,对对对就是这样~
egg的文档很少,有疑惑的地方我一般是这样解决的。看API (基本无法解决问题)-> 读源码(可以解决一部分疑惑)-> 涉及到资料比较多的知识,可以看看koa和chain的相关资料,一言不合就google(也可以解决一部分问题)-> 还不行就问团队的egg先驱探险者@空承 -> 如果有了新的思路再循环整个过程,然后不断尝试。
Egg相关也可以去看下空承写的唯快不破:Egg及在Summer中的应用
回到需求本身
举个栗子,做需求的过程中遇到了一个上传的filestream问题。简单来讲,上传文件实际上拿到的是一个fs的read流。如果是文本其实很好办,node也像java一样有类似readline的方法能解决。但是我要解决的是一个多sheet的excel文件。第一反应就是去npm找合适的第三方包来解决。把流行的几个包试了一下,基本都是要读取路径的,没有直接可以对流处理的。其中最强大的xlsx可以处理流,但是直接用是不行的。首先,它读的一定是个正常的文件流,上传来的read流并不支持。又尝试了各种其他方案,一一否决了之后决定先把流生成临时文件,代码如下:
function read(rs) {
return new Promise((resolve, reject) => {
rs.on('data', (chunk) => {
fs.writeFileSync(filePath, chunk, 'binary');
}).on('end', () => {
resolve();
}).on('error', (err) => {
reject(err);
});
});
}
Promise
Promise解决的是异步编程的问题,理念上他像一个容器,里面保存着某个异步操作的结果。从语法上来看,它是一个对象,构造函数包括resolve和rejected。
我理解他也是维护状态来实现回调的,包括三种状态的变化:pending(处理中),resolved(处理成功),reject(处理失败)。resolve是将Promise对象的状态从从Pending变为Resolved,在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。同理,reject将Promise对象的状态从从Pending变为Rejected,在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。Promise实例生成以后,可以用then方法分别指定Resolved状态和Reject状态的回调函数。更深入可以看http://liubin.org/promises-book/ 很详细,很强大。
node的fs
用法上API基本足矣,但是要理解的是写文件其实是有一个pipe概念在里面的。就好比两个桶,read流是一个桶(source),用一根管子(pipe)往另一个桶(target)也就是要生成的文件里倒水(data)。用pipe运输data是需要过程的,这个过程就用到了Promise的状态,来看是不是写完了。于是就有了上面的代码。
xlsx–解析Excel文件
有了真实存在的文件而不是一个read流就解决一切了么?当初天真的LZ也是这么一位的,然而真相使用xlsx是只转workbook对象的部分是可以的,主要是下面这一部分。
function parse_xlscfb(cfb, options) {
if(!options) options = {};
fix_read_opts(options);
reset_cp();
var CompObj, Summary, Workbook;
if(cfb.find) {
CompObj = cfb.find('!CompObj');
Summary = cfb.find('!SummaryInformation');
Workbook = cfb.find('/Workbook');
} else {
prep_blob(cfb, 0);
Workbook = {content: cfb};
}
if(!Workbook) Workbook = cfb.find('/Book');
var CompObjP, SummaryP, WorkbookP;
if(CompObj) CompObjP = parse_compobj(CompObj);
if(options.bookProps && !options.bookSheets) WorkbookP = {};
else {
if(Workbook) WorkbookP = parse_workbook(Workbook.content, options, !!Workbook.find);
else throw new Error("Cannot find Workbook stream");
}
但是从workbook读取sheet页的对象的时候就抛异常了,把workbook对象打出来看了一下,再对比下解析的源码,大概是因为sheet页的类似于head的一个东西一般是[‘!range’],但是我手里的文件是[‘!ref’],不知道是不是因为背景中叙述的文件来源是xmind导出来的树所导致的。那其实现在是要兼容[‘!ref’]。此时一脸懵逼的LZ就开始google了,在xlsx的wiki里看到关于worksheet对象的解释如下:
Worksheet Object
Each key that does not start with ! maps to a cell (using A-1 notation)
worksheet[address] returns the cell object for the specified address.
Special worksheet keys (accessible as worksheet[key], each starting with !):
ws[‘!ref’]: A-1 based range representing the worksheet range. Functions that work with sheets should use this parameter to determine the range. Cells that are assigned outside of the range are not processed. In particular, when writing a worksheet by hand, be sure to update the range. For a longer discussion, see http://git.io/KIaNKQFunctions that handle worksheets should test for the presence of !ref field. If the !ref is omitted or is not a valid range, functions are free to treat the sheet as empty or attempt to guess the range. The standard utilities that ship with this library treat sheets as empty (for example, the CSV output is an empty string).
When reading a worksheet with the sheetRows property set, the ref parameter will use the restricted range. The original range is set at ws[‘!fullref’]
ws[‘!cols’]: array of column properties objects. Column widths are actually stored in files in a normalized manner, measured in terms of the “Maximum Digit Width” (the largest width of the rendered digits 0-9, in pixels). When parsed, the column objects store the pixel width in the wpx field, character width in the wch field, and the maximum digit width in the MDW field.
ws[‘!merges’]: array of range objects corresponding to the merged cells in the worksheet. Plaintext utilities are unaware of merge cells. CSV export will write all cells in the merge range if they exist, so be sure that only the first cell (upper-left) in the range is set.
其实我自己也懒得看英文,上面这段大概就是解释了一下ref和cols两种情况取sheet页里cell范围的相关事宜,具体在查+试并结合xlsx的规范,发现应该是这样把sheet页的cell对象读出来作为一个二维数组返回的:
function _decode_range(range) {
let o = {s: {c: 0, r: 0}, e: {c: 0, r: 0}};
let idx, i = 0, cc = 0;
let len = range.length;
for (idx = 0; i < len; ++i) {
if ((cc = range.charCodeAt(i) - 64) < 1 || cc > 26) break;
idx = 26 * idx + cc;
}
o.s.c = --idx;
for (idx = 0; i < len; ++i) {
if ((cc = range.charCodeAt(i) - 48) < 0 || cc > 9) break;
idx = 10 * idx + cc;
}
o.s.r = --idx;
if (i === len || range.charCodeAt(++i) === 58) {
o.e.c = o.s.c;
o.e.r = o.s.r;
return o;
}
for (idx = 0; i !== len; ++i) {
if ((cc = range.charCodeAt(i) - 64) < 1 || cc > 26) break;
idx = 26 * idx + cc;
}
o.e.c = --idx;
for (idx = 0; i !== len; ++i) {
if ((cc = range.charCodeAt(i) - 48) < 0 || cc > 9) break;
idx = 10 * idx + cc;
}
o.e.r = --idx;
return o;
}
至此,我拿到了上传的Excel文件的每个sheet页的每个cell值。后面要做的就是一些业务逻辑处理了,就不粘代码了。整体来看上传大概就是做了下面这几件事:生成临时文件->读取文件->解析数据->得到数据后删除临时文件->对数据进行业务逻辑处理->返回处理后的结果
exports.upload = function*() {
const rs = yield this.getFileStream();
let ret = [];
try {
yield read(rs).then(function () {
return _parse_xlsx(filePath);
}, function (error) {
return error;
}).then(function () {
_clean(filePath);
ret = _build(data);
});
this.body = {
data: ret,
};
} catch (e) {
this.logger.error(`upload(${data})`, e);
toResponse(this, Result.error(ResultCode.FILE_NOT_SUPPORT, e.message));
}
};
Finally
LZ是个普通的业务开发,所以为了产品的迭代速度时间一向紧迫。这个引出来一大串学习过程的需求可能选择写在熟悉的java工程里很快就能交付使用。但是事实上,一系列的探索和实现大概花了一周的时间才完成。所以此处不得不感谢我们项目的产品和运营对于一个并不牛X的技术人员的任性和偏执的包容。(中间LZ也为了不影响整体进度做了一部分java版的实现,后来才改成了node版,所以有小伙伴看到这里不要像LZ一样任性,科学实验不拖team的后腿是前提~)