Mongodb 按需式物化视图使用指南

介绍

$merge,又称按需式物化视图,是MongoDB4.2最强大的新增功能之一。

每次运行该命令,会允许你按照增量的方式更新结果集。相对于 out 每次都要重新生成结果集,merge 有着更好的性能。

准备数据

在开始介绍 $merge 用法之前,我们先准备测试数据,请在本地使用 docker run mongodb 4.2。

假设我们需要使用 lookup 关联查询两个表。

新建一个 note 表并写入 1000 条数据, 在 mongo shell 中执行

db.createCollection('note')
var notes = new Array(1000).fill(0).map((it,index) => ({name : 'note'+index}))
db.note.save(notes)

新建一个 portfolio 表并写入 10000 条数据,并且和 note 表进行关联

db.createCollection('portfolio')

var notes = db.note.find().toArray()
for(var i=0; i<10e3; i++){
    var index = i%1000
    var noteId = notes[index]._id.str
    db.portfolio.save({
        noteId : noteId,
        amount : index
    })
}

连表查询, 把 note 表中的 name join 到 portfolio 中

db.portfolio.aggregate([
// 把 noteId 转换成 ObjectId
{$addFields : {noteObjId : {$toObjectId : "$noteId"}}},
{$lookup: { 
         from: "note", 
         localField: "noteObjId",
         foreignField: "_id",
         as: "note_docs" } 
},
// 合并两个表
{
   $replaceRoot: { newRoot: { $mergeObjects: [ { $arrayElemAt: [ "$note_docs", 0 ] }, "$$ROOT" ] } }
},
{$project : {noteObjId:0, note_docs : 0}}
])

查询结果

/* 1 */
{
    "_id" : ObjectId("5eb25e2d8be714faeead14ae"),
    "name" : "note0",
    "noteId" : "5eb25d788be714faeead10c6",
    "amount" : 0.0
}

/* 2 */
{
    "_id" : ObjectId("5eb25e2d8be714faeead14af"),
    "name" : "note1",
    "noteId" : "5eb25d788be714faeead10c7",
    "amount" : 1.0
}
....more data...

使用

使用 $merge 我们就可以把上述查询结果存入另一个表中

db.portfolio.aggregate([
// 把 noteId 转换成 ObjectId
{$addFields : {noteObjId : {$toObjectId : "$noteId"}}},
{$lookup: { 
         from: "note", 
         localField: "noteObjId",
         foreignField: "_id",
         as: "note_docs" } 
},
// 合并两个表
{
   $replaceRoot: { newRoot: { $mergeObjects: [ { $arrayElemAt: [ "$note_docs", 0 ] }, "$$ROOT" ] } }
},
{$project : {noteObjId:0, note_docs : 0}},
{$merge: 'full'}
])

这样查询结果就会存入 full 表中,full 是一个 collection,我们可以对它进行增查删改和新建索引等操作,所以可以无缝支持 mongoose。

这里我们使用了 merge 的默认选项,merge 的具体选项有

{ $merge: {
     into: <collection> -or- { db: <db>, coll: <collection> },
     on: <identifier field> -or- [ <identifier field1>, ...],  // Optional
     let: <variables>,                                         // Optional
     whenMatched: <replace|keepExisting|merge|fail|pipeline>,  // Optional
     whenNotMatched: <insert|discard|fail>                     // Optional
} }

{$merge: ‘full’} 等价于

{ 
$merge: {
     into: 'full',
     on: '_id',  // Optional
     let: { new: "$$ROOT" },                                         // Optional
     whenMatched: 'merge',  // Optional
     whenNotMatched: 'insert'                     // Optional
} 
}

即以 portfolio 的 _id 为唯一key,当本次 aggregate 查询结果和 full 表已有的记录 matched 时,合并两个对象,不 matched 时,新增一条记录.

如果 portfolio 和 note 数据有更新,full 表数据不会自动更新,需要重新执行上述 aggregate。

删除过期数据

那么问题来了,如果删除了 portfolio 表中的某条数据,full 表数据是不会自动删除的,我们需要怎么做呢?

我们可以给数据打算时间戳

var time = Date.now()
db.portfolio.aggregate([
// 把 noteId 转换成 ObjectId
{$addFields : {noteObjId : {$toObjectId : "$noteId"}, mergedAt: time }},
{$lookup: { 
         from: "note", 
         localField: "noteObjId",
         foreignField: "_id",
         as: "note_docs" } 
},
// 合并两个表
{
   $replaceRoot: { newRoot: { $mergeObjects: [ { $arrayElemAt: [ "$note_docs", 0 ] }, "$$ROOT" ] } }
},
{$project : {noteObjId:0, note_docs : 0}},
{$merge: 'full'}
])

// 删除非本次更新的数据
db.full.remove({"mergedAt" : {$ne : time}})

再更新完数据后,删除非本次更新的数据即可

