专栏首页Nodejs技术栈前后端全部用 js 开发是什么体验(Hybrid + Egg.js经验分享)

前后端全部用 js 开发是什么体验(Hybrid + Egg.js经验分享)

作者 | Derek Yeung

编辑 | Nodejs技术栈

本文经作者 @Derek Yeung 授权分享,排版由 “Nodejs技术栈” 编辑。

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

App

在H5页面中,moe-plus调用的是vue-router,而在app中则是5+的webview,其他component也是同理

同时moe-plus也提供了环境判断的接口,可以在一些有硬性差异的部分手动判断,分别实现不同的效果

前面也提到了,moe-plus并不是从框架的角度去开发的,所以也并没有开源的计划,这里只做为一个案例分享,如果有感兴趣的小伙伴可以自行下载该仓库体验

https://github.com/DerekYeung/moe-plus-demo

另外毕竟现在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(还是图省事)

结果就是带来了《由一行代码引发的“血案”》

感兴趣的小伙伴可以进传送门:https://link.zhihu.com/?target=https%3A//cnodejs.org/topic/5aaba2dc19b2e3db18959e63 https://zhuanlan.zhihu.com/p/34702356 这次事故之后我们也彻底放弃了偷懒的做法,选择拥抱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的配合,我们还可以做一些更加灵活的事情

比如下面这段代码是我们针对某些关键操作设定的“监控”

'use strict';
const { Validate } = require('moe-query');
const { Rule } = Validate;

const defaultId = 'adminid';
const defaultName = 'admin';

module.exports = app => {
  return function(name) {
    const OperationLog = new Rule('school:operation:log');
    OperationLog.inject(function(query) {
      const model = this.hasOperationModel ? this.hasOperationModel() : this.get(app.model.School.OperationLog);
      const log = model.create();
      const ctx = this.ctx || app.ctx;
      const resource = this;
      const isCreated = (query.action === 'insert');
      const nameData = isCreated ? resource.get('name') : resource.getOld('name');
      const Admin = ctx.Admin || {};

      log.name = name;
      log.platform = 'master';
      log.schoolid = resource.schoolid;
      if (log.Fields[defaultId]) {
        log[defaultId] = Admin.id;
      }
      if (log.Fields[defaultName]) {
        log[defaultName] = Admin.name;
      }
      const operation = isCreated ? '创建' : '更新';
      const description = [];
      if (log[defaultName]) {
        description.push(`管理员<${log[defaultName]}>`);
      }
      description.push(operation + '了');
      const resourceDescription = [];

      if (resource.Pid) {
        resourceDescription.push(`${resource.Labels[resource.PrimaryKey]}为<${resource.Pid}>`);
      }
      if (nameData) {
        resourceDescription.push(`${resource.Labels.name}为<${nameData}>`);
      }
      if (resourceDescription) {
        description.push(resourceDescription.join('、'));
      } else {
        description.push('未知');
      }
      description.push('的');
      if (resource.ModelName) {
        description.push(`<${resource.ModelName}>`);
      } else {
        description.push('未知模型');
      }

      log.description = description.join(' ');
      log.operation = operation;
      log.url = ctx.url;
      log.uuid = ctx.uuid;
      log.detail = {
        resource,
        isCreated,
        ua: ctx.get ? ctx.get('user-agent') : '',
        gp: ctx.gp,
        header: ctx.header,
      };
      return log.save();
    }).on([ 'after save' ]);
    const DeleteEvent = new Rule('school:operation:log:delete');
    DeleteEvent.inject(function(result) {
      const model = this.hasOperationModel ? this.hasOperationModel() : this.get(app.model.School.OperationLog);
      const log = model.create();
      const ctx = this.ctx || {};
      const resource = this;
      const isDeleted = result.affectedRows > 0;
      const nameData = resource.get('name');
      const Admin = ctx.Admin || {};

      log.name = name;
      log.platform = 'master';
      log.schoolid = resource.schoolid;

      if (log.Fields[defaultId]) {
        log[defaultId] = Admin.id;
      }
      if (log.Fields[defaultName]) {
        log[defaultName] = Admin.name;
      }
      const operation = isDeleted ? '删除' : '尝试删除';
      const description = [];
      if (log[defaultName]) {
        description.push(`管理员<${log[defaultName]}>`);
      }
      description.push(operation + '了');
      const resourceDescription = [];

      if (resource.Pid) {
        resourceDescription.push(`${resource.Labels[resource.PrimaryKey]}为<${resource.Pid}>`);
      }
      if (nameData) {
        resourceDescription.push(`${resource.Labels.name}为<${nameData}>`);
      }
      if (resourceDescription) {
        description.push(resourceDescription.join('、'));
      } else {
        description.push('未知');
      }
      description.push('的');
      if (resource.ModelName) {
        description.push(`<${resource.ModelName}>`);
      } else {
        description.push('未知模型');
      }

      log.description = description.join(' ');
      log.operation = operation;
      log.url = ctx.url;
      log.uuid = ctx.uuid;
      log.detail = {
        resource,
        isDeleted,
        ua: ctx.get ? ctx.get('user-agent') : '',
        gp: ctx.gp,
        header: ctx.header,
      };
      return log.save();
    }).on([ 'after delete' ]);
    return [ OperationLog, DeleteEvent ];
  };
};

