前端服务可维护性差?最低成本拆解EGG服务实践分享

619 阅读8分钟

大家好,我是曹俊_Eren。本文可能更适用于有多人协作开发的中大型项目,当然如果你的项目因为各种原因导致代码文件杂乱无章,也是适用。

Ryan Dahl自 2009 年创造出 nodejs 距今已经12年,业界已经有很多成熟的框架,比如express、koa、egg等。在编写服务端代码时,往往都会划分出router层、controller层和service层,当然也会有一些其他层,比如model层和view层。

  • router: 监听页面的路由,并调用Controller层的路由处理函数
  • controller: 给Router层提供服务,调用Service层提供的数据处理
  • service: 实现具体的功能

router像是前台,根据顾客的需求指引进店;controller像是服务员,为顾客点菜下单是她的工作;service就是厨师,菜的味道好不好都由他决定。

如上面所说的,笔者在服务端中建好router、controllr、service三个文件夹,将各个业务模块的代码分别存放到里面,这种方式简单快捷,很适合项目的快速起步。后来团队的开发者慢慢增多,接近有4~5个人开发这个项目。

一起来看下这个项目的整体结构

image.png

可以看到应用app下有controller、service和router,是很典型的单体应用。然后展开controller目录可以看到

image.png

发现有很多不同的模块(抑或是功能?)聚合在里面。

我来简单概括下上图的问题:

首先,这个项目没有严格的模块划分,每个组员按照各自的理解创建目录和文件,久而久之架构混乱,已经嗅到腐烂的气息。一个模块的controller、service文件各自在一个大型的目录下,在开发一个模块时要跨越整个项目的多个目录,开发体验不好。

其次,没有区分公共代码和业务代码,开发者想查找、调用一些通用逻辑时比较困难,新人学习成本高。

每个开发者的工作习惯都不一样,对模块的划分理解不一样。新开发者可能没有意识到文件目录的命名规范,在编写工具类时也可能没有注意到公共内容和业务内容的分离。

通过口口相传的规范无法彻底被执行,也就是我们所谓的“部落知识”(在一个组织里面独有的知识)。开发者造成混乱没有局限在一定范围的话,最后可能需要重构整个项目。

上述问题,归根到底在于系统架构不清晰缺乏模块的划分或者模块划分不明确,聚度低、高耦合。

试想,如果我们将项目划分为多个模块,在开发某个模块时,在它的目录下就可以完成所有功能。如果出现的坏代码,只需要重构模块所在目录,而不需要重构整个项目,会不会更好呢?

好的设计

微服务、领域驱动设计是很流行的话题,但前端服务的复杂度往往不高。如果拆分成多个服务,不仅需要投入大量人力成本,还可能会发现每个服务都比较“单薄”,很多时候稍庞大的前端服务都是“比上不足不下有余”。

领域驱动设计在互联网业务开发中的实践中详细讲解微服务是如何拆分的,里面提到的“分治”思想可能对前端服务提高可维护性有帮助。

分治 把问题空间分割为规模更小且易于处理的若干子问题。分割后的问题需要足够小,以便一个人单枪匹马就能够解决他们;其次,必须考虑如何将分割后的各个部分装配为整体。分割得越合理越易于理解,在装配成整体时,所需跟踪的细节也就越少。即更容易设计各部分的协作方式。评判什么是分治得好,即高内聚低耦合。

因此根据业务需求,设计业务模块以及它们之间的关系非常重要。当系统越来越庞大,每一个模块会越发接近一个微型应用。多人协作时,每个人负责会有各自模块,如果每个模块都是一个微型应用,每个人可以在模块里面完成所有功能的开发,是一件很有趣的事情。

image.png

如果某个业务因为频繁变化而导致代码腐烂,也仅仅是重构这个模块即可,对其他模块没有影响。

egg框架

笔者的项目使用的是 egg.js 框架,这是它的设计原则之一

按照约定进行开发,奉行『约定优于配置』

egg 可以自动抓取 router 、controller、service、middleware 等目录下的文件,将方法挂载在特定上下文变量。

默认的目录结构是这样的:

image.png

这是一种大单体应用的结构,不好划分模块。怎么样通过工程化手段,把单体应用划分成多个微型应用了?

egg 有一个很好的功能是可以定制上层框架,详情可以参考官网章节 框架开发 ,接下来深入了解一下

微应用的实现

要实现单体应用划分成多个微型应用的需求,需要使用到egg提供的框架继承自定义加载器的功能。

框架继承

首先需要创建一个npm应用,项目包含下面文件和代码

// package.json
{
  "name": "my-framework",
  "dependencies": {
    "egg": "^2.0.0"
  }
}

// index.js
module.exports = require('./lib/framework.js');

// lib/framework.js
const path = require('path');
const egg = require('egg');
const EGG_PATH = Symbol.for('egg#eggPath');

class Application extends egg.Application {
  get [EGG_PATH]() {
    // 返回 framework 路径
    return path.dirname(__dirname);
  }
}

// 覆盖了 Egg 的 Application
module.exports = Object.assign(egg, {
  Application,
});

然后在我们的服务应用中,通过package.json引入

// package.json
{
  "scripts": {
    "dev": "egg-bin dev"
  },
  "egg": {
    "framework": "my-framework"
  }
}

这样就可以实现一个框架的继承,但依旧没有实现我们需要的功能。我们可以利用继承的特性,重写现有egg提供的功能。

自定义加载器