普通视图

如果用普通视图来查询上述内容的话,可以这样做

db.createView('p_view', 'portfolio',
[ 
{$addFields : {noteObjId : {$toObjectId : "$noteId"}}},
{$lookup: { 
         from: "note", 
         localField: "noteObjId",
         foreignField: "_id",
         as: "note_docs" } 
},
{
   $replaceRoot: { newRoot: { $mergeObjects: [ { $arrayElemAt: [ "$note_docs", 0 ] }, "$$ROOT" ] } }
},
{$project : {noteObjId:0, note_docs : 0}}
])

这样我们就建好了一个叫 p_view 的视图,假设我们修改了 note 的 name, p_view 会同步更新数据,因为实际上 p_view 每次都会执行连表查询。

虽然 p_view 不是一个 collection,但是我们在 mongoose 中也是可以照常使用 Schema 的

const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:57017/test', {
  useNewUrlParser: true,
  useUnifiedTopology: true
});

let schema = new mongoose.Schema({
    amount: Number,
    name: {type: String, get: str => str.toUpperCase()}
  },
  {toObject: {getters: true}}
)

const View = mongoose.model('View', schema, 'p_view');

View.countDocuments().then(total => console.log('total : ', total))

View.find().limit(2).then(its => {
  console.log('find items length ', its.length)
  console.log('find items ', its)
})

输出

find items length  2
find items  [
  {
    _id: 5eb25e2d8be714faeead14ae,
    name: 'NOTE000',
    noteId: '5eb25d788be714faeead10c6',
    amount: 0,
    id: '5eb25e2d8be714faeead14ae'
  },
  {
    _id: 5eb25e2d8be714faeead14af,
    name: 'NOTE1',
    noteId: '5eb25d788be714faeead10c7',
    amount: 1,
    id: '5eb25e2d8be714faeead14af'
  }
]
total :  10000

总结

  • 物化视图并非像普通视图那样会自动更新数据,需要手动触发更新
  • 删除过期数据比较麻烦
  • 适合的使用场景,定期汇总的数据表,例如每日数据报表
  • 如果查询性能不是问题,请使用普通视图,不用维护数据更新

typescript 的严格模式实践

开启了严格模式后,可以帮助我们提前发现很多 BUG,关于严格模式的详细介绍,可以看看这篇文章

接下来,我将在一个实际项目上打开严格模式,看看要如何落地

Property has no initializer

变量声明后没有初始化

这种问题要根据实际情况来解决,需要初始化的补上即可。
在这个例子中,这是一个 model 对象,我们明确知道 mongoose 会帮我们做好初始化的,我们可以这样声明字段:

name!: string

感叹号的意思是,告诉 type checker:“这个字段你就不用担心初始化问题了,我们开发会搞定的”。

除了这个方式,我们还有其他的解决方案,具体可以看看这篇文章

Parameter is any type

这是一个很常见的场景,用 query 表示 object 参数,至于 query 里面有什么东西,请允许我引用一个经典笑话:

当我写下这一行代码时,只有我和上帝知道是什么意思。一个月后,只有上帝才知道是什么意思了…

我们可以在开发规范里要求大家写文档,但这种约束力明细不如
“不写清楚类型就不让你执行”
来得稳妥。

所以,这种情况下,严格模式的 typescript 会报错:

Error:(18, 18) TS7006: Parameter 'query' implicitly has an 'any' type.

不允许 any 类型的参数,所以我们必须为参数声明类型

export class UsersQueryDto {
  @IsNotEmpty()
  readonly userId!: string
}

然后标记类型:

@Get()
async findAll (query : UsersQueryDto): Promise<IUserModel[]> {
    return this.usersService.findAll(query)
}

当然了,不像 Java 那么死板,也不是所有参数都要搞一个类来声明类型的,声明类型也可以更简单些:

@Get()
async findAll (query : {userId : string}): Promise<IUserModel[]> {
    return this.usersService.findAll(query)
}

是否要声明一个类,取决于是否要将这个参数类型在其他地方共享

Module need types

我们直接引入一个包,以 mongoose 为例

import * as mongoose from 'mongoose'

会报错:

Error:(3, 27) TS7016: Could not find a declaration file for module 'mongoose'. 'klg-nest-starter/node_modules/mongoose/index.js' implicitly has an 'any' type.
  Try `npm install @types/mongoose` if it exists or add a new declaration (.d.ts) file containing `declare module 'mongoose';`

安装提示把对应的 types 安装以下即可,执行:

npm install @types/mongoose

如果你使用的包没有提供 types,那就需要你自己编写 types,具体可以参考其他包的写法。
不过 typescript 目前的火热程度,稍微有人气的包都会提供 types,或者很多包干脆就是直接用 typescript 编写的,不用太担心这个问题。

Nullable

在有了 @types/mongoose 的基础上,我们可以提前发现更多问题了