当操作人员对受到监控的资源进行操作后,系统会自动记录当前的操作,必要的话也可以阻断当前的操作

前面也提到了,我们目前有多个mysql节点加上2个salve节点,所以egg-database理所当然的也支持多节点和salve模式

在config中配置好对应的连接点,然后在对应的model中修改默认连接的db即可

// example model
    config() {
      return {
        db: 'log',
      };
    }

slave模式就比较简单了,只需要在对应的节点下添加slave属性,在执行不同的sql请求时会自动切换当前的连接池

2.egg-request

egg-request本质上只是一个对egg curl的封装,通过统一的api对所有的request请求进行管理

egg-request核心部分由master和request组成,master负责对外的统一接口,对内的统一管理,request则负责具体的http请求

egg-request默认创建了3个master,亦可自行创建custom master

'use strict';
const ApiRequestInstance = Symbol('Application#Request#Api');
const HttpRequestInstance = Symbol('Application#Request#Http');
const SoapRequestInstance = Symbol('Application#Request#Soap');
const Request = require('../../lib/request');

module.exports = {
  get soap() {
    if (!this[SoapRequestInstance]) {
      this[SoapRequestInstance] = new Request.Master('soap', this);
    }
    return this[SoapRequestInstance];
  },
  get api() {
    if (!this[ApiRequestInstance]) {
      this[ApiRequestInstance] = new Request.Master('api', this);
    }
    return this[ApiRequestInstance];
  },
  get http() {
    if (!this[HttpRequestInstance]) {
      this[HttpRequestInstance] = new Request.Master('http', this);
    }
    return this[HttpRequestInstance];
  },
};

调用方法:

// GET
this.app.api.get(url, data, options);

// POST
this.app.api.post(url, data, options);

// other method 
this.app.api.exec(url, method, options);

// 额外增加了download方法
this.app.api.download(url, data, path, options);

可以在config中配置相应的service用来监听处理request事件

// app/config/config.js
config.request = {
  service: 'Request',
};

// app/service/request.js

'use strict';
const Service = require('egg-moe/component').Service;

class MoeService extends Service {

  beforeRequest(request) {
    // 这里可以对request进行操作,比如统一在header中加注token
    return request;
  }

  afterRequest(request, response, error) {
    // 这里将所有的request请求记录下来
    const model = this.ctx.model.RequestLog;
    model.create().save({
      url: request.req.url,
      method: request.config.options.method,
      request: JSON.stringify(request.config),
      response: JSON.stringify(response),
      error: JSON.stringify(error),
    });
    return request;
  }

}

module.exports = MoeService;

3.egg-moe-payment

顾名思义,egg-moe-payment是我们处理支付相关的插件,其实这里并没有什么好讲的,我们也只实现了我们需要的部分,不需要的部分也并没有去做

不过这里需要小吐槽一下阿里官方的alipay-sdk,居然不支持gbk的验签...

恰好我们阿里这边的一个合作对方返给我们的签名是GBK的,但是验签死活不通过,搞得我心态差点崩了,最后一看代码实现

这...好吧

说一下我们的解决方案

首先我们使用到了两个库

const iconv = require('iconv-lite');
const urlencode = require('urlencode');

重新实现getSignContent

getAlipaySignContent(signArgs = {}, charset = 'utf-8') {
  return Object.keys(signArgs).sort().filter(val => val).map((key) => {
    let value = signArgs[key];
    if (Array.prototype.toString.call(value) !== '[object String]') {
      value = JSON.stringify(value);
    }
    value = urlencode.decode(value, charset); // 这里做decode
    return `${key}=${decodeURIComponent(value)}`;
  }).join('&');
}

验签

const ALIPAY_ALGORITHM_MAPPING = {
  RSA: 'RSA-SHA1',
  RSA2: 'RSA-SHA256',
};
verifySign(content, sign, key, signType = 'RSA2', charset = 'utf-8') {
  charset = charset.toLowerCase();
  content = iconv.encode(content, charset); // 这里进行编码转换
  const cryptoSign = crypto.createVerify(ALIPAY_ALGORITHM_MAPPING[signType]);
  cryptoSign.update(content);
  return cryptoSign.verify(key, sign, 'base64');
}

