作者 | Derek Yeung
编辑 | Nodejs技术栈
转发链接:
目录
前后端全部用 JS 开发是什么体验( Hybrid + Egg.js经验分享)上 本篇
Class 前言
最近刚从公司离职,回顾下来之前项目中除了代码,关心更多的是业务,一直没好好梳理整个技术栈,趁着现在时间自由,打算好好梳理总结一下,算是给过去几年的node生涯交一份答卷,也顺便给大家分享一下前后端全部用javascript开发是什么样的体验
Class 项目背景
项目是一款面向学校、教师、家长以及学生的教育类app,涉及的平台有:
- ios
- android
- pc web
- h5 web
使用的技术栈有:
- For 前端:html5plus + moe-plus + vue
- For 后端:egg + egg-moe
Class 前端
既然项目是一个app应用,那么第一个问题就是:原生 or 混合
原生和混合的优劣势,我相信大家都清楚,不引战不讨论,这里就不多说了
对于我们而言,在项目初期我们并没有对应两个端的开发人员,并且初期的版本需要大量的迭代和测试,所以在这种条件下采用原生开发是不合适的
那么,第二个问题就是要确定混合开发的方案,项目立项是在2016年,那么可以来看一下,在当时我们可以选择的方案有哪些:
- Html5 Plus
- React Native
- Weex
- Flutter
- 其他移动框架 + 套壳(理论上也是一种方案…)
Weex和Flutter在当时属于新生儿,各自的生态圈还不够完善,所以没有继续考虑
剩下的5+和React各有千秋,不过最终还是选择了5+,原因其实也很简单:方便(图省事)
熟悉HB的同学应该清楚,5+在打包方面是秒杀其他方案的,够简单也够省事,不用对环境进行配置,也不需要各种命令行build,当然这一切也是建立在5+稳定的情况下
那么5+是不是就是最佳的方案了呢?
当然不是,5+在打包方面可以说是最佳的,但是在开发和调试上简直就是噩梦
比如理想中的场景是在PC浏览器中预览并且调试,但是现实情况是只要是需要调用plus的地方,只 能 真 机…
没有所见即得所得,也没有F12,只有一条数据线和一个只能打印String的控制台…
现在回想起来,唯一让我坚持下去的理由大概就是自己选的框架,跪着也要啃完
终于在18年的时候,我决定对架构动刀,彻底解决这个问题
恰巧当时5+的兄弟uni-app也诞生了,但是刚出生的uni-app是否稳定,是否能持续下去都是一个未知数,我也不敢贸然采用一个全新的方案
并且此时app已经稳定,更换框架已经不单纯是面临学习成本的问题,更多的是风险
所以经过大概两周的测试,最终我选择在5+的基础上搭建一层中间件,用来解决这个问题,于是moe-plus就诞生了
About moe-plus
moe-plus是一个基于vue和5+开发的中间层 moe-plus的定位不是框架,moe-plus更像是一个定制开发的“翻译官”,通过调用统一的api,实现跨平台的效果
moe-plus目录结构:
├─ components
│ ├── webview
│ └── request
├─ core
├─ runtimes
│ └── html5plus
│ └── components
│ ├── webview
│ └── request
│ └── vue
│ └── components
│ ├── webview
│ └── request
moe-plus规范了每个component的api,在不同环境api有差异的情况下,通过runtime内部的component来覆盖默认的component,
以最常见的页面跳转举例:
this.Page.open(path, param);
在vue环境下执行:
open(path, param = {}, meta = {}) {
const vm = plus.vm;
const prod = process.env.NODE_ENV === 'production';
const hashMode = ~window.location.href.indexOf('#');
if (!prod) {
const json = {
param: JSON.stringify(param)
};
const stringified = queryString.stringify(json);
const isHasParam = path.indexOf('?') > -1 ? true : false;
if (hashMode) {
path = path + (isHasParam ? '&' : '?') + stringified;
}
}
this.clearWebview();
if (vm) {
vm.isForwarding = true;
vm.$router.push({
path,
query: param,
meta: meta
});
}
}
在5+环境下则会执行:
open(path, param) {
return this.openView(path, param, 'slide-in-right');
}
openView(path, param = {}, animate = '', extras = {}) {
const url = this.loadUrl(path);
let webview = this.exists(path);
if (param && param.isInvoke) {
extras.isInvoke = true;
}
const extend = Object.assign({}, extras, {
param,
});
const onPageShowed = () => {
this.trigger(webview, 'page-show-success');
};
if (!webview) {
const styles = param.styles || {};
webview = this.create(path, url, Object.assign({
top: 0,
bottom: 0,
}, styles), extend);
const paramWatcher = `window.addEventListener('param-change', function(listener) {
var webview = plus.webview.currentWebview();
var param = listener.detail;
if (!(param instanceof Object)) {
param = JSON.parse(param);
}
webview.param = param;
});`;
let loading = null;
const timer = setTimeout(() => {
loading = Toast.showWaiting('载入中...');
}, isIos ? 600 : 600);
webview.addEventListener('loaded', () => {
if (timer) {
clearTimeout(timer);
}
if (loading) {
loading.close();
}
webview.evalJS(paramWatcher);
webview.show(animate, Duration, onPageShowed);
});
} else {
this.trigger(webview, 'param-change', param);
this.trigger(webview, 'page-open');
this.trigger(webview, 'page-show');
webview.show(animate, Duration, onPageShowed);
}
return webview;
}
在H5页面中,moe-plus调用的是vue-router,而在app中则是5+的webview,其他component也是同理
同时moe-plus也提供了环境判断的接口,可以在一些有硬性差异的部分手动判断,分别实现不同的效果
前面也提到了,moe-plus并不是从框架的角度去开发的,所以也并没有开源的计划,这里只做为一个案例分享,如果有感兴趣的小伙伴可以自行下载该仓库体验
另外毕竟现在0202年了,uni-app也已经成熟,对于有多端需求的小伙伴我的推荐是直接上手uni-app(dcloud打钱),虽然无法达到100%全端适配,但也是目前来讲多端里最好的方案了
当然了,如果只是追求H5和App两个端的体验的话,那么我的推荐是RN或者类moe-plus的方案
About 项目
前面花了不少篇幅向大家介绍了moe-plus,下面就给大家分享一下我们的项目开发日常和一些小工具
首先我们来说说环境配置那些事
不管是什么样的项目,都会遇到同样一个问题,那就是环境配置
每一个项目都会有prod或者dev环境,有的项目还会有更多(如beta、test)
我们在开发过程中经常会遇到需要切换不同的环境,那么不同的环境该如何切换呢?
Plan A:
const config = {
devConfig
};
const config = {
prodConfig
};
这是最简单的做法,缺点也很明显,代码有污染,切换容易出错,可变的配置项越多就越复杂
Plan B:
const env = require(envFilePath) // process.env.NODE_ENV;
const envConfigPath = `${env}.conf`;
cosnt config = require(envConfigPath);
这个是我们采用的方案,应该也是最普遍的做法,通过env文件或环境变量,读取不同的配置文件
这个方案没有什么问题,但当环境越来越多,配置变更越来越多的时候,则需要记住越来越多的env
针对这种情况,我们内部设计一套内部的cli工具,用来创建/管理配置信息
通过npm run config生成配置文件
生成配置文件之后通过npm run dev/build来选择当前编译环境,配置文件默认随环境选择,亦可手动选择
同时使用electron配套搭配了可视化界面
可视化界面中更加清晰的显示了每个项目的状态
细心的小伙伴应该注意到了上图中还有一个远程调试二维码
这也是我们在开发过程中做的一个功能
在项目开发的过程中,难免会遇到测试的问题,有的时候哪怕是很微小的变动,往往也需要发布一个版本
如果测试团队是异地测试,更新包也有公网泄露的风险
我们解决这个问题的方法是:
我们将内网与外网打通,让外部能够访问内网的开发机,异地预览实时的效果
打包好的更新包通过加密上传到服务器/oss,通过扫码授权更新
同时记录下每个包的版本信息,方便测试人员在不同的版本之间进行切换
最后将运行/发布的版本的二维码提供给相应的测试人员,测试人员通过App扫码即可远程更新
远程调试演示:
安装好的开发版本也会自动连接到远程日志服务器,将App运行过程中产生的console实时传递到开发人员一端
除了扫码远程调试之外,我们也做了扫码登录
用户在扫码之后服务器会建立一条通道,当用户确认登录之后服务器会下发新授权令牌到授权网页,页面中则是通过postMessage进行通信,使得原网页拿到对应的token
由于产品的定位是平台,那么必然也少不了与第三方应用进行交互
我们给第三方提供了用户的Oauth授权以及敏感信息授权(比如手机号)
第三方通过对接公共平台,调用我们的js sdk可以轻松接入到我们的系统
前端的部分,就先讲到这里,虽然有一些功能因为业务的原因没有办法拿出来讲,不过相信通过上面的几个演示大家也可以看到从感知度来讲,混合方案与原生并无很大差异,更多的是性能上的差异(主要存在于低端机型),不过我相信在未来配置越来越高的情况下,这种差异会逐渐变小甚至无感
Class 后端
说完前端,接下来就说一说后端那些事
在2017年之前整个系统并不完全是由node支撑的,核心业务部分是”almost世界上最好的语言”php开发的
因为前端部分也是h5的混合开发方案,所以切换成node其实更多的原因是想体验一下用一种语言统一前后端的感觉,顺便挑战一下只招js工程师的成就
虽然想法很美好,奈何现实给了我一拳
由于是大规模替换,如果要将所有的代码进行重写那将耗费非常多的时间,为了减少重构的时间,我选择的是基于Koa重建yii2(还是图省事)
结果就是带来了《由一行代码引发的“血案”》
感兴趣的小伙伴可以进传送门: 这次事故之后我们也彻底放弃了偷懒的做法,选择拥抱egg的怀抱(真香)
之所以选择egg,是因为我们需要一套有自己内部规范并且可靠的框架,而egg所提供的插件开发和框架开发恰好就是我们需要的
我们的业务涉及到前台、后台、鉴权、支付、三方服务、socket等等大大小小14个平台
每个平台中既有独立的业务,也有公共的部分,所以我们在egg的基础上研发了自己的framework:egg-moe
About egg-moe
egg-moe通过egg的扩展loader功能,将common目录下的service、model和config进行挂载
将所有公共部分业务全部放到common下,平台私有业务放在各自目录
目录结构:
├─ common
│ └── service
│ └── common.js
│ └── ...
│ └── model
│ └── common.js
│ └── ...
├─ frontend
│ └── service
│ └── frontend-custom.js
│ └── ...
│ └── model
│ └── frontend-custom.js
│ └── ...
├─ backend
│ └── service
│ └── backend-custom.js
│ └── ...
│ └── model
│ └── backend-custom.js
│ └── ...
这样在开发过程中,涉及到公共部分的业务由common统一接口,每个业务下只需要关注自己本身的业务
同时,egg-moe统一了路由规范,统一错误捕获,另外把项目中常用的module整合到了一起,避免各自调用不同的module
虽然这里不想讨论其他的框架,但是这里不得不说一句,如果你的团队需要的是一套符合自己内部规范的框架,那么通过egg+定制框架的方式一定是最佳的
当然了,没有最完美的框架,只有最合适的框架,根据项目情况选择最合适的框架才是真理
About 后端
在后端开发上,因为涉及到业务的原因,很少能有具体的功能拿来讲,这里主要就和大家分享一下我们平台的架构和我们在过程中开发的一些插件
平台架构:
前台、后台、Socket三个服务分别为面向客户和管理一端的前置服务,负责接收处理有人为操作的请求
统一服务是面向业务层面的后置服务,负责统一接口、鉴权、清洗数据等任务
第三方服务与公共平台则是负责与第三方数据交互以及我们对外开放接口部分
Oauth则是用户与第三方之间建立授权的核心服务,所有第三方与用户之间的关系均由该服务进行处理
Schedule顾名思义,我们的计划任务服务,负责90%的计划任务,剩下的10%则为各个模块内部任务
Slave这个服务就比较惨了,什么脏活累活都是他干,属于名副其实的slave…
在学校端,我们还有一部分业务系统,但这部分与平台其实没什么关系,后面也会讲到一部分,这里就不列出了
目前的配置是9台ecs + 4个mysql节点+2slave节点 + 1redis
部署方面没有采用容器而是传统方案,运维和监控方面则是完全交给了alinode
About 插件
egg-database egg-database是一个orm插件,之所以没有选择sequelize而是新造了轮子主要的原因还是习惯了yii的风格,所以参考了yii的风格来实现了node版本,熟悉yii的同学应该对下面的代码不陌生
在egg-database中,我们这样定义模型
app/model/user.js
'use strict';
const { ActiveRecord, Validate, Field } = require('moe-query');
const { Rule } = Validate;
module.exports = app => {
class Model extends ActiveRecord {
extras() {
return {};
}
tableName() {
return 'user';
}
}
const model = new Model();
model.fields({
name: new Field('name').label('昵称'),
mobile: new Field('mobile').label('手机号').mobile()
.required(),
password: new Field('password').label('密码').string(128)
.required(),
last_login_time: new Field('last_login_time').label('上次登录时间').time(),
update_time: new Field('update_time').label('更新时间').time(),
create_time: new Field('create_time').label('创建时间').time(),
});
// 或简写模式:model.fields([ 'name', 'mobile', ... ]);
model.rules([
new Rule(model.Fields.mobile),
new Rule(model.Fields.password),
]);
if (app.rule.Time) {
model.mount(app.rule.Time);
}
return model;
};
查询单条数据:
const model = this.ctx.model.User;
// 1
const user = await model.fetch(123);
// 2
const user = await model.fetch('张三', 'name');
// 3
const user = await model.fetch({
name: '张三',
password: '李四'
});
查询多条数据:
const model = this.ctx.model.User;
const all = await model.query.where([
[ 'name', '=', '张三' ]
]).desc().all(); /* or asc(), order()*/
// 分页
const list = await model.query.where([
[ 'name', '=', '张三' ]
]).list(/* request */);
// 排序 分组
const datas = await model.query.where([
[ 'name', '=', '张三' ]
]).limit(20).offset(0).order('id', 'desc').group('name').all();
新增数据:
const model = this.ctx.model.User;
const user = model.create();
user.name = '张三';
user.password = '李四';
await user.save();
// or
const user = model.create({
name: '张三',
password: '李四'
});
await user.save();
// or
const user = model.create();
await user.save({
name: '张三',
password: '李四'
});
修改数据:
const model = this.ctx.model.User;
const user = await user.fetch(1); // 得到张三
user.name = '王五';
await user.save();
// or
await model.query.update({
name: '王五'
}, 1 /* or [where Condition] */);
关联查询:
const model = this.ctx.model.User;
const list = await model.query.leftJoin('table', [ /* join Condition*/ ]);
除了关联查询之外,同样也提供关系查询
首先我们添加一个与user相关的model,这里以user device举例
app/model/user/device.js
'use strict';
const { ActiveRecord, Validate, Field } = require('moe-query');
const { Rule } = Validate;
module.exports = app => {
class Model extends ActiveRecord {
tableName() {
return 'user_device';
}
}
const model = new Model();
model.fields([
'userid',
'name',
'token',
'update_time',
'create_time'
]);
if (app.rule.Time) {
model.mount(app.rule.Time);
}
return model;
};
然后在 app/model/user.js 的Model中添加relation和对应的extras
...
relation() {
return {
// hasOne or hasMany
Device: this.hasMany(app.model.User.Device, {
id: 'userid',
})
};
}
extras() {
return {
devices() {
return this.Device || [];
}
};
}
...
最后在查询时,通过joinWith带入
const model = this.ctx.model.User;
const list = await model.query.joinWith('Device').all();
另外,model也提供了各个阶段的查询事件,如before save/after save等等
比如通过 model.on(‘before save’); 可以在数据保存前做最后的处理, 通过 model.on(‘after save’); 则是在数据保存后得到对应的事件
同时egg-database也提供了规则的概念(Rule),可以将重复、公共部分的事件处理成规则
比如上面model中model.mount(app.rule.Time)的部分,具体的实现是这样的:
'use strict';
const { ActiveRecord, Validate, Field } = require('moe-query');
const { Rule } = Validate;
const CreateAttribute = 'create_time';
const UpdateAttribute = 'update_time';
const TimeRule = new Rule('time:save');
TimeRule.inject(function(query) {
const isNew = this.is('exists'); // 判断是新增还是更新,true为新增,false为更新
const time = Math.round(new Date().getTime() / 1000);
if (!isNew || !this[CreateAttribute]) { // 如果是新增并且有创建时间字段,设置该字段为当前时间
if (this.Fields[CreateAttribute]) {
this[CreateAttribute] = time;
}
}
if (this.Fields[UpdateAttribute]) { // 如果是有更新时间字段,设置该字段为当前时间
this[UpdateAttribute] = time;
}
return true;
}).on([ 'before save' ]);
module.exports = TimeRule;
通过该rule实现了数据创建时间、更新时间的自动设置,业务中再也不需要手动指定创建/更新时间
通过事件和rule的配合,我们还可以做一些更加灵活的事情
本篇未完结,请见下一篇
推荐JavaScript经典实例学习资料文章
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
《 》
作者 | Derek Yeung
编辑 | Nodejs技术栈
转发链接:
相关文章
本站已关闭游客评论,请登录或者注册后再评论吧~