首发于尹锋以为
流水不腐,户枢不蠹 — 设计可维护可扩展的系统(上)

流水不腐,户枢不蠹 — 设计可维护可扩展的系统(上)

我们要深刻理解架构的核心要素,基于可信导向来进行架构与设计。在确保可信的前提下,要在性能、功能、扩展性等方面做好权衡;慎重地定义我们的模块与接口,真正做到高内聚与低耦合;低阶架构与设计要遵循高阶的架构与设计原则,在充分理解原有架构与设计的情况下,持续优化;我们要熟悉各种设计模式,重用公共成熟组件和服务,避免重复劳动。

— 任正非《全面提升软件工程能力与实践,打造可信的高质量产品》


近期,华为腾讯都宣布投入重金和精力在技术建设上,华为更是宣布全面提升软件质量,视高质量代码为尊严和个人荣誉。这是一个非常大的话题,涉及到意识观念、方法论、解决方案、流程制度和组织结构等方面。

本文落脚在方法论和解决方案上(文末有其他部分的链接),重点介绍部分软件开发思想,讲解如何利用设计模式、设计原则和编程范式,设计易懂、可扩展的和高内聚低耦合的系统。也会讲解这些思想的实现,你可以将这些设计思想和实现,纳入工具箱,选择适合的进行设计开发。

KISS

KISS原则(Keep It Simple & Stupid),是一种归纳过的经验原则。KISS 原则是指在设计当中应当注重简约的原则。总结工程専业人员在设计过程中的经验,大多数系统的设计应保持简洁和单纯,而不掺入非必要的复杂性,这样的系统运作成效会取得最优;因此简单性应该是设计中的关键目标,尽量回避免不必要的复杂性。

说明这个原则最好的例证,就是约翰逊(KISS 的创造者)向一群设计喷气式飞机工程师提供了一些工具,他们所设计的喷气式飞机,必须可由普通机械师只使用这些工具在战斗情况下进行现场修理。 因此,“愚蠢”是指被设计的物品在损坏与修复的关联之间,它们的难易程度。这个缩写词已被美国军方,以及软件开发领域的许多人所使用。

DRY

DRY 是 Don’t Repeat Yourself 的缩写,翻译过来就是「不做重复事」。

这正是一个逼近软件本质的原则,它指导我们把经常使用的功能抽象成库,把重复出现的代码重构为可重用的框架模块。如果你用 DRY 来要求自己,很快你就会发现自己抽象和架构能力的飙升。

在实践中大多遵循 rule of three,是指:相同的代码片段重复出现三次及以上的时候,将其提取出来做成一个模块就势在必行了。

正交性

“正交性”是几何学中的术语,互为直角的直角坐标系就具有正交性;在计算技术中表示不依赖性或解耦性。

在软件开发领域,系统应该由一组相互协作的模块组成,每个模块的实现都不依赖于其他模块的功能。有时,这些组件被组织为多个层次,每层提供一级抽象。这种分层的途径是设计正交系统的强大方式。因为每层都只使用在其下面的层次提供的抽象,在改动底层实现、而又不影响其他代码方面,拥有极大的灵活性。分层也降低了模块间依赖关系的风险。

API 其实也是强化正交性的利器,它通过接口规范确定了互不影响的功能,又通过接口协议隐藏了内部实现,去除了对实现技术的依赖性。

SOLID

Robert C. Martin. 在 21 世纪初期提出了面向对象设计的五大基本原则 SOLID (单一功能、开闭原则、里氏替换、接口隔离以及依赖反转),运用这些原则,工程师能够开发出来更易懂、易维护和可扩展的系统。虽然 SOLID 是面向对象的设计,但是也适用于敏捷开发等其他软件开发过程。

下面介绍一下 SOLID 中的单一功能原则和开闭原则,以及实现方式。

Single Responsibility Principle 单一功能原则

