模式系统与最简单的Node.js MVC Web Server设计

@xingbofeng 2018-01-12 13:39:18发表于 xingbofeng/xingbofeng.github.io JavaScriptNode.js编程/思考

学了这么久的设计模式,最近一直在看Node.js的设计模式,一直纳闷为何会有模式这一类东西的存在,那么模式究竟是什么东西?后面在看了《面向模式的软件架构》之后才慢慢知道有了一些系统的概念。

模式是什么?

面对特定问题时,专家很少去寻找与既有解决方案截然不同的新方案,而通常会想起一个以前解决过的类似问题,并将其解决方案的精髓用于解决这个新问题。

从特定问题—解决方案中提炼出通用的因素便可得到模式:这些问题—解决方案通常是一系列熟悉的问题和解决方案,其中每对问题—解决方案都呈现出相同的模式。

Model-View-Controller模式

MVC模式大量用在现代软件开发流程中,为何会有MVC模式的存在,来看这一个例子:开发带人机界面的软件。

用户界面需求容易变化。例如,添加应用程序功能时,必须修改菜单以便能够访问新功能,还可能需要针对特定客户调整用户界面。系统可能需要移植到另一个平台,而该平台采用的“外观”标准完全不同。即便是升级到新的窗口系统版本,也可能需要修改代码。总之,如果系统的使用寿命很长,可能经常需要修改用户界面。设计灵活的系统时,让用户界面与功能核心紧密地交织在一起将付出高昂的代价,且容易出错。这样做的后果是,可能需要开发和维护多个大不相同的软件系统——每种用户界面实现一个,且修改将涉及众多不同的模块。总之,开发这种交互式软件系统时,必须考虑如下两个方面:

  • 应该能够轻松地修改用户界面,在运行阶段就能完成;
  • 调整或移植用户界面时,不应影响到应用程序功能核心的代码。

为解决这种问题,应将交互式应用程序划分成三部分:处理、输出和输入。

  • 模型(model)组件封装核心数据和功能,独立于输出表示方式和输入行为。
  • 视图(view)组件向用户显示信息。视图从模型那里获取它显示的信息,一个模型可以
    有多个视图。
  • 每个视图都有相关联的控制器(controller)组件。控制器接受输入,通常是表示鼠标移动、鼠标按钮激活或键盘输入的事件。事件被转换为服务请求,而服务请求要么被发送给模型,要么被发送给视图。用户只通过控制器与系统交互。

通过将模型与视图和控制器组件分开,让同一个模型可以有多个视图。如果用户通过一个视图的控制器修改了模型,这种变更应在依赖相关数据的其他所有视图中反映出来。为此,每当模型的数据发生变化时,它都会通知所有视图,而视图将从模型那里检索新数据,并更新显示的信息。这种解决方案确保了修改应用程序的一个子系统时不会严重影响其他子系统。例如,可将非图形用户界面改成图形用户界面而无需修改模型子系统,还可支持新的输入设备而不影响信息的显示和功能核心。所有软件版本都可依赖同一个模型子系统,该子系统独立于“外观”。

用Model-View-Controller模式实现一个鉴权服务

我们从下图所示的结构开始分析:

上图显示了Model-View-Controller模式的典型示例;它描述了一个简单的鉴权服务的结构。AuthController接受来自客户端的输入,从请求中提取登录信息,并执行一些初步验证。之后AuthService检查客户端提供的凭证是否与存储在数据库中的信息匹配;最后使用db模块执行一些特定的查询来完成的,作为与数据库通信的一种手段。这三个组件连接在一起的方式将决定应用程序的可重用性,可测试性和可维护性。

在这里:模型(Model)指的就是db模块,控制器(Controller)指的就是AuthControllerAuthService,而视图则是前端的用户界面,也就是HTML文档。

将这些组件连接在一起的最自然的方法是通过AuthService请求db模块,然后从AuthController请求AuthService

