月度归档:2019年11月

基于 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 管理能力,极大简化了微服务部署和管理难度。