mongoose 对 findById 的返回值定义是 T | null

findById(id: any | string | number,
      callback?: (err: any, res: T | null) => void): DocumentQuery<T | null, T, QueryHelpers> & QueryHelpers;

所以这里 user 的查询结果可能会 null,直接是用 user.id 可能会出现错误。
修复方案:

  async getAccountAndUser (userId: string): Promise<IAccountModel> {
    let user = await this.userModel.findById(userId)
    if (!user) throw new Error('User not found ' + userId)
    let account = await this.accountModel.findOne({userId: user.id})
    if (!account) throw new Error('Account not found ' + user.id)
    return account
  }

Type Match

export async function genFixturesTemp (template: object, nums: number, modelName: string, fixData?: Function) {
  if (!fixData) fixData = (index: number, it: object) => it
  let items = Array(10).fill(0).map(fixData)
  return items
}

这段代码里有个 bug,能一眼看出来吗?

typescript 给了我们一个提示

原来 map 函数对参数的定义是

map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];

第一个参数 value 第二个参数才是 index:number
修复方案:

export async function genFixturesTemp (template: object, nums: number, modelName: string, fixData?: (it: object, i: number) => any) {
  if (!fixData) fixData = (it: object, index: number) => it
  let items = Array(10).fill(0).map(fixData)
  return items
}

调整函数,并准确声明 Function 的类型

如何保持git commit 记录简洁?

问题

在缺少强制的 git commit 规范的时候,开发有时候偷懒会随意填写 commit 信息,于是我们会看到比较随意的 commit 信息。

使用脚本查看下最常见的 commit 说明是啥,在某个项目目录下执行:

git log --pretty="%s"

然后把输出内容使用 js 做个分组统计,把重复次数超过5次的内容打印出来

times 7  修复单元测试
times 30 Merge branch 'master' 
times 62 Merge branch 'master' 
times 6 Merge remote-tracking branch 'origin/master'
times 145 fix
times 13 fix: conflict
times 6 fix: test
times 7 fix: unit test
list { '[object Object]': 1 }

可以看出,很多 comit 是只有一个 fix 的,出现了145次。

那要如何让 commit 变得更清爽和有意义呢?下面介绍几个方案

rebase

Git有一种称为rebase的操作,有人把它翻译成“变基”。

利用这个特性可以实现把多个 commit 合并成一个。

我们在开发某个功能的时候,不可避免的要多次 commit 代码,这个时候 git log 长这样:

* b1b8189 - (HEAD -> master) Commit-3
* 5756e15 - Commit-2
* e7ba81d - Commit-1
* 5d39ff2 - Commit-0

使用 rebase 我们可以把 git log 合并成这样:

* 5d39ff2 - Commit-merge

具体操作可以这篇教程,实际上就是把每个 commit 列出来,让你决定每个 commit 是否要保留。摘抄一段说明:

  1. squash:使用该 Commit,但会被合并到前一个 Commit 当中
  2. fixup:就像 squash 那样,但会抛弃这个 Commit 的 Commit message

但是上面这篇教程是纯命令行的,而且还需要在 vim 下面做编辑操作,还是略显麻烦。

Webstorm 作为一个称职的 IDE,果断提供了 rebase 的 GUI 操作。
在左下角的 Version Controller 面板,打开 log 视图,可以看到最近提交 commit

右键点击最早的 commit 记录,选择 Interactively Rebase from Here, 从这里开始交互式的 rebase。

在弹出的交互视图中,我们选择保留 commit1, 其他的都 fixup, 使用该 commit,但是抛弃这个 Commit 的 Commit message。

rebase 完成后,commit 就只剩下一个了。

扁平化合并

虽然有 IDE 帮我们做 rebase,但是 rebase 还是比较危险的,容易造成冲突,所幸我们还有更简单的解决方案,gitee 提供了一个扁平化合并的选项

从描述中就可以知道,就是我们要的合并 commit 的功能,不过是这分支合并的时候顺带做的。

建议做分支合并的时候都使用该选项。

不过这个可不是 gitee 的独家功能哦,实际上就是 git merge 加了 –squash 选项。

具体可以看这篇文章

校验 commit 格式

上面的方案都是基于事后的修补,其实我们也可以限制开发在提交 commit 的时候,就对格式进行校验,我们可以直接使用现成的工具,它们就是:

  • Husky : 通过 git hook 触发,执行某些指令
  • commitlint : 顾名思义,对 commit 进行 lint 检查,已经有通用的模板。

具体配置方式可以看这篇文章

使用注解简化 Mongoose 事务的使用

mongoose 提供的事务

MongoDB 4.0 开始提供了事务支持,mongoose 也提供了相应的实现,不过目前的写法还是比较繁琐。
我们看一下 mongoose 给出的 demo

const Customer = db.model('Customer', new Schema({ name: String }));