单一功能原则:每个类都应该有一个单一的功能,并且该功能应该由这个类完全封装起来。所有它的(这个类的)服务都应该严密的和该功能平行(功能平行,意味着没有依赖)。简单说,就是 Unix 设计哲学:

Do One Thing, Do It Well。

因而,单一职责和高内聚某种程度是同义词。一个类或者模块应该有且只有一个改变的原因。

一个具体的例子就是,想象有一个用于编辑和打印报表的模块。这样的一个模块存在两个改变的原因。第一,报表的内容可以改变(编辑)。第二,报表的格式可以改变(打印)。单一功能原则认为这两方面的问题事实上是两个分离的功能,因此他们应该分离在不同的类或者模块里。把有不同的改变原因的事物耦合在一起的设计是糟糕的。

保持一个类专注于单一功能点上的一个重要的原因是,它会使得类更加的健壮(同样适用于模块、函数)。

Open Close Principle 开闭原则

开闭原则:软件中的对象(类,模块,函数等等)应该对于扩展是开放的(你可能会好奇,函数怎么扩展,不用担心,后面会讲),但是对于修改是封闭的。

对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。

对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对类进行任何修改。

这意味着一个实体是允许在不改变它的源代码的前提下变更它的行为(通过扩展它)。该特性在产品化的环境中是特别有价值的,在这种环境中,改变源代码需要代码审查,单元测试以及诸如此类的用以确保产品使用质量的过程。遵循这种原则的代码在扩展时并不发生改变,因此无需上述的过程。

这个原则可以极大的提高系统可扩展性,后面的大部分篇幅都是围绕这个原则展开的,如何通过扩展(而不是修改)现有源代码增强现有的模块、类和系统的功能。

AOP 面向切面编程

AOP(Aspect Oriented Programming),即面向切面编程,是一种设计思想。可以理解为 OOP(面向对象编程)里程碑式的补充,OOP 是从静态角度考虑程序结构,从横向上区分出类,AOP 是从动态角度考虑程序运行过程,从纵向上向对象中加入特定的代码。

从主关注点中分离出横切关注点是面向切面的程序设计的核心概念。分离关注点 使得解决特定领域问题的代码从主业务逻辑中独立出来,业务逻辑的代码中不再含有针对特定领域问题代码的调用,他们的关系通过切面来封装、维护,这样原本分散在在整个应用程序中的不变部分和变动部分就可以很好的分开管理起来。

直白一点,OOP 是将做同一件事情的业务逻辑封装成一个对象。但是,在做一件事情过程(主流程)中又想做别的事情(比如页面统计、日志打点等)对 OOP 来说难以解决。AOP 出现了,他将这部分逻辑抽离出来,让 OOP 代码能专注于主流程,更好地遵循单一职责原则,提高内聚性。

举个例子,对于一个信用卡应用程序来说,存款、取款、帐单管理是它的主关注点,日志、权限控制和持久化将成为横切关注点,他们与信用卡业务本身没有关系,却是被业务模块所调用,属于系统不可或缺的部分。具体以日志为例,在存款前后把金额打印出来:

Function.prototype.before = function(beforefn) {
  var _self = this;
  return function() {
    beforefn.apply(this, arguments);
    return _self.apply(this, arguments);
  };
};
​
Function.prototype.after = function(afterfn) {
  var _self = this;
  return function() {
    var ret = _self.apply(this, arguments);
    afterfn.call(this, arguments, ret);
    return ret;
  };
};
​
var deposit = function(moeny) {
  var current = 100;
  console.log("deposit: " + moeny + ", current: " + current);
  return current + moeny;
};
​
deposit = deposit
  .before(function(moeny) {
    console.log("before: " + moeny);
  })
  .after(function(arg, moeny) {
    console.log("after: " + moeny);
  });
​
deposit(5);

存款的逻辑在 deposit 中实现,而那些与业务无关的逻辑,日志、权限管理和持久化等外部行为,在 beforeafter 这两个连接点切入,将这些外部行为和业务逻辑分离开来,减少系统的重复代码,也降低了系统的耦合度,便于未来扩展。

