Skip to content

Latest commit

 

History

History
483 lines (381 loc) · 18.2 KB

04.编写你的第一个Node.js微服务.md

File metadata and controls

483 lines (381 loc) · 18.2 KB

04.编写你的第一个 Node.js 微服务

在本章中,我们将使用 Seneca 以及其他能让我们能得益于微服务特性的框架,来构建一个基于微服务的电子商务软件。

微电子商务概览

  • 编写微服务
  • 调整微服务规模
  • 创建 API
  • 整合 Seneca 与 Express
  • 通过 Seneca 持久化数据

我们要做的是使用流行的 JavaScript 框架创建一个聚合所有其他微服务的微服务,并为单页面应用 (SPA,Single-Page Application)提供 API。

在本章中,我们将深入讨论以下 4 个微服务。

  • 商品管理服务: 负责添加、编辑和删除数据库中的商品信息,以及给客户提供商品信息。本服务只会对部分用户开放具有添加和删除商品功能的管理页面。
  • 订单管理服务: 负责管理订单与账单。
  • 邮件服务: 负责向客户发送邮件。
  • UI: 负责给某个单页面应用提供其他微服务的特性,但是本章中我们只构建 JSON 接口。

微服务部署图如下:

从上图中可以看出,将部分微服务对外界隐藏,分别对不同的网络暴露了不同服务:

  • UI 将直接暴露在互联网上,所有人都可以访问它。
  • 商品管理服务 管理电子商务系统中的产品信息。它有以下两个对外接口:
    • 为 UI 提供数据的 Seneca 服务端。
    • 公司内部用于创建、更新和删除商品信息的 JSON API。
  • 邮件服务 是我们与客户的通信渠道。我们将通过该微服务阐述 Seneca 的优点,并会举例说明,当一个微服务宕机时,如何保证最终一致性和进行系统降级。
  • 订单管理服务 负责处理客户的订单。通过这个微服务,我们将讨论如何进行微服务数据的本地化,而不再是以系统全局共享的方式管理数据。你无法直接在本机数据库中获取商品名字或价格,而是需要通过其他微服务获取。

这个系统中并没有客户或员工信息管理功能,但是,基于上述的 4 个微服务,我们将能够深入微服务架构的核心理念。Seneca 拥有非常强大的数据存储与传输插件系统,使其能够很方便地与不同的数据存储系统、传输系统对接。

我们将使用 MongoDB 作为所有微服务的数据存储层。Seneca 提供了开箱即用的内存数据库插件,使用者可以直接开始编码。但是,内存数据库只能暂存数据,并不能持久化存储。

商品管理服务——双重核心

因为可以与 Express 直接整合,在 Seneca 上构建双重 API 是相当容易的。通过 Express,UI 可以向外界提供以下功能:编辑、添加、删除商品等,它是一个非常方便的框架。

同时,商品管理服务还可以通过Seneca TCP(Seneca 默认加载插件)让私有部分对外提供服务,因此内部网络中其他微服务(尤其是 UI)就能够访问到商品目录列表。商品管理服务将是小而具有内聚性的(它只管理商品),并且具备可伸缩性。虽然微小,但是该服务拥有处理电子商务系统中商品信息的所有能力。

我们需要做的第一件事情是定义商品管理服务的功能,如下所示:

  • 获取数据库中所有商品信息。这在实际生产环境中或许不是一个好方法(因为一般在生产系统中可能会需要分页),但是在我们的例子中是可行的。
  • 根据指定分类获取商品信息列表。与前一个功能类似,在生产系统中需要对结果进行分页。
  • 根据指定 ID 获取商品信息。
  • 将商品信息添加到数据库(本例中使用 MongoDB 作为数据库)。该功能会使用到 Seneca 的数据抽象能力,将微服务与实际存储系统解耦:我们能够(在理论上)轻易地将底层数据库从 Mongo 切换到其他存储系统。
  • 删除商品信息。同样使用到 Seneca 的数据抽象。
  • 编辑商品信息。

获取商品信息

为了获取商品信息,我们会直接从数据库中获取所有的商品信息返回给接口。在这种情况下,我们没有创建任何分页机制。但是,通常情况下,数据分页是避免数据库(也可能是应用,但主要是数据库)性能问题的好方法。

/**
 * 获取所有商品列表
 */
seneca.add({ area: 'product', action: 'fetch' }, function(args, done) {
  var products = this.make('products');
  products.list$({}, done);
});