let session = null;
return Customer.createCollection().
  then(() => db.startSession()).
  then(_session => {
    session = _session;
    // Start a transaction
    session.startTransaction();
    // This `create()` is part of the transaction because of the `session`
    // option.
    return Customer.create([{ name: 'Test' }], { session: session });
  }).
  // Transactions execute in isolation, so unless you pass a `session`
  // to `findOne()` you won't see the document until the transaction
  // is committed.
  then(() => Customer.findOne({ name: 'Test' })).
  then(doc => assert.ok(!doc)).
  // This `findOne()` will return the doc, because passing the `session`
  // means this `findOne()` will run as part of the transaction.
  then(() => Customer.findOne({ name: 'Test' }).session(session)).
  then(doc => assert.ok(doc)).
  // Once the transaction is committed, the write operation becomes
  // visible outside of the transaction.
  then(() => session.commitTransaction()).
  then(() => Customer.findOne({ name: 'Test' })).
  then(doc => assert.ok(doc));

这个 demo 暴露了两个问题:

  1. 需要为每一个事务里做提交和回滚的处理
  2. 事务是用 session 来区分的,你需要一直传递 session

使用注解

所以 akajs 提供了一个事务的注解来简化这个处理流程。

import * as mongoose from 'mongoose'
import {Schema} from 'mongoose'
import * as assert from 'assert'
import {Transactional, getSession} from './decorators/Transactional'

mongoose.connect('mongodb://localhost:27017,localhost:27018,localhost:27019/test?replicaSet=rs', {useNewUrlParser: true})
mongoose.set('debug', true)
let db = mongoose.connection
const Customer = db.model('Customer', new Schema({name: String}))

class ClassA {
  @Transactional()
  async main (key) {
    await new Customer({name: 'ClassA'}).save({session: getSession()})
    const doc1 = await Customer.findOne({name: 'ClassA'})
    assert.ok(!doc1)
    await new ClassB().step2()
    return key
  }
}

class ClassB {
  async step2 () {
    const doc2 = await Customer.findOne({name: 'ClassA'}).session(getSession())
    assert.ok(doc2)
    await Customer.remove({}).session(getSession())
  }
}

new ClassA().main('aaa').then((res) => {
  console.log('res', res)
  mongoose.disconnect(console.log)
}).catch(console.error)

解析:

  • @Transactional() 注解会自动提交或回滚事务(发生异常时)。具体实现见Transactional.ts ,核心实现部分,使用 try catch 捕获异常,实现自动回滚。
try {
  const value = await originalMethod.apply(this, [...args])
  // Since the mutations ran without an error, commit the transaction.
  await session.commitTransaction()
  // Return any value returned by `mutations` to make this function as transparent as possible.
  return value
} catch (error) {
  // Abort the transaction as an error has occurred in the mutations above.
  await session.abortTransaction()
  // Rethrow the error to be caught by the caller.
  throw error
} finally {
  // End the previous session.
  session.endSession()
}
  • 为了避免嵌套调用时,你需要一直传递 session 的尴尬~,akajs 提供全局的 getSession() 方法,其实现原理是依赖 Async Hooks ,是 Node 的实验性特性,
    你对此介意的话,请不要在生产环境使用。

注意 mongodb 的事务必须在复制集上使用,在开发环境启动 mongodb 复制集,推荐使用 run-rs

更进一步

当然,在每一个需要 Session 的地方调用 getSession() 方法还是稍显累赘,我们可以通过 wrap mongoose 的各个方法,来实现自动注入 session。

例如把 mongoose 的 findOne 方法替换为

let originFindOne = mongoose.findOne
mongoose.findOne = () => {
originFindOne().session(getSession())
}

但是工作量有些多,暂时没时间做。

基于 async-hook 实现的 koa 国际化解决方案

前言

在 koa 上实现国际化,有个现成的工具包 koa-locales
简单配置一下就可以使用了

async function home(ctx) {
  ctx.body = {
    message: ctx.__('Hello, %s', 'fengmk2'),
  };
}

但是这里有个问题,想要获得国际化的内容,就必须访问 ctx 对象(request 里附带了用户所使用的的语言信息),这里的访问就成了问题。

koa 的 mvc 结构

一般来说,基于 koa 的应用会采用经典的 mvc 结构, 一个用户请求进来,其调用链是这样的。

router
–>controller
—->service
——>model

如果我们要在 service 层或者 model 层访问 koa 的 ctx 对象,就需要在调用过程把参数一层一层传递下去:

async function home(ctx) {
  ctx.body = service.hello(ctx)
}
function hello(ctx) {
  ctx.__(xxx)
}

这是一件非常恶心的事情。
那要怎么解决这个问题呢?

eggjs 的结构反转方案

eggjs 采用了结构翻转的设计,当用户请求进来时,初始化 controller 和 service 等对象,挂载在 ctx 上。