虽然代码看着不多,但是这个问题是我始料未及的,希望如果官方能看到的话可以更新一下

4.egg-moe-cache

egg-moe-cache是基于egg-redis的一个封装,在没有封装之前,我们需要通过手动指定prefix或者cacheName,这样代码中既不美观也无法做到统一管理,于是我们将这部分重新封装,引入了model的概念

举例,比如需要读写某个管理员的行为权限,在原方法中我们需要这样写

const adminid = 1;
const name = `master/behavior/${adminid}`;
const set = await this.app.redis.set(name, xxx);
const get = await this.app.redis.get(name);

在同一个文件中,这样写是没有什么太大的问题的,但是如果遇到在不同文件中调用,麻烦就大了

此时key需要定义两次,并且如果某一方切换key,另外一方没有切换,那么就会导致业务上的错误

所以我们将需要做prefix的部分做了集中管理,以文件的形式对cache进行定义

以上面的举例,首先定义一个behavior.js在app/cache中

// app/cache/behavior.js
'use strict';
const { Store } = require('moe-cache');

module.exports = app => {
  const store = new Store(app);

  store.key = 'master/behavior/${target}';
  store.props = [
    'target',
  ];
  return store;
};

调用时逻辑与model类似

const adminid = 1;
const cache = this.app.cache.Behavior;
const set = await cache.set(adminid, xxx);
const get = await cache.get(adminid);

可以看到封装后的cache不再关注具体的key,只需要传递相应的target即可,即便是key有改变,只要target相同那么也不需要调整业务代码

当然,不是每一个key都会有前缀,所以这里也支持了this.app.cache.set/get的调用

5.egg-moe-builder

egg-moe-builder是一套打包用的cli工具,原理其实很简单,通过调用egg-moe-builder --build命令,builder会将common和当前目录打包到一起,使用zip加密,待部署使用,下面部署部分也会讲到,这里就不详细讲了

About 部署

在项目早期还是koa的时代,服务相对较少,我们采用线上打包+手动pm2来做部署

切换到了egg之后理所当然的也弃掉了pm2,此时随着服务的增加以及ecs的增加,不论是手动打包还是启动都是一个问题

于是我们开发了一套deploy系统,用来部署和监控运行的服务

首先我们通过egg-moe-builder将项目进行打包

egg-moe-builder会把项目打包为一个加密的zip文件,将打包好的package上传到deploy平台,输入密码发起部署命令

同时我们也可以将配置好的节点下载作为配置文件

在npm run build构建时选择对应的部署节点,在打包好之后会自动下发对应的部署命令

在构建方案的选择上,由于每台ecs的购入时间不同,环境也不同,所以取消了中心服务器构建的概念,而是采用了本机构建的方案

在构建完毕之后,deploy会优先建立一个backup并将流量切走,等待主程序重启完毕之后,再将流量切回,实现平滑重启

具体实现大致如下:

// util.js
const util = {
  createStream(name, ports = []) {
    const servers = ports.map(node => {
      return `server 127.0.0.1:${node};`;
    });
    const server = servers.join('\n');
    return `upstream ${name} {
        ${server}
        keepalive 64;
}`;
  },
  reload() {
    return new Promise((resolve, reject) => {
      const link = spawn('nginx', [ '-s', 'reload' ], {
        shell: true,
      });
      link.stdout.on('data', data => {
        console.log(`stdout: ${data}`);
      });
      link.stderr.on('data', data => {
        console.log(`stderr: ${data}`);
        reject(data);
      });
      link.on('close', code => {
        if (!code) {
          resolve(true);
        } else {
          reject();
        }
      });
    });
  },
  start(target = '', name = '', port = '') {
    const pack = fs.readFileSync(target + '/package.json');
    const pkg = JSON.parse(pack);
    const sticky = !!(pkg.scripts.start && pkg.scripts.start.indexOf('sticky') > -1);
    return new Promise((resolve, reject) => {
      const link = spawn('egg-scripts', [ 'start', '--daemon', `--title=egg-server-${name}`, `--port=${port}`, sticky ? '--sticky' : '' ], {
        cwd: target,
        shell: true,
      });
      link.stdout.on('data', data => {
        console.log(`stdout: ${data}`);
      });
      link.stderr.on('data', data => {
        console.log(`stderr: ${data}`);
        reject(data);
      });
      link.on('close', code => {
        if (!code) {
          resolve(true);
        } else {
          reject();
        }
      });
    });
  },
  stop(target = '', name) {
    return new Promise((resolve, reject) => {
      const link = spawn('egg-scripts', [ 'stop', `--title=egg-server-${name}` ], {
        cwd: target,
        shell: true,
      });
      link.stdout.on('data', data => {
        console.log(`stdout: ${data}`);
      });
      link.stdout.on('error', error => {
        reject(error);
      });
      link.on('close', code => {
        if (!code) {
          resolve(true);
        }
      });
    });
  },
};