这样,我们在 Seneca 中添加了一个可以返回数据库中所有商品信息的模式(pattern)。函数 products.list$() 有以下两个入参:

  • 查询条件。
  • 一个可以处理错误和结果对象的函数(记住错误优先回调法则)。

通过 seneca.add() 方法,我们将 done 函数传递给 list$ 方法。因为 Seneca 符合错误优先回调法则,所以这种方法有效。它相当于以下代码的简写:

seneca.add({ area: 'product', action: 'fetch' }, function(args, done) {
  var products = this.make('products');
  products.list$({}, function(err, result) {
    done(err, result);
  });
});

获取指定类别的商品

获取指定类别的商品与获取所有商品信息非常类似。唯一的区别是,在获取指定类别商品时需要传入类别参数,根据参数来筛选结果。

/**
 * 获取指定类别的商品列表
 */
seneca.add(
  { area: 'product', action: 'fetch', criteria: 'byCategory' },
  function(args, done) {
    var products = this.make('products');
    products.list$({ category: args.category }, done);
  }
);

这段代码唯一明显的区别是多了一个 category 入参,该参数将会被委派给 Seneca 的数据抽象层,并且根据底层存储系统生成相应的查询语句。

根据 ID 获取商品

根据 ID 获取商品信息是最必要的功能之一,同时它的实现也存在难点。所谓的难点,并不是在编码层面。

/**
 * 根据id获取商品
 */
seneca.add({ area: 'product', action: 'fetch', criteria: 'byId' }, function(
  args,
  done
) {
  var product = this.make('products');
  product.load$(args.id, done);
});

该功能实现的难点在于如何生成 id。id 的生成是应用与数据库的一个关联点。Mongo 通过哈希生成一个复合 ID,而 MySQL 通常采用一个自增的整型数字来唯一标识每一行记录。如果想要将应用的存储系统从 MongoDB 切换为 MySQL,首先需要解决的问题是如何将以下哈希值转换为一个整型数字:

e777d434a849760a1303b7f9f989e33a

每个微服务的数据都应该存放在本地,这意味着改变某个实体的 ID 数据类型需要同时改变其他数据库中相关联的 ID 类型。

添加商品

/**
 * 添加商品
 */
seneca.add({ area: 'product', action: 'add' }, function(args, done) {
  var products = this.make('products');
  products.category = args.category;
  products.name = args.name;
  products.description = args.description;
  products.category = args.category;
  products.price = args.price;
  products.save$(function(err, product) {
    done(err, products.data$(false));
  });
});

我们使用了 Seneca 的辅助方法 product.data$(false),通过它我们能够获取所需的实体数据,并且返回的数据中不会包含命名空间(域)、实体名、库名等我们并不关心的元数据信息。

删除商品

删除商品的操作通常根据 id 来完成:根据指定主键来定位和删除指定商品:

/**
 * 根据id删除指定商品
 */
seneca.add({ area: 'product', action: 'remove' }, function(args, done) {
  var product = this.make('products');
  product.remove$(args.id, function(err) {
    done(err, null);
  });
});

在这段代码中,除了错误发生时会返回错误信息之外,不会返回其他任何信息;因此,对于调用终端来说,只要没有错误信息返回就表明删除动作成功。

编辑商品

/**
 * 通过id获取商品信息并编辑
 */
seneca.edit({ area: 'product', action: 'edit' }, function(args, done) {
  seneca.act(
    { area: 'product', action: 'fetch', criteria: 'byId', id: args.id },
    function(err, result) {
      result.data$({
        name: args.name,
        category: args.category,
        description: args.description,
        price: args.price
      });
      result.save$(function(err, product) {
        done(product.data$(false));
      });
    }
  );
});

这是一个值得注意的场景:在编辑商品之前,需要先根据 id 取出商品信息,这个方法我们已经实现了。因此,需要做的是,取出商品信息后,将传入的数据放入相应字段并且保存。这是 Senec

整合各模块

正如我们之前提到的,商品管理服务具有双重性:一面是使用 Seneca 通过 TCP 传输数据给其他微服务,另一面是通过 Express(Node.js 的 Web 应用框架)以 REST 风格提供服务。我们将所有代码整合如下:

var plugin = function(options) {
  var seneca = this;

  /**
   * 获取所有商品列表
   */
  seneca.add({ area: 'product', action: 'fetch' }, function(args, done) {
    var products = this.make('products');
    products.list$({}, done);
  });

  /**
   * 根据分类获取商品列表
   */
  seneca.add(
    { area: 'product', action: 'fetch', criteria: 'byCategory' },
    function(args, done) {
      var products = this.make('products');
      products.list$({ category: args.category }, done);
    }
  );

  /**
   * 根据id获取商品
   */
  seneca.add({ area: 'product', action: 'fetch', criteria: 'byId' }, function(
    args,
    done
  ) {
    var product = this.make('products');
    product.load$(args.id, done);
  });

  /**
   * 添加商品
   */
  seneca.add({ area: 'product', action: 'add' }, function(args, done) {
    var products = this.make('products');
    products.category = args.category;
    products.name = args.name;
    products.description = args.description;
    products.category = args.category;
    products.price = args.price;
    products.save$(function(err, product) {
      done(err, products.data$(false));
    });
  });

  /**
   * 根据id删除商品
   */
  seneca.add({ area: 'product', action: 'remove' }, function(args, done) {
    var product = this.make('products');
    product.remove$(args.id, function(err) {
      done(err, null);
    });
  });

  /**
   * 根据id获取商品信息并编辑
   */
  seneca.add({ area: 'product', action: 'edit' }, function(args, done) {
    seneca.act(
      { area: 'product', action: 'fetch', criteria: 'byId', id: args.id },
      function(err, result) {
        result.data$({
          name: args.name,
          category: args.category,
          description: args.description,
          price: args.price
        });
        result.save$(function(err, product) {
          done(err, product.data$(false));
        });
      }
    );
  });
};

module.exports = plugin;

var seneca = require('seneca')();
seneca.use(plugin);
seneca.use('mongo-store', {
  name: 'seneca',
  host: '127.0.0.1',
  port: '27017'
});

seneca.ready(function(err) {
  seneca.act('role:web', {
    use: {
      prefix: '/products',
      pin: { area: 'product', action: '*' },
      map: {
        fetch: { GET: true },
        edit: { GET: false, POST: true },
        delete: { GET: false, DELETE: true }
      }
    }
  });

  var express = require('express');
  var app = express();
  app.use(require('body-parser').json());

  // Seneca与Express集成
  app.use(seneca.export('web'));

  app.listen(3000);
});

我们构建了一个 Seneca 插件,该插件能够被不同的微服务复用,并且包含了前文中提到的商品管理服务需要的所有方法定义。