ctx 对象几乎可以在编写应用时的任何一个地方获取到.
在 Controller、Service 等可以通过 this.app,或者所有 Context 对象上的 ctx.app:

// app/controller/home.js
class HomeController extends Controller {
  async index() {
    // 从 `Controller/Service` 基类继承的属性: `this.app`
    console.log(this.app.config.name);
    // 从 ctx 对象上获取
    console.log(this.ctx.app.config.name);
  }
}

eggjs 根本没有传递 ctx,而是所有东西都挂载在 ctx 上,所以 eggjs 这里的国际化就很好做了。

其实这个也是我不考虑使用 eggjs 的一个重要原因,这种设计模式打破的 function 简单的特性。普通的 function 参数即是输入,返回值即是输出,这种特性是非常好写 Unit Test 的。

而在 function 里访问 this.xxx 这个方式,就意味着能访问的对象取决于上下文而非函数的参数,这会给测试带来灾难,你需要联系上下文才能知道 this 里面究竟有什么。

当然 eggjs 里面只是往 this.ctx 挂载类似静态类的实现,并没有保存变量,一定程度上避免了混乱的问题,不过开了这个头,就容易走歪了。

获取调用链

其他语言是如何解决这个问题的呢?

Java 的 Threadlocal

Java 的解决方案是 Threadlocal, 在 J2EE 中,用户的每个请求都会非配给一个线程,Java 提供了 Threadlocal 这样一个关于创建线程局部变量的类。通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。而使用ThreadLocal创建的变量只能被当前线程访问,其他线程则无法访问和修改。

一个请求绑定一个线程,一个线程绑定一个变量。

有了这个特性,Java 的 Web 框架一般会在线程初始化之后,把 Session 写入 Threadlocal,然后在任意一处代码中都能访问到同一个 Session。

Node.js 的 async-hooks

好消息是 Node 世界里也有类似 Java 的 Threadlocal 实现,就是 Async Hooks

注意 async_hooks 目前还是实验性特性!而且已经实验了 2 年多还没转正!

Async hook 对每一个函数(不论异步还是同步)提供了一个 Async scope,你可以通过 async_hooks.currentId() 获取当前函数的 Async ID。

const async_hooks = require('async_hooks');

console.log('default Async Id', async_hooks.currentId()); // 1

process.nextTick(() => {
  console.log('nextTick Async Id', async_hooks.currentId()); // 5
  test();
});

function test () {
  console.log('nextTick Async Id', async_hooks.currentId()); // 5
}

在同一个 Async scope 中,你会拿到相同的 Async ID。
基于这个特性,我们就能追踪一个用户请求触发的所有异步操作了。

国际化的最终实现方案

OK,既然技术上可行,那我们就可以给出具体实现了。

  1. 使用 koa-locales 实现多语言配置文件的解析,和用户语言的识别。
  2. 把 koa-locales 里进行文本翻译的方法抽成工具类
  3. 编写一个过滤器,把用户语言与 async_hooks 的 Async ID 绑定
  4. 在 service 层代码中调用工具类,工具类通过 Async ID 获取当前用户的语言,进行文本翻译。

app.ts 注册

const koa = new Koa()
// 国际化
initI18n(koa, {
  // dirs: ['$PWD/locales'],
  functionName: 'i18n',
  defaultLocale: 'zh-CN'
})

I18nUtil.ts 工具类

import * as locales from 'koa-locales'
import {logger} from '@akajs/utils'
import {createNamespace} from 'cls-hooked'

const session = createNamespace('i18n locale')

let defaultLocale = 'zh-CN'

// 把 koa-locales 的文本翻译方法抽出来
let gettext = function (locale: string, key: string, ...values) {
  // 等待被覆盖
  return key
}

// 新的文本翻译方法,给 Service 层调用
export function i18n (key?: string, ...values: Array<any>) {
  // get locale
  logger.debug('locale form namespace ', session.get('locale'))
  let locale = session.get('locale') || defaultLocale
  return gettext(locale, key, ...values)
}

export function initI18n (app, options) {
  locales(app, options)
  app.use(async (ctx, next) => new Promise(
    session.bind(async (resolve, reject) => {
      try {
        let locale = ctx.__getLocale()
        logger.debug('locale ', locale)
        session.set('locale', locale)
        await next()
        resolve()
      } catch (err) {
        reject(err)
      }
    })
  ))
  gettext = app[options.functionName || '__']
  defaultLocale = options.defaultLocale
}

UserService.ts 在 Service 中使用 i18n

async register (value: RegisterDto) {
    throw new BizError(i18n('Phone %s Used', phone))
}

最终,我们可以在任意代码中引入 I18nUtil.ts 工具类,就可以准确获取用户当前语言了, 实际上是为每个用户请求建立一个 Session:

用户发起请求 –> 中间件保存用户语言到 session –> Service –> I18nUtil get 用户语言 from session。