让我们通过实际实现刚刚描述的系统来演示这一点。那么我们来设计一个简单的鉴权服务器,它将有以下两个HTTP API

  • POST '/ login':接收包含用户名和密码对进行身份验证的JSON对象。 成功时,它会返回一个JSON Web Token(JWT),随后的请求中使用它来验证用户的身份。

  • GET'/ checkToken':查看用户是否具有权限。

对于这个例子,我们将使用几种技术;这对我们来说并不陌生。我们使用express来实现Web APIlevelup来存储用户的数据。

db模块

我们先从底层开始构建应用程序;首先实现levelUp数据库实例的模块。我们来创建一个名为lib/db.js的新文件,其中包含以下内容:

const level = require('level');
const sublevel = require('level-sublevel');
module.exports = sublevel(
  level('example-db', {
    valueEncoding: 'json'
  })
);

前面的模块是存储在./example-db目录中的LevelDB数据库的连接,然后使用sublevel来修饰实例,通过这一模块实现了增删查改数据库。模块导出的对象是数据库对象本身。

authService模块

现在我们有了db单例,我们可以使用它来实现lib/authService.js模块,它负责查询数据库,根据用户身份凭证查看用户是否具有权限。 代码如下(只显示相关部分):

"use strict";

const jwt = require('jwt-simple');
const bcrypt = require('bcrypt');

const db = require('./db');
const users = db.sublevel('users');

const tokenSecret = 'SHHH!';

exports.login = (username, password, callback) => {
  users.get(username, (err, user) => {
    if(err) return callback(err);
    
    bcrypt.compare(password, user.hash, (err, res) => {
      if(err) return callback(err);
      if(!res) return callback(new Error('Invalid password'));
      
      let token = jwt.encode({
        username: username,
        expire: Date.now() + (1000 * 60 * 60) //1 hour
      }, tokenSecret);
      
      callback(null, token);
    });
  });
};

exports.checkToken = (token, callback) => {
  let userData;
  try {
    //jwt.decode will throw if the token is invalid
    userData = jwt.decode(token, tokenSecret);
    if (userData.expire <= Date.now()) {
      throw new Error('Token expired');
    }
  } catch(err) {
    return process.nextTick(callback.bind(null, err));
  }
    
  users.get(userData.username, (err, user) => {
    if (err) return callback(err);
    callback(null, {username: userData.username});
  });
};

authService模块实现login()服务,该服务负责查询数据库,检查用户名和密码信息,checkToken()服务接受token作为参数并验证其有效性。

authController模块

继续在应用程序的层次上,我们现在要看看lib/authController.js模块。这个模块负责处理HTTP请求,它本质上是Express路由的集合;该模块的代码如下:

"use strict";

const authService = require('./authService');

exports.login = (req, res, next) => {
  authService.login(req.body.username, req.body.password,
    (err, result) => {
      if (err) {
        return res.status(401).send({
          ok: false,
          error: 'Invalid username/password'
        });
      }
      res.status(200).send({ok: true, token: result});
    }
  );
};

exports.checkToken = (req, res, next) => {
  authService.checkToken(req.query.token,
    (err, result) => {
      if (err) {
        return res.status(401).send({
          ok: false,
          error: 'Token is invalid or expired'  
        });
      }
      res.status(200).send({ok: 'true', user: result});
    }
  );
};

authController模块实现两个Express路由:login()用于执行登录操作并返回相应的tokencheckToken()用于检查token的有效性。这两个路由委托他们的大部分逻辑到authService,所以他们唯一的工作是处理HTTP请求和响应。

app模块

最后,在应用程序的入口点,我们调用我们的controller。遵循约定,我们将把这个逻辑放在名为app.js的模块中,放在我们项目的根目录下,如下所示:

"use strict";

const Express = require('express');
const bodyParser = require('body-parser');
const errorHandler = require('errorhandler');
const http = require('http');

const authController = require('./lib/authController');