这段代码并非 AOP 优雅的实现,仅对 AOP 设计思想做一个大概的介绍和基础实现,后续会有更优雅的实现方案。

模块模式

模块模式:通过执行一个匿名函数,形成一个闭包(closure),返回一个对象,避免全局变量的泛滥和其他脚本的冲突,有助于保持项目的代码单元分离和组织。

let EmployeeModule = (function() {
  let list = [];
  return {
    add: function(employee) {
      list.push(employee);
    },
    getAll: function() {
      return list;
    },
    totalCount: function() {
      return list.count;
    }
  };
})();

我们可以这样来调用 EmployeeModule:

EmployeeModule.add({ id: "001", name: "Tesla" });
EmployeeModule.add({ id: "002", name: "Apollo" });
​
EmployeeModule.getAll();

如果模块不需要修改升级,这种方式是完美的。但是事与愿违,我们的业务逻辑是变化的,可能需要扩展新的功能,可能需要修改现有方法,这个时候需扩展它、增强他,可以将原来的 Module 作为参数传给立即执行函数,创建一个闭包,给闭包其添加新方法和修改原有的方法。

let EmployeeModule = (function(my) {
  my.anotherFunction = function() {
    console.log("this is another function");
  };
  my.add = function(employee) {
    // override existing function
  };
  return my;
})(EmployeeModule);

这里简单介绍了一下模块模式,他是 JavaScript 中一个非常经典的模式,被各种 JavaScript 框架广泛使用。

Decorator 装饰器模式

AOP 的思想就是扩展现有函数的功能,同时不改变现有函数的功能。细心的读者或许已经发现,这其实和装饰器模式是一致的。

在 OOP 中,装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。

在 JavaScript 中,ES7 已经支持 Decorator,用来修改类或者类的属性的行为。下面举例说明如何在 ES7 中通过 Decorator 修改类的属性的行为,在执行方法之前,打印日志信息:

function log(target, name, descriptor) {    // Decorator 实现
  var oldValue = descriptor.value;
​
  descriptor.value = function() {
    console.log(`Calling ${name} with`, arguments);  // 扩展功能
    return oldValue.apply(this, arguments);
  };
​
  return descriptor;
}
​
class Math {
  @log          // 调用 Decorator
  add(a, b) {
    return a + b;
  }
}
​
const math = new Math();
​
math.add(2, 4);

表面上看,Decorator 只是扩展现有函数的功能,其实这有另一种玩法:函数式编程,通过一个函数来构造另一个函数,享受函数拼装组合带来的好处,其实大部分 AOP 的实现都可以利用这一点。

深入一下,log 这个 Decorator 几乎可以装饰所有的函数。因此方便的将一些非业务逻辑的、其他控制类代码抽象出来,封装到 Decorator 中来。

目前,Decorator 已经在各知名框架中开始大面积使用,包括 Angular、React、Redux 和 AntD 等。

Middleware 中间件

在传统软件开发领域, 中间件是提供系统软件和应用软件之间连接的软件,以便于软件各部件之间的沟通。我们这里说的中间件和传统软件开发领域中的中间件有所差异。

如果你使用过 Express、Koa 或者 Redux,你会发现他们都有 Middleware 的概念,他们都是一种拦截器的思想,其实也是 AOP 的一种实现,都是在特定的输入输入之间,添加一些额外的处理逻辑,同时不影响原有的操作。

Express、Koa 和 Redux 中间件的总体目标是一致的,设计思路却不尽相同,下面剖析一下 Koa 的实现方式。

在 Koa 中,通过 app.use(fn) 加载一个中间件。


const Koa = require('koa');
const app = new Koa();

// x-response-time middleware
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// logger middleware
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response middleware
app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

x-response-timeloggerresponse 都是中间件,x-response-time 设置了 HTTP Header X-Response-Time,logger 打印服务端处理耗时。