egg之所以可以自动抓取目录下的文件,是因为它的加载器(Loader)。如果想要自动抓取模块下的目录,就需要定制egg的文件加载功能。

可以通过继承 Loader 类,重写它的方法,进而实现自定义文件加载

// lib/framework.js
const path = require('path');
const egg = require('egg');
const EGG_PATH = Symbol.for('egg#eggPath');

+ class MyAppWorkerLoader extends egg.AppWorkerLoader { // AppWorkerLoader 是什么呢?
+   load() {
+     super.load();
+     // 自己扩展
+   }
+ }

class Application extends egg.Application {
  get [EGG_PATH]() {
    // 返回 framework 路径
    return path.dirname(__dirname);
  }
  // 覆盖 Egg 的 Loader,启动时使用这个 Loader
  get [EGG_LOADER]() {
    return YadanAppWorkerLoader;
  }
}

// 覆盖了 Egg 的 Application
module.exports = Object.assign(egg, {
  Application,
+   // 自定义的 Loader 也需要 export,上层框架需要基于这个扩展
+   AppWorkerLoader: MyAppWorkerLoader,
});

代码中的 AppWorkerLoader 继承于 Loader 基类,在官网的加载器章节中说到 Loader 有下面方法:

  • loadPlugin():加载插件
  • loadConfig():加载配置
  • loadAgentExtend():加载agent对象的extend对象
  • loadApplicationExtend():加载app对象的extend对象
  • loadRequestExtend(): 加载request对象
  • loadResponseExtend():加载response对象
  • loadContextExtend(): 加载context对象
  • loadHelperExtend(): 加载工具方法
  • loadCustomAgent(): 加载定义的agent对象
  • loadCustomApp(): 加载定义的app对象
  • loadService(): 加载service
  • loadMiddleware(): 加载中间件
  • loadController(): 加载controller
  • loadRouter(): 加载路由文件

从继承关系 MyAppWorkerLoader -> AppWorkerLoader -> Loader 看来,我们完全可以在 MyAppWorkerLoader 中定制上面所有的方法

定制 controller 的加载

Loader 类的 loadController 方法实现了controller目录的抓取,在 egg-core 包可以找到它。

// egg-core/lib/loader/mixin/controller.js
const path = require('path');
const is = require('is-type-of');

module.exports = {
  /**
   * Load app/controller
   * @param {Object} opt - LoaderOptions
   * @since 1.0.0
   */
  loadController(opt) {
    opt = Object.assign({
      caseStyle: 'lower', // 转换文件名的首字母
      directory: path.join(this.options.baseDir, 'app/controller'), // controller所在位置
      initializer: (obj, opt) => { // 对每个文件 export 出来的值进行处理
        if (is.function(obj) && !is.generatorFunction(obj) && !is.class(obj) && !is.asyncFunction(obj)) { // 判断controller是不是函数
          obj = obj(this.app); // 传入app对象到controller函数中
        }
        if (is.class(obj)) { // 判断controller是不是类
          obj.prototype.pathName = opt.pathName;
          obj.prototype.fullPath = opt.path;
          return wrapClass(obj);  // 主要是定义执行在controller前面的middleware
        }
        if (is.object(obj)) {
          return wrapObject(obj, opt.path); // 主要是定义执行在controller前面的middleware
        }
        // support generatorFunction for forward compatbility
        if (is.generatorFunction(obj) || is.asyncFunction(obj)) {
          return wrapObject({ 'module.exports': obj }, opt.path)['module.exports']; // 主要是定义执行在controller前面的middleware
        }
        return obj;
      },
    }, opt);
    const controllerBase = opt.directory;

    this.loadToApp(controllerBase, 'controller', opt); // 将controller导出的对象挂在在app上下文对象中
  },
};

从上面代码可见,通过传入参数opt.directory可以定义controller的所在目录

// lib/framework.js
const path = require('path');
const egg = require('egg');
const EGG_PATH = Symbol.for('egg#eggPath');

class MyAppWorkerLoader extends egg.AppWorkerLoader {
+  loadController(opt) {
+    super.loadController(Object.assign({ // 调用父类的loadController方法
+      directory: [
+        ...['your/controller/path'],  // 自定义的controller路径
+        path.resolve(process.cwd(), 'app/controller'),  // egg的controller默认路径 
+      ],
+      override: false, // 遇到已经存在的文件时是直接覆盖还是抛出异常
+    }, opt));
+  }

class Application extends egg.Application {
  get [EGG_PATH]() {
    // 返回 framework 路径
    return path.dirname(__dirname);
  }
  // 覆盖 Egg 的 Loader,启动时使用这个 Loader
  get [EGG_LOADER]() {
    return YadanAppWorkerLoader;
  }
}

// 覆盖了 Egg 的 Application
module.exports = Object.assign(egg, {
  Application,
   // 自定义的 Loader 也需要 export,上层框架需要基于这个扩展
   AppWorkerLoader: MyAppWorkerLoader,
});

通过以上代码就可以实现controller目录的扩展。

此外,我们还可以扩展 router、service、middleware、extend 目录,想要了解具体实现的同学,可以参考封装好的npm包 egg-micro-app 。核心内容在文件lib/framework.js中,代码不到100行非常简单。

这样便可以实现将大型项目拆解为多个独立模块,每一个模块都是一个微应用,大家可以按照自己的想法将业务模块和通用文件安放到不同的位置,完结撒花。

image.png