let app = module.exports = new Express();
app.use(bodyParser.json());

app.post('/login', authController.login);
app.get('/checkToken', authController.checkToken);

app.use(errorHandler());
http.createServer(app).listen(3000, () => {
  console.log('Express server started');
});

我们可以看到,我们的应用程序模块是非常基础的。 它包含一个简单的Express服务器,它注册了一些中间件和authController导出的两条路由。这也就是一个简单的包含controllermodelWeb服务,添加好前端HTML页面,也就实现了MVC架构的分离

模式的特征

  • 模式阐述了在特定设计情形下反复出现的问题,并提供了解决方案。
  • 模式记录了已得到充分证明的既有设计经验。
  • 模式描述了超越类、实例和组件的抽象。
  • 模式提供了一种通用语言,并让大家对设计原则有一致的认识。
  • 模式是一种记录软件架构的手段。
  • 模式有助于创建具有指定特征的软件。
  • 模式有助于打造复杂而异质的软件架构。
  • 模式有助于控制软件的复杂度。

为什么叫模式

每个模式都包含三部分:

  • 背景(Context) 问题出现的背景;
  • 问题(Problem) 该背景下反复出现的问题;
  • 解决方案(Solution) 经过实践检验的解决之道。

背景

背景描绘了问题发生的情形,让原本平淡无奇的问题—解决方案更为丰满。模式的背景可能非常笼统,如“开发带人机界面的软件”,也可能将具体的模式联系在一起,如“在模型、视图和控制器之间实现变更传播机制”。

问题

模式描述纲要的这部分阐述了给定背景下反复出现的问题。它以笼统的问题陈述开始,阐述了问题的本质:必须解决的具体设计问题是什么?例如,Model-View-Controller模式解决的是用户界面频繁变更的问题。模式表示解决问题时需要考虑的方方面面:

  • 解决方案必须满足的需求,如进程之间的对等通信必须高效;
  • 必须考虑的约束条件,如进程间通信必须遵守特定协议;
  • 解决方案必须具备的特征,如应该能够轻松地修改软件。

Model-View-Controller模式说明了两种作用力:修改用户界面应轻而易举,且这种修改不应影响软件的核心功能。

解决方案

模式的解决方案部分指出了如何解决反复出现的问题,更准确地说是如何平衡相关的作用
力。在软件架构中,这样的解决方案包括两个方面:

  • 每个模式都指定了特定的结构,即元素的空间配置。例如,Model-View-Controller模式的描述中有这样一句话:“将交互式应用程序分成三部分——处理、输出和输入。”
  • 每个模式都说明了运行阶段的行为。例如,在Model-View-Controller模式的“解决方案”部分有这样一句话:“控制器接受输入,这通常是表示鼠标移动、鼠标按钮激活或键盘输入的事件。事件被转换为服务请求,而服务请求要么被发送给模型,要么被发送给视图。”

模式的类型

模式一般分为三类:

  • 架构模式:具体软件架构的模板,描绘了应用程序的系统级结构特征,并将影响子系统的架构。例如Model-View-Controller模式
  • 设计模式:是一种中型模式,规模比架构模式小,但通常独立于编程语言和编程范式。应用设计模式不会影响软件系统的基本架构,但可能严重影响子系统的架构。例如:观察者模式。
  • 成例:如何解决特定的设计问题。针对于特定的语言的模式。例如C++语言的Counted Body模式。

总结

模式提供了一种前途无量的方法,可用于开发具有指定特征的软件。它们记录了既有的设计知识,有助于找到设计问题的妥善解决方案。模式的规模和抽象程度各异,涵盖了众多重要的软件开发领域。模式彼此交织在一起,我们可以使用一个模式来改善另一个更大的模式,还可结合使用多个模式来解决复杂的问题。模式论述了软件架构的一些重要方面,并给既有技术和方法提供了补充。模式可以和任何编程范式结合使用,且几乎可使用任何编程语言实现。