在本章中,我们将使用 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获取商品
*/
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"
}
}
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账户。访问网址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的一小部分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": "[email protected]",
"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
,我们可以将其作为错误码。此时,分支流将因为错误码 信息被终止,我们将这称为数据耦合。我们的软件组件并没有依赖于之前的约定,而依赖了传入的内容。