// deploy 部分参考
this.ctx.runInBackground(async () => {

  await application.deploy.state(app, '正在构建'); // 更新状态
  await Promise.all([
    Builder.util.install(common),
    Builder.util.install(main),
  ]); // npm install
  // backup 格式为`${project}-backup`
  await util.stop(main, backup); // 尝试停止backup服务,避免冲突
  await util.start(main, backup, backupPort); // 启动backup
  await fs.writeFileSync(streamPath, util.createStream(server_name, [
    backupPort,
  ])); // 切换端口
  await new Promise(resolve => {
    setTimeout(() => {
      util.reload();
      resolve();
    }, 3000);
  }); // 等待3秒后 reload nginx
  await application.deploy.state(app, '准备启动');
  await util.stop(main, app); // 停止主服务
  await application.deploy.state(app, '启动中');
  await util.start(main, app, port); // 启动主服务
  await fs.writeFileSync(streamPath, util.createStream(server_name, [
    backupPort,
  ])); // 将端口切回
  await new Promise(resolve => {
    setTimeout(() => {
      util.reload();
      resolve();
    }, 3000);
  }); // 等待3秒后 reload nginx
  await util.stop(main, backup); // 停掉backup
});

About 云下的东西

前面说过我们在学校端也有部署一些服务,这些服务都是部署在学校内部的,同时,我们的合作伙伴也会部署服务到学校端,但是学校内网是无法访问的,并且每个合作伙伴的业务也不互通,最终会形成信息孤岛

于是我们研发了一套分布式的内网管理系统,提供合作伙伴对应的api接入,通过长连接与云服务保持通信,统一接口格式,将三端打通

这套系统部署在树莓派上,部署时只需要将树莓派加入学校网络并授权连接,根据学校的规模可增加更多的节点,只要在同一个内网中则会自动组网

-End-

本文分享自微信公众号 - Nodejs技术栈(NodejsRoadmap),作者:Derek Yeung

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-06-24

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 不容错过的 Node.js 项目架构

    Express.js 是用于开发 Node.js REST API 的优秀框架,但是它并没有为您提供有关如何组织 Node.js 项目的任何线索。

    五月君
  • JavaScript 浮点数之迷:0.1 + 0.2 为什么不等于 0.3?

    “0.1 + 0.2 = ?” 这个问题,你要是问小学生,他也许会立马告诉你 0.3。但是在计算机的世界里就没有这么简单了,做为一名程序开发者在你面试时如果有人...

    五月君
  • 一次 RabbitMQ 生产故障引发的服务重连限流思考

    原由是生产环境 RabbitMQ 消息中间件因为某些原因出现了故障导致当时一些相关的服务短时间不可用,后来 RabbitMQ 修复之后,按理来说服务是要正常恢复...

    五月君
  • vscode源码分析【三】程序的启动逻辑,性能问题的追踪

    代码文件:src\main.js 如果指定了特定的启动参数:trace vscode会在启动之初,执行下面的代码:

    liulun
  • 对新入门程序员,有用的几点建议!

    计算机专业的学生,可能有一种错觉,觉得大部分程序员,都在编写公开出售的软件或者通用软件。

    Java学习
  • 数据安全与隐私保护

    数据安全自古有之,并不是一个全新的概念。冷兵器时代的战争就非常关注情报,通过情报可以了解竞争对手的强项和弱项,从而制定制敌的方法和手段。而数据保护就是针对这个情...

    用户7321376
  • 程序员:不要自称码农

    计算机专业的学生,可能有一种印象,觉得大部分程序员,都在编写公开出售的软件或者通用软件。

    恒宇少年
  • 强化学习方法汇总,以及他们的区别

    了解强化学习中常用到的几种方法,以及他们的区别, 对我们根据特定问题选择方法时很有帮助. 强化学习是一个大家族, 发展历史也不短, 具有很多种不同方法. 比如...

    机器人网
  • 通过计算器了解简单工厂模式

    简单工厂模式(Simple Factory Model),又叫做静态工厂方法模式(Static Factory Method Model),属于创建型模式(也就...

    程序员小强
  • 【聚焦】数据分析能力的8个等级

    并非所有的分析方法作用都相同。和大多数软件解决方案一样,你会发现分析方法的能力也存在差异,从简单明了的到高级复杂。下面我们按照不同分析方法所能给人带来的智能程度...

    小莹莹

扫码关注云+社区

领取腾讯云代金券