解析:最终的实现方案中,我们并没有直接使用 async_hooks 特性,而是用了 cls-hooked 这个包提升了易用性和兼容性,Pandora.js 还使用这个包来做 Node 应用的调用链记录,有兴趣的可以了解下。

Spring Cloud 与 Service Mesh,如何选择?

导读

Spring Cloud 基于Spring Boot开发,提供一套完整的微服务解决方案,具体包括服务注册与发现,配置中心,全链路监控,API网关,熔断器,远程调用框架,工具客户端等选项中立的开源组件,并且可以根据需求对部分组件进行扩展和替换。

Service Mesh,这里以Istio(目前Service Mesh具体落地实现的一种,且呼声最高)为例简要说明其功能。 Istio 有助于降低这些部署的复杂性,并减轻开发团队的压力。它是一个完全开源的服务网格,可以透明地分层到现有的分布式应用程序上。它也是一个平台,包括允许它集成到任何日志记录平台、遥测或策略系统的 API。Istio的多样化功能集使你能够成功高效地运行分布式微服务架构,并提供保护、连接和监控微服务的统一方法。

从上面的简单介绍中,Service Mesh 和 Spring Cloud 实现的功能差不多,那这两种架构应该如何选择呢?

Service Mesh 的优势

2019 年,经过前几年的发展,Service Mesh 在国内开始大面积开花结果,互联网大厂纷纷开始走上实践道路(例如蚂蚁金服的SOFASTACK),也有大部分企业已经开始接触Service Mesh。

可以认为 Service Mesh 将是微服务的未来,是可以替换目前的事实标准 Spring Cloud 的存在,其原因,总结下来,有四个方面:

与 Spring Cloud 功能重叠

来简单看一下他们的功能对比:

功能列表 Spring Cloud Isito
服务注册与发现 支持,基于Eureka,consul等组件,提供server,和Client管理 支持,基于XDS接口获取服务信息,并依赖“虚拟服务路由表”实现服务发现
链路监控 支持,基于Zikpin或者Pinpoint或者Skywalking实现 支持,基于sideCar代理模型,记录网络请求信息实现
API网关 支持,基于zuul或者spring-cloud-gateway实现 支持,基于Ingress gateway以及egress实现
熔断器 支持,基于Hystrix实现 支持,基于声明配置文件,最终转化成路由规则实现
服务路由 支持,基于网关层实现路由转发 支持,基于iptables规则实现
安全策略 支持,基于spring-security组件实现,包括认证,鉴权等,支持通信加密 支持,基于RBAC的权限模型,依赖Kubernetes实现,同时支持通信加密
配置中心 支持,springcloud-config组件实现 不支持
性能监控 支持,基于Spring cloud提供的监控组件收集数据,对接第三方的监控数据存储 支持,基于SideCar代理,记录服务调用性能数据,并通过metrics adapter,导入第三方数据监控工具
日志收集 支持,提供client,对接第三方日志系统,例如ELK 支持,基于SideCar代理,记录日志信息,并通过log adapter,导入第三方日志系统
工具客户端集成 支持,提供消息,总线,部署管道,数据处理等多种工具客户端SDK 不支持
分布式事务 支持,支持不同的分布式事务模式:JTA,TCC,SAGA等,并且提供实现的SDK框架 不支持
其他 …… ……

从上面表格中可以看到,如果从功能层面考虑,Spring Cloud与Service Mesh在服务治理场景下,有相当大量的重叠功能,从这个层面而言,为Spring Cloud向Service Mesh迁移提供了一种潜在的可能性。

服务容器化

在行业当前环境下,还有一个趋势,或者说是现状。越来越多的应用走在了通往应用容器化的道路上,或者在未来,容器化会成为应用部署的标准形态。而且无论哪种容器化运行环境,都天然支撑服务注册发现这一基本要求,这就导致Spring Cloud体系应用上容器的过程中,存在一定的功能重叠,有可能为后期的应用运维带来一定的影响,而Service Mesh恰恰需要依赖容器运行环境,同时弥补了容器环境所欠缺的内容(后续会具体分析)。

术业有专攻

从软件设计角度出发,我们一直在追求松耦合的架构,也希望做到领域专攻。例如业务开发人员希望我只要关心业务逻辑即可,不需要关心链路跟踪,熔断,服务注册发现等支撑工具的服务;而平台支撑开发人员,则希望我的代码中不要包含任何业务相关的内容。而Service Mesh的出现,让这种情况成为可能。

语言壁垒

目前而言Spring Cloud虽然提供了对众多协议的支持,但是受限于Java技术体系。这就要求应用需要在同一种语言下进行开发(这不一定是坏事儿),在某种情况下,不一定适用于一些工作场景。而从微服务设计考虑,不应该受限于某种语言,各个服务应该能够相互独立,大家需要的是遵循通信规范即可。而Service Mesh恰好可以消除服务间的语言壁垒,同时实现服务治理的能力。