当请求开始时首先请求流通过 x-response-timelogger 中间件,然后继续移交控制给 response 中间件。当一个中间件调用 next() 则该中间件暂停执行,并将控制权传递给定义的下一个中间件。当在下游没有更多的中间件执行后,堆栈将展开并且每个中间件恢复执行其上游行为,类似于洋葱结构。




use(fn) {
  if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
  if (isGeneratorFunction(fn)) {
    deprecate('Support for generators will be removed in v3. ' +
              'See the documentation for examples of how to convert old middleware ' +
              'https://github.com/koajs/koa/blob/master/docs/migration.md');
    fn = convert(fn);
  }
  debug('use %s', fn._name || fn.name || '-');
  this.middleware.push(fn);
  return this;
}

app.use 中的逻辑其实非常简单,this.middleware.push(fn); 然后是 listen 方法

listen(...args) {
  debug('listen');
  const server = http.createServer(this.callback());
  return server.listen(...args);
}

http.createServer 接受一个函数 this.callback() 作为参数。

callback() {
  const fn = compose(this.middleware);

  if (!this.listenerCount('error')) this.on('error', this.onerror);

  const handleRequest = (req, res) => {
    const ctx = this.createContext(req, res);
    return this.handleRequest(ctx, fn);
  };

  return handleRequest;
}

compose 之前 push 的 middleware,然后返回一个 handleRequest 函数,这个函数首先通过 createContext 得到当前的请求上下文 ctx(挂载了当前的 request、response、cookie等),然后将 ctxfn 传入 this.handleRequest,并将其结果返回。

下面再看一下 this.handleRequest 的实现。

handleRequest(ctx, fnMiddleware) {
  const res = ctx.res;
  res.statusCode = 404;
  const onerror = err => ctx.onerror(err);
  const handleResponse = () => respond(ctx);
  onFinished(res, onerror);
  return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

控制权转到 fnMiddleware(ctx),执行完成后,handleResponse 最后对响应做处理。

fnMiddleware 其实就是前面传入的 compose(fn) 的结果,所以这部分配合 compose 源码一起看。

function compose(middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  return function (context, next) {
    // last called middleware
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, function next () { 
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

compose 中最重要的就是 returnPromise.resolveresolvefn 是从 middleware[] 中取出来的,也就是 app.use 的函数,return dispatch(i + 1) 表示会依次执行所有的 middleware

至此,koa 的中间件实现就完成了。Express 和 Redux 的中间件网络也有很多文章,这里就不赘述了。

Koa 整体的设计和实现都很高级、精炼,一个关键的设计点是提供了高级“语法糖”。 这提高了互操作性,稳健性,并使书写中间件更加愉快,编写中间件的成本非常低。

在 Koa 中,中间件除了实现日志、权限管理和缓存管理等常见任务以外,所有业务逻辑、路由处理,都是通过中间件完成。

之所以要来分析 Middleware 的源码实现,是因为这是一个很重要的设计思想,也是一件很重要的武器。目前 JavaScript 主流框架都有类似的实现。

小结

这里介绍了一些重要的软件开发原则和设计思想,KISS、DRY、正交设计。也介绍了 SOLID 原则,他是基于 OOP 提出来的,其实也适用于敏捷开发和其他软件开发过程。介绍了 AOP 面向切面编程,他的核心是关注点分离,将功能代码(日志、权限管理、持久化)从业务逻辑代码中分离出来。还重点介绍了装饰器和中间件,他们是 AOP 编程思想的一种实现,同时也体现了前面的软件设计思想。

下一篇会重点介绍在 React 项目中,如何使用这些设计思想,来提高系统的可维护性可扩展性。

尹锋:流水不腐,户枢不蠹 — 设计可维护可扩展的系统(下)zhuanlan.zhihu.com图标

推荐阅读

尹锋:如何优雅的编写 JavaScript 代码zhuanlan.zhihu.com图标

编辑于 2019-03-04

文章被以下专栏收录