以上代码可以分为下列两个部分:

  • 首先,和 Mongo 建立连接,指定 Mongo 为本地数据库。我们使用 mongo-store 插件实现以上功能(插件代码地址为https://github.com/senecajs/seneca-mongo-store,作者是 Richard Rodger,同时也是 Seneca 的作者)。
  • 第二部分对于我们来说是个新内容。如果你曾经使用过 jQuery,可能会产生熟悉感。回调函数 seneca.ready() 需要处理调用 API 前 Seneca 和 Mongo 可能没有建立连接的情况。同时,它也包含了集成 Express 和 Seneca 的代码。

以下是应用的 package.json 文件的配置内容:

{
  "name": "Product Manager",
  "version": "1.0.0",
  "description": "Product Management sub-system",
  "main": "index.js",
  "keywords": ["microservices", "products"],
  "author": "David Gonzalez",
  "license": "ISC",
  "dependencies": {
    "body-parser": "^1.14.1",
    "debug": "^2.2.0",
    "express": "^4.13.3",
    "seneca": "^0.8.0",
    "seneca-mongo-store": "^0.2.0",
    "type-is": "^1.6.10"
  }
}

集成 Express 与 Seneca——如何创建 REST API

seneca.act('role:web', {
  use: {
    prefix: '/products',
    pin: { area: 'product', action: '*' },
    map: {
      fetch: { GET: true },
      edit: { PUT: true },
      delete: { GET: false, DELETE: true }
    }
  }
});

var express = require('express');
var app = express();
app.use(require('body-parser').json());

// 这一步对Seneca与Express进行了集成
app.use(seneca.export('web'));

app.listen(3000);

提供了下列三个 REST 服务端点:

  • /products/fetch
  • /products/edit
  • /products/delete

首先,我们指定 Seneca 根据配置执行 role:web action。配置信息中使用了 /product 作为所有 URL 的前缀,同时将相关 action 与匹配模式 {area: "product", action: "*"} 绑定。虽然我们在本书中第一次接触这种方式,但是这确实是 Seneca 中将 action 与 URL 绑定的一种可行方式,指明 product 这个 area 的处理程序。也就是说,/products/fetch 将与 {area: 'products',action: 'fetch'} 匹配。这或许有点难理解,但是一旦你习惯了这种用法,会发现它相当强大。这可以使我们通过约定的方式将 action 与 URL 松耦合。

在以上配置中,map 属性里指定了服务端点对于不同操作允许的 HTTP 请求类型:fetch 操作对应 GET 方法、edit 操作允许 PUT 方法,而 delete 操作只允许 DELETE 方法。通过这种方式,我们可以合理地控制应用的语义。你可能对余下的代码比较熟悉,即创建一个 Express 应用,并使用到下列两个插件:

  • JSON 解析器
  • Seneca Web 插件

到这里,我们已经完成了 Seneca 与 Express 的集成。如果想为 Seneca 添加新的可通过 API 提供服务的 action,唯一需要做的就是修改 map 属性,将 action 与相应的 HTTP 请求绑定。

邮件服务:一个常见的问题

邮件服务是我们编写一个微服务时的首选,考虑到它有以下特点:

  • 只处理一个功能点。
  • 处理得很好。
  • 持有自己的专有数据。

如何发送邮件

  • 标题
  • 内容
  • 目标地址

接口定义

虽然邮件服务的实现看起来容易,但是企业邮件功能最后可能变得一团糟。因此,我们首先要明确最小需求:

  • 如何渲染邮件?
    • 邮件渲染是否属于邮件操作的限界上下文?
    • 是否另起一个微服务负责邮件渲染?
    • 是否使用第三方工具管理邮件?
  • 是否保留已发送邮件数据以备审计需要?

在这个微服务中,我们使用了 Mandrill,它支持企业级邮件发送、追溯已发送邮件和创建可在线编辑的邮件模板。

var plugin = function(options) {
  var seneca = this;
  /**
   * 使用模板发送邮件
   */
  seneca.add({ area: 'email', action: 'send', template: '*' }, function(args, done) {
    // TODO: More code to come.
  });

  /**
   * 发送包含内容的邮件
   */
  seneca.add({ area: 'email', action: 'send' }, function(args, done) {
    // TODO: More code to come.
  });
};

我们有两种邮件发送模式:一种是使用模板,另一种是在发送请求中自带内容。

设置Mandrill

首先,需要创建一个Mandrill账户。访问网址https://mandrillapp.com并用邮箱进行注册后,即可以进行访问,如下图所示:

现在已经创建了一个账户,可用该账户进入测试模式。单击右上角邮箱地址所在位置,开启菜单栏中的测试模式。Mandrill 左边栏会变为橘黄色。

接下来,我们将创建一个API key,它是Mandrill API需要使用的登录信息。单击 Settings -> SMTP & API Info,添加一个新的key(别忘了勾选标记key为测试使用的复选框)。如下图所示:

你现在唯一需要的信息就是生成的key。让我们对API进行如下测试:

var mandrill = require("mandrill-api/mandrill");
var mandrillClient = new mandrill.Mandrill("<YOUR-KEY-HERE>");

mandrillClient.users.info({}, function(result){
  console.log(result);
}, function(e){
  console.log(e);
});

只需要以上几行代码,就可以测试Mandrill是否已经启动并运行,以及key是否正确。

亲自动手在微服务中集成Mandrill

我们将会用到Mandrill的一小部分API,如果你想使用其他功能,可以从这个网址中获取到相关信息:https://mandrillapp.com/api/docs/

 /**
 * 发送包含内容的邮件
 */
seneca.add({area: "email", action: "send"}, function(args, done) {
  console.log(args);
  var message = {
    "html": args.content,
    "subject": args.subject,
    "to": [{
      "email": args.to,
      "name": args.toName,
      "type": "to"
    }],
    "from_email": "info@micromerce.com",
    "from_name": "Micromerce"
  }
  mandrillClient.messages.send({"message": message}, function(result) {
    done(null, {status: result.status});
  }, function(e) {
    done({code: e.name}, null);
  });
});

第一个发送信息的方法并没有使用到模板。我们只是从应用中获取了一段HTML形式的内容(以及一些其他参数)并通过Mandrill完成发送。

如果发生异常,以上代码会返回 e.name,我们可以将其作为错误码。此时,分支流将因为错误码 信息被终止,我们将这称为数据耦合。我们的软件组件并没有依赖于之前的约定,而依赖了传入的内容。