选择和迁移

从上文中我们得知 Service Mesh 的各种优势,那么,如何让 Service Mesh 在我们的项目中落地呢?接下来我们分几种情况讨论下

全新的项目

如果是小型项目,轻业务、重流量、需求快速变化,可以参考我之前写的文章轻量型互联网应用架构方式,语言层面选择 Node.js + Mongodb/Mysql.

如果项目属于业务复杂类型,语言层面可以选择 Java with Kotlin。

当然,全新项目,容器化是必须的。

而且如果你的工期紧张,可以考虑渐进式地推进, 先 k8s 后 Service Mesh。

先把项目服务容器话,在 k8s 上部署起来,此时已经能够享受 k8s 带来的一部分服务治理能力了。例如:

  • 服务发现
  • 负载均衡
  • API 网关
  • 服务路由

Istio 基于 k8s,项目有了 k8s 的基础,落地 Istio 也可以顺水渠成。

Spring Cloud 项目的迁移

现存的 Spring Cloud 项目的迁移方案,可以参考这篇文章 服务迁移之路 | Spring Cloud向Service Mesh转变

简单来说就是先把服务容器化,然后逐步用 Service Mesh 替换 Spring Cloud 的功能。

其他语言项目的迁移

和上面的方案大同小异,前提条件都是先把服务容器话。

参考文章

服务迁移之路 | Spring Cloud向Service Mesh转变

轻量型互联网应用架构方式

使用 Helm + Kubernetes 部署微服务

前言

在之前的文章中,我提出了轻量型互联网应用架构方式,其中核心思想就是依赖 Kubernetes 实现服务治理。

之后,我尝试在团队中进行落地改造,第一步,就是把目前的服务搬上 Kubernetes。

在这个实践过程中,有些经验可以分享一下。

落地经验

在 Kalengo ,开发隐约(并没有 title 上的区别,但实际工作内容有区分)可以分为两类,一类主要处理业务需求,另一类做基础架构。

考虑到 Kubernetes 的复杂性,如果要做到开发不清楚 Kubernetes 原理和部署流程的情况下也能使用这一套工具,要做到以下这些点:

  1. 测试环境使用 Rancher管理 k8s 集群。
    Rancher 提供了 web 页面,方便开发直接做环境变量修改,镜像变更和部署等操作。

  2. 在 Rancher 中启用 Helm 模板
    把做好的目标发布到内部的 Helm 仓库,然后在配置到 Rancher 商店中,开发如果要新开一套测试环境,直接在 Rancher 商店里点击操作即可。只有模板制作者或运维才需要在本地安装 kubectl 和 helm 等工具

  3. 测试环境使用命名空间实现多租户
    不同开发的测试环境使用命名空间隔离,避免冲突

  4. 内部服务的 service name 要固定
    服务 A 访问服务 B,在没有 k8s 之前,我们是这样做的:
    给服务 B 挂在到负载均衡上:http://b.env.kalengo.cn, 然后在服务 A 通过这个地址访问,不同环境,这个地址不一样,所以就要把地址配置到环境变量中。

但是在 k8s 里,我们可以给服务 B 固定一个 service name:http://b-svc, 服务 A 通过这个地址访问,在任何环境都是这个地址。

总结

做好了 helm 模板之后,可以做到一键部署完整的服务集合。

无论是开发本地集群,测试环境还是阿里云,只需要修改对应环境参数即可。

同时借助于 Rancher 提供的优秀的 Web 管理能力,极大简化了微服务部署和管理难度。

轻量型互联网应用架构方式

轻量型 Web 架构模式

前言

说到互联网应用架构,就绕不开微服务,当下(2019)最热门的微服务架构体系应该还是 Spring Cloud 和 Dubbo,阿里也推出了自己的 Spring Cloud 实现 Spring Cloud Alibaba。这类框架围绕微服务体系提供了大而全的功能,包括服务发现、治理、流量监控、配置管理等,让人向往。

但是其缺点也是比较明显:

  • 限定于 Java 体系,其他编程语言无法享受这个体系
  • 侵入应用,几乎每个 Java 应用都挂载一堆 Spring 相关的 jar 包

所以目前社区发展的新方向是:云原生。

其中的翘楚便是 istio,基于 kubernetes 的 sidecar 模式,把 Sprint Cloud 做的大部分工作下沉到基础应用层面,让应用层无感。
有了 Istio 之后,微服务应用实现了大减负,所以我重新思考了对于小型互联网公司来说,当下最适合的架构模式应该是什么

Web 架构模式

适用场景:小型互联网公司,业务多而小,追求开发效率。
解决方案:把重复工作隐藏起来,让应用层轻量快跑

具体有两个方向
基础架构层面:使用 Kubernetes 和 Istio,提供微服务所需要的功能支持
应用层面:针对 Nodejs 后端编程领域,提供一个最佳实额

整体架构图:

基础架构实践

目前还没有足够的时间完成落地实践,只能说一下理论规划。
现在是云时代,所以直接用 Kubernetes 来做应用编排就可以了。
后期可以落地 Istio,就可以保证微服务们稳定运行。

容器编排

Kubernetes

日志收集

  • 在 k8s 集成 elk
  • 输入日志到阿里云 nas

应用观测

Istio 提供 Grafana 请求分析、 Jaeger链路追踪、告警等

流量控制

  • 权限校验
  • 加密
  • 熔断限流

Web 应用架构

在应用层,我最终选择是使用 Node.js 语言来做主要的业务开发。

使用 Koa + 自研框架。

语言的选择,为什么是 Node.js

主要原因是我目前所在团队技术栈在 Node 积累比较多。

其次原因是有了 typescript 之后,使用 node 编写后端应用变得更加可维护了,而且 node 小而快(开发效率快,当然IO密集型的应用运行效率也不差)非常适合微服务场景。

最后,Node 的上手难度真的很低。

当然要使用其他语言也是可以的,如果有着复杂业务逻辑,可以使用 Kotlin + Spring Boot 这组套件,但不包括 Spring Cloud

Kotlin 能完美复用 JVM 生态,也可以避免 Java 语言的一些繁琐的写法,甚至后期还可以用上 Kotlin 的协程,来替代 Java 目前的多线程并发模型。

当然,本文讨论的是 Web 应用架构,如果你的应用是数据处理方面,本文无法提供任何帮助。而在 Web 领域,Python 和 PHP 对比起 Node,其实并没有优势,所以就不考虑了。

Web 框架如何选

Node 的 Web 框架还挺多的,我比较熟悉的有:

  • Sails.js 大而全,甚至有自己的 orm, node 世界的 ruby on rails
  • Express 基础 web 框架
  • Koa 更基础的 web 框架,甚至两 body-parse 都不自带
  • Thinkjs Thinkphp 吧
  • Egg 阿里出品,挺受欢迎的
  • NestJS node 世界的 Spring MVC

我公司早期使用 sails,看中其集成的丰富功能,但是后期 sails 的成长速度跟不上社区,对一些新特性 Generator 等不能及时兼容,而且太笨重了。

所以团队在 17 年左右转型选择了 Koa,然后自己组装实践各个基础功能,这个做法的好处是高度自定义,非常契合公司发展需要。

社区优秀的框架们

在 17 年之后,相继涌现一些优秀框架,像 egg 和 nestjs,当时我们并没有采用他们。

Egg 本身是优秀的框架,但是它是为中台打造的,但我公司需要用 Node 完成本来是 Java 做的事情,我们会用 Node 做比较重的业务,Egg 在这方便并不适合,例如没有模块划分的概念。

比起 Egg ,NestJS 就更适合我们,当时看它的文档就能感受出来,NestJS 的作者们是真的拿这个框架来做后端业务的,文档里讲到了我们日常碰到的很多痛点:

  • 模块划分
  • 循环引用
  • OpenAPI
  • IOC 依赖注入
  • CRUD

当时感觉像是找到了真爱,但是实践过后,发现一些问题:

  • 编码规范:NestJS 有自己的一套规范,而且它封装得足够多,基本上无法修改这套规范,只能妥协。
  • 集成测试:我们在早期使用 Koa 的过程中,沉淀了一套集成测试方案,特别是对 mongodb,我们做了一些工具可以自动注入和清除测试数据,要在 NestJs 实现这个功能,需要有足够的时间对其改造

基于上面两个原因,NestJS 并没有在团队推广开来。

自研的方向

社区的框架既然不合适,那就博采众长,自己组装一套框架出来,所以我最近捣鼓出了 @akajs, 预期说这个东西是框架,更准确的说法是,Kalengo团队的Node后端开发最近实践集合。就是把后端常用的东西集合打包起来,包含:

  • IOC 依赖注入
  • 注解式路由
  • CRUD 我们很多项目非常简单,重复的 CRUD 工作必须简化
  • Mongoose + Typescript 封装
  • Redis + Redlock 封装
  • 集成测试支持
  • 常用 Util,如日期数字处理等
  • 请求参数校验和全局异常处理

此外还有些最佳实践

  • OpenAPI 文档自动生成
  • Docker 支持
  • UML 设计
  • 模块划分

这些我们则通过项目模板来提供。

有了这套工具,后端可以更加轻量和统一,开发可以短时间内搭建起一个完备的后端服务。

总结

这套架构方式,不论是基础架构层面,还是应用层面,都是通过抽离通用的功能,把服务治理的东西抽出下沉到 Istio,把后端通用的工具和实践抽出放在 @akajs 和项目模板中,以此来简化上层应用。

这样一线的后端开发,可以专注于业务逻辑的实现,不必在为基础设施烦恼。毕竟小公司要生存,“快”是很重要的。