明志唯新

老衣的微服务实践简要指引 2017 版

发表于

这是老衣在 2017 年 5 月份总结的,适用于中小团队跨平台微服务开发的实践指引(简化版)。若有有不当之处,欢迎指点更正。 因本文涉及到大量第三方库或工具,详细学习和了解需要参考相关官方文档。若您在使用 Mac 电脑,建议安装使用 Dash 软件下载查阅;其他操作系统上则考虑使用 Chrome 浏览器在 http://devdocs.io/offline 上查阅文档,值得一提的是该网页的文档支持离线模式。

环境准备

全局必要项

  • node.js 可根据实际情况选择安装当前版或是长期支持版
  • docker 最新版 根据实际需要决定使用免费的 Docker CE 版或是收费的 Docker EE 版。注意 Windows 10 64 位专业版和 64 位企业版可以直接安装(依赖 hyper-v),其他旧版 Windows,需要使用 docker toolbox
  • 根据自己的喜好选择安装下列 Git 客户端:

全局可选项

开发环境

代码托管环境

为了最大限度提功开发部署灵活性,建议使用 Git 方式托管。所以可根据实际情况选择下列之一:

  • 支持 Git 库的 TFS
  • GitHub 私有库或企业版
  • Coding.net 私有库或企业版
  • 开源免费,私有部署的 Gogs,具有丰富的认证方式选择,并支持 Slack 平台的 WebHook
  • GitLab 可托管部署或私有部署

建议在以上任何一种 git 服务中,建立或使用一个支持证书登录的用户,该证书用于构建环境自动拉取代码时使用,不建议使用账号密码方式,因为安全性较差。该证书对应的用户应该有所有需要自动构建的代码库读权限。不建议给该用户开放写权限,避免一些潜在安全问题和代码冲突问题

构建环境

构建环境是专门用来自动化、编译、集成、测试、打包、发布的环境,建议使用独立的计算机和服务器做为构建环境。因为该环境对代码和测试、生产环境都有较高的权限,也会涉及到大量的安全证书或密钥等极敏感信息,所以强烈建议该环境所在设备仅限极少数高度可信赖的人访问管理,并有严格的安全规定,不允许随便安装软件或向外复制数据

建议使用 linux 作为构建环境的操作系统,因为 windows 的命令行工具能力有限。并确保安装如下工具:

  • Node.js 我们需要依赖很多 nodejs 工具链,所以这是必须安装的。通过下列命令确认是否安装,以及什么版本

    node --version
    

    还需要确认 npm 是否安装:

    npm --version
    

    npm 安装新版,可以通过自更新实现

    npm install -g npm
    
  • ShellJS 是在 Node.js API 之上的便携式 ( Windows/Linux/OS X ) Unix Shell 命令实现,可以消除或减少您的 Shell 脚本对特定操作系统的依赖。通过下面的命令全局安装

    npm install -g shelljs
    

    老衣的实践是利用 shelljs 的脚本能力以及js语言的丰富特性和灵活度,通过编写脚本的方式结合其他工具或平台实现编译、测试、打包、发布、通知等的自动化处理

  • Docker 几乎是微服务架构的必需品,我们通过 Docker 隔离和构建相关的服务,通过下列命令确认是否安装,以及什么版本

    docker --version
    
  • git 确保安装使用了最新的 git 命令行客户端,可通过下列命令确认是否安装,以及什么版本

    git --version
    
  • Python 很多辅助工具是用 python 开发的,所以有备无患,通过下列命令确认是否安装,以及什么版本

    python --version
    
  • Mono 是跨平台的 .NET 框架实现,目前 5.0 以上版本已经完整匹配 .NET 4.6.2 的 API 集,几乎除了 Windows 平台特有的 API 外,Mono 和 .NET 框架几乎是完全兼容的。所以如果你的微服务是用的 .NET 开发的,这是必须安装的——当然如果你只打算在 Windows 上用,可以不安装这个。通过下列命令确认是否安装,以及什么版本

    mono --version
    
  • .NET Core 如果你的微服务有用的 .NET Core 开发的,这是必须安装的。通过下列命令确认是否安装,以及什么版本

    dotnet --version
    
  • Cake 是 C# Make 的缩写,是一个基于 C# DSL 的跨平台自动化构建系统。它可以用来编译代码,复制文件以及文件夹,运行单元测试,压缩文件以及构建 Nuget 包等等。

  • TeamCityJenkins 这两个是独立的自动构建服务器软件,如果不愿意使用 shelljs 之类的脚本自行编写构建任务,可以通过他们在管理界面上设置,不过学习成本和复杂度蛮高的,如果团队内没有人熟悉这两个工具,早期不建议使用。

  • 其他语言的基础框架根据实际的每个微服务所使用的语言环境决定安装哪些基础支持工具、框架、模块等

自动构建服务,在拉取代码、获取依赖包、编译、测试、打包、发布等各个环节都可能会发生错误或异常,而编译不通过或测试不通过等情况也应该第一时间跟团队或项目管理者报告,我们在实践中更推荐使用 Slack 来实现,实现方式请参考官方文档 https://api.slack.com/incoming-webhooks,绝大部分情况下 1-3 行代码即可实现,非常方便,当然了这里有个小问题 Slack 的所有客户端(web、手机 App、桌面应用等)都没有中文版。当然了你愿意使用电子邮件或企业微信或微信服务号来实现也可以,只是实现成本和效果跟Slack比完全不在一个级别上。注意:Slack 的 webhook 地址通常类似于 https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX 这属于保密的地址,不应该在公开的文档或代码中体现,否则容易在 Slack 的相关频道中产生虚假的信息,切记!!!

当然了上述构建所需到基础依赖,也可以在准备好到Docker容器中做,可减少不同服务对基础依赖(比如 php、java、nodejs 等)版本不同而带来的构建冲突。早期团队可通过技术选型很大程度上避免基础依赖冲突,所以可先不考虑使用大量 Docker 容器隔离不同到构建环境,待团队逐渐成熟壮大后,可根据需要慢慢向这个方式靠拢。

我们在实践中更倾向于使用 Docker 构建相应的镜像来部署最终的微服务,所以需要一些必要的基础镜像提前做准备。推荐安装如下镜像:

docker pull alpine
docker pull busybox
docker pull centos
docker pull ubuntu
docker pull postgres
docker pull microsoft/dotnet
docker pull microsoft/aspnetcore
docker pull mono
docker pull node
docker pull golang
docker pull mongo
docker pull mysql
docker pull redis
docker pull rabbitmq
docker pull memcached
docker pull nginx
docker pull openresty/openresty

以上镜像,如果需要安装特定版本,可通过加 tag 参数来获取对应版本的镜像。特别一提的是其中 alpinebusybox 都是使用广泛的超小型基础镜像,大小仅有几 M 而已,对最终镜像大小很敏感的可考虑使用这些镜像作为基础。

通过下列命令可在部署环境中确认上述镜像是否已经 pull 到:

docker images

私有 Docker Registry 服务器

Docker Store 上已经有大量官方镜像和公开镜像,可供参考使用,但通常我们自己到私有服务的镜像是不希望公开让别人下载使用的。所以需要搭建自己的镜像库,所以我们需要使用 Docker Registry 来搭建自己的私有镜像库。

注意:新版到 registry 要求必须使用 https 协议,所以搭建时需要考虑购买或使用免费的 SSL 证书。目前国内的腾讯云和阿里云貌似都有免费一年到二级域名证书,可考虑使用。

使用私有的 Docker Registry 服务后,每次构建好的服务镜像都是先 push 到私有服务上,然后再在生产环境中 pull 最新版,并部署使用。

测试&生产环境

测试或生产环境,基本上仅需要安装 Docker,通过相关的命令来拉取相关镜像,并部署或维护所需服务的容器即可。单服务器上多个微服务如果有依赖关系或启动先后顺序等要求时,建议使用 Docker Compose 对微服务的相关容器进行编排。是否使用 Docker Swarm 来做集群化编排更新则由实际业务要求和团队对 Docker 的熟练度来决定。

需要特别指出的是关于数据库的处理,微服务架构通常强调服务的无状态化,其中原因主要是为了服务的可复制性和可迁移性,所以目前的主流实践是,数据库在正式生产环境时,使用公有云的独立数据库服务或自己机房的独立数据库服务器的数据库,不建议使用容器作为数据库的宿主——但测试环境可以根据情况考虑使用,以方便模拟和测试各种情况。

生产环境的服务发现,考虑使用分布式服务发现和配置工具 Consul 。但如果微服务整体全部都是基于 Docker 的,而且集群使用等 Docker Swarm 实现的,那么就不必使用其他工具来做服务发现了,因为 Docker Swarm 内置了服务发现机制。

开发过程

  1. 根据业务和系统架构,拆分微服务
  2. 为每个微服务设计 RESTful Web API的文档,建议使用 API Designer 工具,借助于 RAML 语言设计 API
  3. 根据 API 设计文档,使用 json-server 来编写 API 的 Mockup
  4. Web 前端或 App 通过调用 mock 出来的 api 开发相关功能页面,同时后端人员根据 API 设计文档开发后端 API,测试人员则根据设计文档和 mockup 版的 API 表现编写自动化测试脚本
  5. 构建服务定期从 git 服务器上拉取最新代码,根据项目的定义自动编译、测试相关微服务,并通过 Slack 通知团队或项目负责人;
  6. 构建服务在编译和测试都通过后,通过脚本自动构建 Docker 镜像,然后 push 到私有的 docker 镜像库中,并通过 Slack 通知团队或项目负责人;
  7. 测试&生产环境根据提前编写好的脚本,自动从私有镜像库中拉取最新版的服务镜像,部署运行对应的新服务

注意事项:

  • 期间测试人员通过编写的自动化 API 测试脚本不断验证服务的最终表现是否正确,不正确的不应该进入生产环境
  • 单元测试是开发人员自行编写的,但是需要明确测试的命令行调用方式,以便构建服务器能够不停的测试验证代码
  • 每个用到数据库的微服务都应该具备数据库的自动化迁移能力,比如新增的表在服务启动时应该会自动创建,而如果服务回滚到旧版本时必须能够将数据库对应回滚到原来的结构,比如删除增加的表或字段等。
  • 前后端应该完全隔离在不同的镜像中,在服务的总出口处(通常是 nginx 之类带有反向代理的服务器)将 api 和页面集成到同一域名下
  • 生产环境尽可能利用 nginx 服务器的热部署和热更新能力

API 的自动化测试,可通过如下 nodejs 工具开发测试脚本:

实际演练

微服务有几个要点:

  • 功能独立
  • 独立部署
  • 独立进程
  • 可替换性

功能独立容易理解,而难度是整体服务如何合理拆分,其基本原则就是看该服务的数据是否需要关联另外一个服务的数据表才能查出,如果是则应该合并到一个服务中,否则可独立拆分;独立部署独立进程 告诉我们,每个服务运行在独立的进程或服务器中,每个服务都应该可以独立部署,且不应该影响其他服务的运行;可替换性,则是说每个服务都应该可以随时用任何编程语言重新实现并替换掉旧有的服务而不会影响其他服务和整体系统的正常运行。

鉴于每个微服务都可能随时更新,为了保证整体系统运行的稳定性,我们需要至少做到如下几点:

  • 每个微服务的更新过程都要足够的快,要尽可能在 1 秒内完成,以便减少服务更新时出现的各种异常
  • 每个微服务调用其他服务时最后都具备 retry 机制,以便在调用到的另外一个微服务出现短期故障(比如更新服务)时,能够具备过一点时间后再访问的能力,这样最前端的用户或者服务调用者会认为当前的服务调用速度只是比原来慢了而已
  • 集中的日志收集服务,可以通过该服务的界面上查看到各个微服务的日志,以便团队或项目领导者能够及时了解和分析系统的运行状态,及时发现和修复各种问题异常
  • 微服务架构的复杂性决定了微服务总体出现异常的几率比单体系统要多,所以单元测试和集成测试变得极其重要,甚至可以说是必须的
  • 每个微服务对外的服务接口(如 WebAPI),应该都是具备普适性、自释性、可预见性、明确性的。也就是:
    • 每个微服务的对外接口实现可以不依赖于任何特定的语言
    • 每个微服务调用另外一个服务时,都应该是尽可能使用通用的通信协议,而不是私有的,避免跨语言实现的难度,即通畅是使用 HTTP 协议
    • 每个服务的最终路由地址是不可随意变更的
    • 每个服务的路径自身就是其功能的最简说明
    • 通过一个 api 地址就能较为准确的猜出另外一个服务的地址和访问方式

基于以上观点,并假定我们大部分的微服务都使用 .NETMono.NET Core 编写,那么如下相关的库和组件则应该是必须的或优先考量的:

  • NancyFx 这是一个非常优秀的Web框架,独立于 ASP.NET 以外,也就是说他自身不依赖 System.Web 命名空间下的任何东西。具有极好的跨平台支持能力和极佳的可测试性、组件可替换性、路由可视性。用它编写微服务,会让开发过程变得很简单。当然如果你实在不喜欢这套框架的风格,依然可以选用 ASP.NETASP.NET MVCASP.NET Core 等相对传统的技术,毕竟这并不影响微服务实现的本身。
  • Flurl 这是一个新出现的优雅的的Http客户端库,它依然基于 HttpClient,但是提供了 Fluent 风格的编程模型,所以写代码时会比传统方式要爽快和高效的多。虽然 RestSharp 也是一个很优秀的 WebAPI 访问库,但是它目前不支持 .NET Core 等框架,所以跨平台能力欠佳。
  • xUnit.net 这是 NUnit.net 原作者几年前重新打造的一个 .NET 平台的单元测试框架,具有丰富的工具链。微软官方的 ASP.NET MVC 等框架也都使用的该框架编写的所有单元测试,而没有采用微软自家的单元测试框架。
  • KestrelHttpServer 这是微软官方实现的 ASP.NET Core 的 Host 服务器框架,可以实现极高性能的 SelfHost 服务,而不必依赖 IIS。
  • TinyMapper 比老牌的 AutoMapper 具有更佳的性能(大概快60倍),而且老衣也曾贡献了部分代码增加了一些特性。不过目前对 .NET Core 支持方面还有点瑕疵,但可以在 .NET 项目中大规模使用。
  • Mapster 它跟 TinyMapper 一样是新型的 Mapper 库,号称快速、有趣且激动人心的 Mapper,老衣目前在 .NET Core 项目中主要采用这个作为主要的 Mapper 库。
  • Json.NET .NET 平台下最流行的高性能(但不是最快的)JSON 库。
  • Polly.NET 平台下,让开发者可以轻松实现线程安全的重试熔断超时代码,大幅提升应用稳定性的绝对利器
  • C-Sharp-Promise Promise在 js 开发领域大行其道,深得开发者们的喜爱。C# 程序员们可以用 C-Sharp-Promise,使用 Promise 的方式编程。一些时候你会发现它比 async 的方式更好
  • Topshelf 当你需要将一个 .NET 的 Console 或者桌面应用,作为 Windows 服务运行时,它会很好的帮到你。它还支持 Mono,也就是说可以在 Linux 上玩
  • Dapper 轻量级的通用 ORM,支持市面上大多数关系型数据库。兼容 .NETMono.NET Core

.NET Core 上使用 Nancy 创建一个 Hello World 级的微服务

  1. 先确保 node.jsnpm 已安装

    node --version
    npm --version
    

    如果上述命令显示未安装 node.js,请到 Node.js官网 下载对应操作系统的版本,并安装好。

  2. 确保 yeoman 已经安装

    yo --version
    

    如果上述命令未输出版本号,提示 yo 命令不存在则,应该使用下面到命令安装它

    npm install -g yo
    
  3. 确保 generator-aspnet 已经安装

    yo aspnet
    

    如果上述命令提示未安装一个名叫 aspnetgenerator, 则需要使用下面到命令安装 generator-aspnet

    npm install -g generator-aspnet
    
  4. 上一步命令正常会输出如下结果

         _-----_     ╭──────────────────────────╮
        |       |    │      Welcome to the      │
        |--(o)--|    │  marvellous ASP.NET Core │
       `---------´   │        generator!        │
        ( _´U`_ )    ╰──────────────────────────╯
        /___A___\   /
         |  ~  |
       __'.___.'__
     ´   `  |° ´ Y `
    
    ? What type of application do you want to create? (Use arrow keys)
    ❯ Empty Web Application
      Empty Web Application (F#)
      Console Application
      Console Application (F#)
      Web Application
      Web Application Basic [without Membership and Authorization]
      Web Application Basic [without Membership and Authorization] (F#)
    (Move up and down to reveal more choices)
    

    按键盘的 ⬇ 键,直到 出现在 Nancy ASP.NET Application 这一行后,按回车键

    ? What's the name of your ASP.NET application? (NancyApplication) 后面输入你的服务的名字(比如:hi)后回车。

    提示: 由于这个名字会出现在代码中,所以请使用类似 C# 变量 的命名规范输入,不要使用任何中文字符或 -,也不要是纯数字。

  5. 假设上一步输入的名字是 hi,使用下列命令进入 hi 目录,并使用 Visual Studio Code 打开该目录

    cd hi
    code .
    
  6. Visual Studio Code 打开后,查看修改 global.json 文件中的 sdk version 为最新安装的 .NET Core SDK 的版本。如果不知道当前安装的 .NET Core SDK 是什么版本,可通过执行下列命令查看

    dotnet --version
    

    如果你安装的是 1.0.4 版,则此时应该会看到结果是 1.0.4

    此时把 global.json 文件中 version 后面的版本号,改为刚才命令中输出的版本号(例如 1.0.4)。

  7. 查看 hi.csproj 文件中的 TargetFramework 节点的值,如果你打算使用 .NET Core 1.0 则应该是 netcoreapp1.0,如果打算使用 .NET Core 1.1 则应该是 netcoreapp1.1 。你还需要根据自己选择版本修改 Nuget 引用的相关库的版本。还要修改 AssemblyName 的值为你期望的程序集名,通常我们跟服务名一致,本例中应该是 hi

    这里我们使用 .NET Core 1.1 版来开发,那么此时应该将 hi.csproj 文件的内容调整为如下:

    <Project ToolsVersion="15.0"  Sdk="Microsoft.NET.Sdk.Web">
    
      <PropertyGroup>
        <TargetFramework>netcoreapp1.1</TargetFramework>
        <DebugType>portable</DebugType>
        <AssemblyName>hi</AssemblyName>
        <OutputType>Exe</OutputType>
      </PropertyGroup>
    
      <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="1.1.2" />
        <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="1.1.2" />
        <PackageReference Include="Microsoft.AspNetCore.Owin" Version="1.1.2" />
        <PackageReference Include="Nancy" Version="2.0.0-clinteastwood" />
      </ItemGroup>
    
    </Project>
    

    注意: Nancy 的版本号应该使用最新支持 .NET Core 的版本。2.0.0-clinteastwood 是编写此文时的 NancyFx 最新版。实际操练时需要根据新的情况对应修改。

  8. 执行下列命令,加载依赖包、编译并运行这个 hi 服务

    dotnet restore
    dotnet build
    dotnet run
    

    会看到如下结果

    Hosting environment: Production
    Content root path: /Users/yimingzhi/Projects/wang/hi
    Now listening on: http://localhost:5000
    Application started. Press Ctrl+C to shut down.
    

    在浏览器中访问 http://localhost:5000 地址,可以看到 Hello from Nancy running on CoreCLR

    这样一个最简单的 Hellow World 级的微服务已经开发完成。其中 HomeModule.cs 文件就是这个服务的核心逻辑代码:

    namespace hi
    {
        using Nancy;
    
        public class HomeModule : NancyModule
        {
            public HomeModule()
            {
                Get("/", args => "Hello from Nancy running on CoreCLR");
            }
        }
    }
    

    是不是非常简单呢 _

  9. 目前这个服务看起来很美,但是存在如下问题:

    • 需要运行主机上安装 .NET Core 的 sdk 或者运行时
    • 如果多个服务用的不同版本的 .NET Core 则可能会出现版本冲突等问题
    • 部署起来比较麻烦,需要用 ftprdpsshrsync 等工具部署大量文件
    • 基本不具备规模化横向部署能力,比如一次性部署 100 个节点
    • 一旦服务崩溃或其他异常退出时,无法自动重启

    因此我们需要借助 Docker 这个容器工具来解决它,如果你不确定是否安装了 docker,请运行下面的命令确认

    docker --version
    

    当然你也可以通过

    docker info
    

    查看 docker 的一些信息,还可以通过

    docker images
    

    查看已经 pull 到本地的 docker 镜像列表,需要确认 .NET Core1.1.2 运行时(编写此文时的最新版本)镜像是否存在,如果不存在请使用下列命令拉取镜像:

    docker pull microsoft/dotnet:1.1.2-runtime
    

    拉取完毕后我们就可以准备构建 hi 服务的 docker 镜像了

  10. hi 目录下创建一个名字为 Dockerfile 的文件——注意:这个文件名是区分大小写并没有任何扩展名的

  11. Visual Studio 2017Visual Studio Code 打开刚才创建的 Dockerfile 文件,并将以下内容写入并保存:

    FROM microsoft/dotnet:1.1.2-runtime
    ENV TZ=Asia/Shanghai
    COPY ./dist /app
    WORKDIR /app
    EXPOSE 5000/tcp
    ENTRYPOINT ["dotnet","./hi.dll"]
    

    提示:

    ENV TZ Asia/Shanghai 表示该镜像的容器默认的运行时时区为上海时区,这对于大陆的服务来说至关重要,不加这行默认情况一般都是 UTC 时间,切记 EXPOSE 5000/tcp 表示该镜像的容器默认向外暴露 5000 端口,这里就是 hi 服务的默认端口 ENTRYPOINT ["dotnet","./hi.dll"] 中的 hi.dll,为服务的主运行程序,不同服务这个地方会有所不同 之所以使用 runtime 版的基础镜像而不是 sdk 版,是因为 sdk 版的镜像太大(大了几百兆),对 publish 后的 .NET Core 应用也是没有必要的。

  12. hi 目录下运行下面的一组命令构建正式发布版的 hi 服务镜像

    dotnet restore
    dotnet build -c Release
    dotnet publish -c Release -o dist
    docker build -t hi . 
    

    使用 docker images 命令查看是否有名为 hi 的镜像出现在列表中。如果没有,请检查上面的这组命令是否输出了什么错误信息,修改代码并重新执行上面这组命令,直到 docker 镜像列表中有名为 hi 的镜像出现为止。

    提示

    上面这组命令中 hi 服务被发布在 dist 目录中,因此需要在 .gitignore 中将 dist 目录设置为忽略项,不做版本控制。

  13. 通过下面的命令执行 hi 镜像:

    docker run --name hiServ --rm -p 5000:5000 hi
    

    此时会收到如下结果:

    Hosting environment: Production
    Content root path: /app
    Now listening on: http://localhost:5000
    Application started. Press Ctrl+C to shut down.
    

    第1行告诉你运行环境是产品环境还是开发环境;第3行意思是你可以通过访问本机的 http://localhost:5000 看到结果。但是这时候我们在浏览器中访问 http://localhost:5000,会提示你: 无法访问此网站

    在另外一个控制台窗口 ( Windows 下叫命令行窗口) 中运行下面的命令进入容器:

    docker exec -it hiServ bash
    

    运行命令:

    curl http://localhost:5000/
    

    此时会看到输出结果为 Hello from Nancy running on CoreCLR,这说明 hi 服务可从内容内访问,是容器外无法访问。

    输入 exit 命令退出容器

  14. 打开 Program.cs 文件,在 .UseKestrel() 下插入一行新代码 .UseUrls("http://*:5000/"),即 Program.cs 的代码变为:

    namespace hi
    {
        using System.IO;
        using Microsoft.AspNetCore.Hosting;
    
        public class Program
        {
            public static void Main(string[] args)
            {
                var host = new WebHostBuilder()
                    .UseContentRoot(Directory.GetCurrentDirectory())
                    .UseKestrel()
                    .UseUrls("http://*:5000/")
                    .UseStartup<Startup>()
                    .Build();
    
                host.Run();
            }
        }
    }
    
  15. 再次运行下面这组命令,重新构建 hi 服务的镜像:

    dotnet restore
    dotnet build -c Release
    dotnet publish -c Release -o dist
    docker build -t hi .
    

    运行下面的命令临时将 hi 镜像以名为 hiServ 的容器运行:

    docker run --name hiServ --rm -p 5000:5000 hi
    

    打开浏览器,访问 http://localhost:5000/, 看到了 Hello from Nancy running on CoreCLR

    这样我们的 hi 服务容器镜像就算成功了。

    提示

    docker run --name hiServ --rm -p 5000:5000 hi 中的 --name hiServ 是为容器起一个特定名字hiServ; --rm 是让这个容器在退出时立即删除; -p 5000:5000 是为了将容器的 5000 端口映射到当前主机的 5000 端口上,以便在真机中可以测试访问这个服务。 最终生产环境中不会以当前用户的前置进程方式运行,否则用户退出时容器就会结束运行。所以我们通常会额外附加 -d 参数让服务在后台执行,详情请参考 Docker CLI 的官方文档。

将hi服务增加数据有关的 API

前面,我们演示了如何使用 docker 将一个 .NET Core 跑起来。下面为 hi 服务增加几个复杂的 API

增加一个服务状态的 api

  1. 在 hi 这个项目中增加一个新文件 StatusModule.cs,内容如下

    namespace hi
    {
        using Nancy;
        using System;
    
        public class StatusModule : NancyModule
        {
            public StatusModule()
            {
                Get("/status", _ =>
                {
                    return new
                    {
                        running = true,
                        time = DateTime.Now
                    };
                });
            }
        }
    }
    

    用下面的一组命令运行起 hi 服务:

    dotnet restore
    dotnet build
    dotnet run
    

    postman 或非 windows 系统的命令行工具 curl,访问 http://localhost:5000/status,会收到一个 json 数据,类似于:

    {
      "running": true,
      "time": "2017-05-16T18:03:34.4867970+08:00"
    }
    

    但是,如果你用浏览器访问 http://localhost:5000/status 你会收到一个错误页,标题是 500 - Internal Server Error,不过你看不到错误内容——让你改一下代码才能看到错误。

    在hi目录中新建一个文件 Bootstrapper.cs,内容如下:

    namespace hi
    {
        using Nancy;
    
        public class Bootstrapper : DefaultNancyBootstrapper
        {
            public override void Configure(Nancy.Configuration.INancyEnvironment environment)
            {
    #if DEBUG
                environment.Tracing(enabled: false, displayErrorTraces: true);
    #endif
            }
        }
    }
    

    再次编译运行,

    dotnet restore
    dotnet build
    dotnet run
    

    用浏览器访问 http://localhost:5000/status,会看到一个很详细的 Nancy.ViewEngines.ViewNotFoundException 异常报告,意思是找不到名为 ><f__AnonymousType0'2 的任何视图文件。

    这其实是 Nancy 框架的一个特性,他可以实现一个地址即是 webapi 又是网页,其具体的原理就是判断当前请求的 Content-Type 中是否以 text/html 开头,如果是说明当前请求是一个浏览器,那么 Nancy 就会使用当前地址返回的 model 类型名作为视图名查找视图文件,找到了就用类似于 mvc 的方式显示视图页面,如果找不到就报错。

    如果我们不希望用这个特性,就是期望该地址永远返回一个 json 数据,则需要使用 Response.AsJson 方法。StatusModule.cs 应该修改为:

    namespace hi
    {
        using Nancy;
        using System;
    
        public class StatusModule : NancyModule
        {
            public StatusModule()
            {
                Get("/status", _ =>
                {
                    return Response.AsJson(new
                    {
                        running = true,
                        time = DateTime.Now
                    });
                });
            }
        }
    }
    

    再次编译运行,

    dotnet restore
    dotnet build
    dotnet run
    

    用浏览器访问 http://localhost:5000/status,我们会看到与在 postman 看到的一样漂亮的 json 数据。Great!

  2. 准备一个数据库,为了跨平台我们选择 PostgreSQL 数据库。当然了在计算机上安装一个数据库是很麻烦的事情,这里我们用 Docker 直接用官方的 postgres 镜像运行起一个容器即可。执行下列命令:

    docker run --name pgdb -e POSTGRES_PASSWORD=123456 -p 5432:5432 -d postgres
    

    此时我们就准备好了一个 postgres 数据库,账号是 postgres,密码是 123456,并可以通过本机的 5432 端口访问到它。你可以使用 pgAdmin 链接并管理这个数据库,老衣假定你已经学会使用 pgAdmin 的基本功能,才继续下面的演练的。

  3. 使用 pgAdmin 在 pgdb 这个数据库容器中创建一个新的数据库 hiDB

  4. 在 hi 项目上,从 Nuget 引用最新版的 Npgsql,先在 Bootstrapper.cs 中通过 Nancy 自带的容器注册一个数据库连接:

    namespace hi
    {
        using System.Data;
        using Nancy;
    
        public class Bootstrapper : DefaultNancyBootstrapper
        {
            public override void Configure(Nancy.Configuration.INancyEnvironment environment)
            {
    #if DEBUG
                environment.Tracing(enabled: false, displayErrorTraces: true);
    #endif
            }
    
            protected override void ConfigureApplicationContainer(Nancy.TinyIoc.TinyIoCContainer container)
            {
                base.ConfigureApplicationContainer(container);
    
                container.Register<IDbConnection>((c, p) =>
                {
                    return new Npgsql.NpgsqlConnection("Host=localhost;Username=postgres;Password=123456;Database=hiDB;Timeout=3");
                });
            }
        }
    }
    

    然后增加一个 DbConnectExtensions.cs 文件,代码:

    using System.Data;
    
    namespace hi
    {
        public static class DbConnectExtensions
        {
            public static bool IsConnectable(this IDbConnection conn)
            {
              bool connectable = false;
              try
              {
                  conn.Open();
                  var cmd = conn.CreateCommand();
                  cmd.CommandText = "select 1;";
                  int r = (int)cmd.ExecuteScalar();
                  connectable = r == 1;
              }
              catch
              {
                  connectable = false;
              }
              finally
              {
                  if (conn.State == ConnectionState.Open)
                  {
                      conn.Close();
                  }
              }
              return connectable;
            }
        }
    }
    

    之后在 StatusModule.cs 中增加数据库可连接性的状态检测,代码如下:

    namespace hi
    {
        using System;
        using System.Data;
        using Nancy;
    
        public class StatusModule : NancyModule
        {
            public StatusModule(IDbConnection conn)
            {
                Get("/status", _ =>
                {
                    return Response.AsJson(new
                    {
                        running = true,
                        dbConnectable = conn.IsConnectable(),
                        time = DateTime.Now
                    });
                });
            }
        }
    }
    

    再次调试运行 hi 项目,然后访问 http://localhost:5000/status 会收到类似于下面这样的 json 数据:

    {
      running: true,
      dbConnectable: true,
      time: "2017-05-17T14:47:47.1287050+08:00"
    }
    

    其中 dbConnectable 表示数据库可连接性状态。如果你把数据库容器停止:

    docker stop pgdb
    

    再次调试运行,并访问 http://localhost:5000/status,会发现 dbConnectable 的值变为 false 了。 嗯,脑袋聪明的你一定会想: 这个数据库检测的代码应该封装成一个方法,这样就可以多个服务中都能方便做这个检查了; 而数据库连接字符串,也应该用配置文件。老衣在这里先提示你这未必是好的,至于原因吗,后面会有讨论。

    当然了,你也可以在这个 api 中增加 CPU、内存等实时环境状态数据,用于监控服务通过这个 api 定时获取到该服务的相关状态数据

增加一组简单的新闻相关 API

  1. 先确保数据库容器 pgdb 处于启动状态:

    docker start pgdb
    
  2. 在数据库 hiDB 上建一张 news 表:

    create table news
    (
        id serial primary key,
        title varchar(200) not NULL,
        content text NOT NULL
    )
    
  3. 在 hi 项目上,从 Nuget 引用最新版的 DapperDapper.Contrib

  4. 在 hi 项目中,新建一个文件 NewModule.cs:

    using System.Data;
    using Dapper.Contrib.Extensions;
    using Nancy;
    using Nancy.ModelBinding;
    
    namespace hi
    {
        public class NewsModule : NancyModule
        {
            public NewsModule(IDbConnection conn)
            {
                Get("/api/news", _ =>
                {
                    return Response.AsJson(conn.GetAll<News>());
                });
    
                Post("/api/news", _ =>
                {
                    var model = this.Bind<News>();
                    var insertNumber = conn.Insert(model);
                    return Response.AsJson(insertNumber > 0);
                });
    
                Get("/api/news/{id:int}", x =>
                {
                    int id = x.id;
                    var model = conn.Get<News>(id);
                    if (model == null)
                    {
                        return Response.AsJson(new { error = "该新闻不存在" }).WithStatusCode(HttpStatusCode.NotFound);
                    }
                    return Response.AsJson(model);
                });
            }
        }
    
        [Table("news")]
        public class News
        {
            public int id
            {
                get;
                set;
            }
    
            public string title
            {
                get;
                set;
            }
    
            public string content
            {
                get;
                set;
            }
        }
    }
    

    为了简单期间,我把 News 的定义放在了同一个文件中,实际项目中不应该这样写

    即增加了 3 个 API:

    • 所有新闻的列表
    • 添加一个新闻
    • 获取指定 id 的新闻

    用 postman 向 http://localhost:5000/api/news POST 一条 json 数据:

    {
        "title":"test",
        "content":"this is only one test"
    }
    

    收到 true 说明添加成功,接着 GET 请求 http://localhost:5000/api/news 会获取含有刚添加的新闻的数组 json:

    [
      {
        "id": 1,
        "title": "test",
        "content": "this is only one test"
      }
    ]
    

    GET 请求 http://localhost:5000/api/news/1 会获取 id 为 1 的新闻数据 json:

    {
      "id": 1,
      "title": "test",
      "content": "this is only one test"
    }
    

    GET 请求 http://localhost:5000/api/news/2会获取一个状态码为404的json数据:

    {
      "error": "该新闻不存在"
    }
    

    很棒啊, 3 个 API 都表现正常了。是不是感觉用 Nancy 替代 ASP.NET MVC,用 Dapper 替代 EntityFramework 写代码变得更畅快淋漓呢?嗯,不过也不是说这些替代就任何场合都会更好,这完全可以由开发者或团队根据实际情况自由控制。 但老衣在这里主要是想说,一些其他选择也许可以让你的开发变得轻松有趣一些了,甚至更多好处。

    总之,以后我们也就可以使用以上类似的方式,很快速的创建其他微服务了。

  5. 嗯,到这里不知道你是否想起了前面的数据库连接字符串中,包含了 Host=localhost 也就是说数据库服务器的所在地是本机。而我们的数据库实际上是在另外一个容器中,只是因为我们运行容器时把本机端口和容器做了个映射,所以造成了数据库在本机的假象。我们到底应该如何处理呢?一台生产环境的机器上可能会有多个数据库容器,或者其他服务的容器,我们不能也不应该把所有服务的端口都在真实生产环境中上进行映射暴露。生产环境中数据库容器或类似的涉及信息安全的容器,都应该尽可能不允许外部直接访问,而是使用容器的相关通信手段。这样一来,我们的hi服务访问的数据库应该是 pgdb 这个容器。所以在部署(非开发阶段)应该将 Host 的值改为 pgdb,并使用 link 方式将 hi 服务的容器与 pgdb 进行连接,直接通过容器通信。改完数据库连接字符串后,重新制作 hi 服务的容器镜像

    dotnet restore
    dotnet build -c Release
    dotnet publish -c Release -o dist
    docker build -t hi .
    

    构建完成后,以容器方式连接 pgdb 并运行

    docker run --name hiServ --rm -p 5000:5000 --link pgdb:pgdb hi
    

    访问 http://localhost:5000/status,收到

    {
      running: true,
      dbConnectable: true,
      time: "2017-05-18T14:52:30.3009050+08:00"
    }
    

    可以看出数据库连接是成功的,很棒。即使你把 pgdb 容器的端口映射移除,也会发现 hi 服务仍可以访问 pgdb 容器的数据库,但是你的 pgadmin 就访问不了,挺安全吧 :D 至于如何跨服务器连接容器通信,这属于高级话题,有机会再细聊或者直接查相关文档和书籍研究一下。

  6. 看起来这时候我们用一个数据库容器和一个 hi 服务容器实现了期望的功能,是不是完美了呢?当然不完美,因为不完美的人类永远造不出完美的事物来,只能尽力无限趋向完美!(来自老衣语录^_^)把数据库容器也作为一个微服务,我们都知道:是个服务就有升级、更新、重启或宕机的时候。我们希望数据库容器中的数据库因某原因出现崩溃退出时能够自动重启数据库服务,以便达到整体系统的高可用度。docker 运行容器时指定 --restart 参数就可以非常简单做到这一点,Great!看起来很美是不是,但请想一下如果用户刚好在数据库崩溃时访问了 hi 服务,是不是就会收到服务器端异常呢?就前面实现的代码来说,答案是肯定的。那么怎么能够让用户感受不到这中间的短时间数据库失联呢?前文中我们提到过 .NET 领域有个很好的库 Polly,可以再遇到一些异常时实现 Retry。现在用它改造一下 hi 服务的代码,让新闻有关的几个 api 实现数据库链接异常时自动 Retry:

    先在 hi 项目上用 Nuget 引用 Polly,然后修改 NewsModule.cs 的代码为:

    using System.Data;
    using Dapper.Contrib.Extensions;
    using Nancy;
    using Nancy.ModelBinding;
    using Polly;
    
    namespace hi
    {
        public class NewsModule : NancyModule
        {
            public NewsModule(IDbConnection conn)
            {
                Get("/api/news", _ =>
                {
                    var list = DbRetry().Execute(() => { return conn.GetAll<News>(); });
    
                    return Response.AsJson(list);
                });
    
                Post("/api/news", _ =>
                {
                    var model = this.Bind<News>();
                    var insertNumber = DbRetry().Execute(() => { return conn.Insert(model); });
                    return Response.AsJson(insertNumber > 0);
                });
    
                Get("/api/news/{id:int}", x =>
                {
                    int id = x.id;
                    var model = DbRetry().Execute(() => { return conn.Get<News>(id); });
                    if (model == null)
                    {
                        return Response.AsJson(new { error = "该新闻不存在" }).WithStatusCode(HttpStatusCode.NotFound);
                    }
                    return Response.AsJson(model);
                });
            }
    
            Policy DbRetry()
            {
                return Policy.Handle<System.Net.Sockets.SocketException>()
                             .Or<Npgsql.NpgsqlException>()
                             .RetryForever();
            }
        }
    
        [Table("news")]
        public class News
        {
            public int id
            {
                get;
                set;
            }
    
            public string title
            {
                get;
                set;
            }
    
            public string content
            {
                get;
                set;
            }
        }
    }
    

    编译运行 hi 服务后,不要着急访问 news 相关 api,先模拟数据库失联:

    docker stop pgdb
    

    等数据库停止后,用浏览器访问 http://localhost:5000/api/news,你会发现浏览器一直在等待服务器响应…… 立即再切换到刚才的命令行窗口,启动数据库容器:

    docker start pgdb
    

    再次切换到刚才访问 http://localhost:5000/api/news 那个浏览器窗口,你会惊奇的发现有数据了,而不是返回数据库异常!这就是 Polly 的强大之处。当然了这个库还有很多强大功能,请移步到官方网站查看文档学习一下吧。

    到这里,聪明的你是否已经联想到,其他情况一个微服务请求另外一个微服务的时候也可以用 Polly 实现高可用呢?

用用OpenResty吧

OpenResty 是一款基于 NGINXLuaJIT 的 动态 Web 平台。你可以根据自己的需求设定启用的模块,并编译生成自己定制的 OpenResty。官方的 Docker 镜像默认包含了一些模块:

  • file-aio
  • http_addition_module
  • http_auth_request_module
  • http_dav_module
  • http_flv_module
  • http_geoip_module=dynamic
  • http_gunzip_module
  • http_gzip_static_module
  • http_image_filter_module=dynamic
  • http_mp4_module
  • http_random_index_module
  • http_realip_module
  • http_secure_link_module
  • http_slice_module
  • http_ssl_module
  • http_stub_status_module
  • http_sub_module
  • http_v2_module
  • http_xslt_module=dynamic
  • ipv6
  • mail
  • mail_ssl_module
  • md5-asm
  • pcre-jit
  • sha1-asm
  • stream
  • stream_ssl_module
  • threads

其中 http_auth_request_module 可以实现简单的登录后访问某资源的功能,并且身份验证和资源是分开的,具体介绍可参考 http://ohmycat.me/nginx/2016/06/28/nginx-ldap.htmlhttp_image_filter_module 模块则可以轻松实现实时缩略图、图片旋转等,可参考 https://ruby-china.org/topics/31498 了解。其他模块功能请自行 Google 吧。

OpenResty 继承了 Nginx 的高性能、反向代理等优点外,还支持使用 Lua 脚本编写动态逻辑代码,甚至是服务器端视图。这里我们说一下如何用 openresty 简单实现前面的新闻列表 api 的功能。

  1. 先在一个新建的目录中创建一个 Dockerfile 用来创建带有 postgres 访问能力的 openresty 镜像:

    FROM centos:7
    
    MAINTAINER Evan Wies <evan@neomantra.net>
    
    # Docker Build Arguments
    ARG RESTY_VERSION="1.11.2.3"
    ARG RESTY_LUAROCKS_VERSION="2.3.0"
    ARG RESTY_OPENSSL_VERSION="1.0.2k"
    ARG RESTY_PCRE_VERSION="8.39"
    ARG RESTY_J="1"
    ARG RESTY_CONFIG_OPTIONS="\
        --with-file-aio \
        --with-http_addition_module \
        --with-http_auth_request_module \
        --with-http_dav_module \
        --with-http_flv_module \
        --with-http_geoip_module=dynamic \
        --with-http_gunzip_module \
        --with-http_gzip_static_module \
        --with-http_image_filter_module=dynamic \
        --with-http_mp4_module \
        --with-http_random_index_module \
        --with-http_realip_module \
        --with-http_secure_link_module \
        --with-http_slice_module \
        --with-http_ssl_module \
        --with-http_stub_status_module \
        --with-http_sub_module \
        --with-http_v2_module \
        --with-http_xslt_module=dynamic \
        --with-ipv6 \
        --with-mail \
        --with-mail_ssl_module \
        --with-md5-asm \
        --with-pcre-jit \
        --with-sha1-asm \
        --with-stream \
        --with-stream_ssl_module \
        --with-threads \
        --with-http_postgres_module \
        "
    
    # These are not intended to be user-specified
    ARG _RESTY_CONFIG_DEPS="--with-openssl=/tmp/openssl-${RESTY_OPENSSL_VERSION} --with-pcre=/tmp/pcre-${RESTY_PCRE_VERSION}"
    
    # 1) Install yum dependencies
    # 2) Download and untar OpenSSL, PCRE, and OpenResty
    # 3) Build OpenResty
    # 4) Cleanup
    
    RUN \
        yum install -y \
            gcc \
            gcc-c++ \
            gd-devel \
            GeoIP-devel \
            libxslt-devel \
            make \
            perl \
            perl-ExtUtils-Embed \
            readline-devel \
            unzip \
            zlib-devel \
            postgresql-devel \
        && cd /tmp \
        && curl -fSL https://www.openssl.org/source/openssl-${RESTY_OPENSSL_VERSION}.tar.gz -o openssl-${RESTY_OPENSSL_VERSION}.tar.gz \
        && tar xzf openssl-${RESTY_OPENSSL_VERSION}.tar.gz \
        && curl -fSL https://ftp.pcre.org/pub/pcre/pcre-${RESTY_PCRE_VERSION}.tar.gz -o pcre-${RESTY_PCRE_VERSION}.tar.gz \
        && tar xzf pcre-${RESTY_PCRE_VERSION}.tar.gz \
        && curl -fSL https://openresty.org/download/openresty-${RESTY_VERSION}.tar.gz -o openresty-${RESTY_VERSION}.tar.gz \
        && tar xzf openresty-${RESTY_VERSION}.tar.gz \
        && cd /tmp/openresty-${RESTY_VERSION} \
        && ./configure -j${RESTY_J} ${_RESTY_CONFIG_DEPS} ${RESTY_CONFIG_OPTIONS} \
        && make -j${RESTY_J} \
        && make -j${RESTY_J} install \
        && cd /tmp \
        && rm -rf \
            openssl-${RESTY_OPENSSL_VERSION} \
            openssl-${RESTY_OPENSSL_VERSION}.tar.gz \
            openresty-${RESTY_VERSION}.tar.gz openresty-${RESTY_VERSION} \
            pcre-${RESTY_PCRE_VERSION}.tar.gz pcre-${RESTY_PCRE_VERSION} \
        && curl -fSL http://luarocks.org/releases/luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz -o luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz \
        && tar xzf luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz \
        && cd luarocks-${RESTY_LUAROCKS_VERSION} \
        && ./configure \
            --prefix=/usr/local/openresty/luajit \
            --with-lua=/usr/local/openresty/luajit \
            --lua-suffix=jit-2.1.0-beta2 \
            --with-lua-include=/usr/local/openresty/luajit/include/luajit-2.1 \
        && make build \
        && make install \
        && cd /tmp \
        && rm -rf luarocks-${RESTY_LUAROCKS_VERSION} luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz \
        && yum clean all \
        && ln -sf /dev/stdout /usr/local/openresty/nginx/logs/access.log \
        && ln -sf /dev/stderr /usr/local/openresty/nginx/logs/error.log
    
    # Add additional binaries into PATH for convenience
    ENV PATH=$PATH:/usr/local/openresty/luajit/bin/:/usr/local/openresty/nginx/sbin/:/usr/local/openresty/bin/
    
    ENTRYPOINT ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]
    

    用这个文件构建一个镜像(建议翻墙状态构建,你懂的):

    docker build -t openresty:postgres .
    
  2. 在另外一个新建的目录(假设名字叫 newsOR )中,新建一个 index.html 文件:

    <html ng-app="app">
    <head>
        <title>新闻列表</title>
        <link href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
    </head>
    <body>
        <div class="container" ng-controller="newsListCtrl">
            <h2>新闻列表</h2>
            <table class="table table-bordered">
                <thead>
                    <th>ID</th>
                    <th>标题</th>
                    <th>内容</th>
                </thead>
                <tbody>
                    <tr ng-repeat="item in list">
                        <td ng-bind="item.id"></td>
                        <td ng-bind="item.title"></td>
                        <td ng-bind="item.content"></td>
                    </tr>
                </tbody>
                </ul>
        </div>
        <script src="https://cdn.bootcss.com/angular.js/1.6.4/angular.min.js"></script>
        <script>
            angular.module('app', [])
                .controller('newsListCtrl', function ($scope, $http) {
                    $scope.list = [];
                    $http.get('/api/news').then(function (res) {
                        $scope.list = res.data;
                    });
                });
        </script>
    </body>
    </html>
    
  3. newsOR 目录中,创建一个 nginx.conf 文件:

    #user  nobody;
    worker_processes  1;
    
    events {
        worker_connections  1024;
    }
    
    http {
        include       mime.types;
        default_type  application/octet-stream;
        sendfile        on;
        keepalive_timeout  65;
    
        upstream pgsql {
            postgres_server pgdb:5432 dbname=hiDB password=123456 user=postgres;
            postgres_keepalive off;
        }
    
        server {
            listen       80;
            server_name  localhost;
            charset utf-8;
    
            location / {
                root /www;
                index index.html;
            }
    
            location /api/news {
                postgres_pass pgsql;
                rds_json on;
    
                postgres_query 'select * from news';
            }
        }
    }
    
  4. 在上一步创建的目录中,创建一个 Dockerfile 文件:

    FROM openresty:postgres
    ENV TZ=Asia/Shanghai
    COPY ./nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
    EXPOSE 80
    ENTRYPOINT ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]
    

    构建这个镜像:

    docker build -t news:or .
    
  5. 连接 pgdb 容器运行上一步构建的镜像:

    docker start pgdb
    docker run --rm -p 8000:80 --link pgdb:pgdb news:or
    
  6. 用浏览器访问 http://localhost:8000/api/news 你会看到跟之前我们访问 hi 服务的新闻列表 api 获得一样的 json 数据。但这里我们实际上只是在 nginx.conf 中加了几行配置就实现了。更加简单快速(虽然第一次准备支持 postgres 的 openresty 镜像有点慢,但是这个镜像是可以复用的,不必每次都重新构建)!访问 http://localhost:8000/ 可以看到通过这个 api 获取数据后绑定到前端页面列表

  7. 如果我们先把之前的 hi 服务容器 hiServ 运行起来,并把前面步骤中的 nginx.conf 中到 /api/news 的配置改为:

    proxy_set_header Content-Type application/json;
    proxy_pass http://hiServ:5000/api/news;
    

    然后再次构建 news:or 镜像,并重新运行这个镜像的容器,你会发现效果跟第 5 步一样。只是原来上是通过反向代理访问了 hiServ 上的新闻 api 而已。

  8. 至于如何使用 openresty 设置更复杂的反向代理、运行 Lua 脚本等细节,请移步到http://agentzh.org/misc/slides/ngx-openresty-ecosystem/#1 了解一些相关特性,也到https://moonbingbing.gitbooks.io/openresty-best-practices/content/ 查看相关最佳实践,一次学习终生受用。

用 shelljs 自动拉取代码构建镜像并推送 Slack 通知

你可能已经发现了,我们前面的内容中几乎所有的编译、发布、打包、容器的运行停止等都是用命令行来做的。原因就是命令行指令可以写成批处理脚本,脚本并不涉及鼠标点击等人工干预,所以就可以让机器自按照一些计划安排自动反复执行。我们在前面提到过的 xunit.netmocha 等测试框架甚至 gruntgulpwebpack 等 web 前端打包工具也都有对应的命令行(CLI)支持,所以也都可以很方便的自动化执行。

我们选择 shelljs 作为批处理工具的原因是使用熟悉的 javascript 语言写比其他有很多额外的好处,比如更丰富的基础类库、工具链、依赖包等。

至于 Slack 的介绍前面已经讲过,我们主要用它把机器和人之间的交互打通,让机器积极主动的告诉人或团队,一些代码发生了什么事情。

提示:

下面的例子代码可能仅兼容 MacOS 或 Linux,不支持 Windows

  1. 准备 Slack 集成的 WebHook:

    假设你已经在 Slack 上注册并创建了自己的团队 url 是 https://XXX.slack.com。那么请登录后访问 https://XXX.slack.com/apps/manage/custom-integrations,在 Incoming WebHooks configuration 中添加真对您某个频道(假设是 YYYY)的配置,此时会获得一个 WebHook 的 Url,我们假设这个 Url 是https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXXincoming-webhooks 的使用方式可以查看 https://api.slack.com/incoming-webhooks

  2. 切换到 hi 服务所在目录的父级目录中

  3. 新建一个用于推送 Slack 通知的 slack.sh 文件:

    channel=$1
    nickname=$2
    msg=$3
    
    data="payload={\"text\": \"${msg}\", \"channel\": \"#${channel}\", \"username\": \"${nickname}\", \"icon_emoji\": \":monkey_face:\"}"
    curl -X POST \
    --data-urlencode "${data}" \
    https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
    
  4. 新建一个 build.js

    var SECONDS_FOR_BUILD = 1 * 60; //两次构建之间间隔的秒数
    
    exec('clear');
    echo('每隔' + SECONDS_FOR_BUILD + '秒检查git代码是否更新');
    
    function build_dotnet(){
        cd('hi');
    
        echo('从git上拉取最新代码...');
        exec('git pull');
    
        echo('清理编译环境...'); //清理的目的是为了强制更新,可根据自己的实际需求决定是否需要这样做
        rm('-rf', './bin');
        rm('-rf', './obj');
        rm('-rf', './dist');
        exec('dotnet clear');
    
        echo('编译并发布...');
        exec('dotnet restore');
        var buildResult = exec('dotnet build -c Release');
        if (buildResult.code != 0) {
            echo('编译失败!');
            exec('sh slack.sh YYYY CSharp编译器 糟糕代码没有编译通过', { silent: true });
            return;
        }
        exec('dotnet publish -c Release -o dist');
    
        echo('构建Docker镜像...');
        exec('docker build -t hi .');
    
        //echo('推送镜像到私有Docker Registry')
        //exec('docker push xxx.xxxxx.xxx/hi'); //将 xxx.xxxxx.xxx 改为自己的私有Docker Registry地址
    
        echo('完成');
        exec('sh slack.sh YYYY 构建器 恭喜主人,新版本的镜像已经准备好了', { silent: true });
    
        cd('../');
    }
    
    build_dotnet(); //立即执行一次构建
    
    setInterval(function () {
        build_dotnet();
    }, SECONDS_FOR_BUILD * 1000);
    
  5. 运行命令:

    shjs build.js
    

    这个构建程序会每个一段时间(上面代码中为 60 秒)拉取最新代码,然后编译、发布、打包 docker 镜像、推送 docker 镜像等一系列动作。

    如果代码没有编译通过,你会在 Slack 中收到一个来自机器人 CSharp 编译器 的消息推送 糟糕代码没有编译通过;如果编译通过、打包好新的 Docker 镜像,推送至私有 Docker Registry上 后,你又会在 Slack 中收到一个来自机器人构建器的消息推送恭喜主人,新版本的镜像已经准备好了。是不是很酷呢?团队或项目主管可以第一时间通过 Slack 了解构建程序执行的情况。

  6. 生产环境中则可以用 build.js 类似的方式定期从私有 Docker Registry 上使用 docker pull xxx.xxxxx.xxx/hi 的命令拉取最新 hi 服务的 Docker 镜像,并用新的镜像替换旧的。甚至可以使用 docker-composedocker-swarm 等做 docker 的编排和集群更新。这里因为各自情况不同,老衣就不给例子代码了,你发挥一下自己的想象力和编程能力吧。

附录

  • 有基于 OpenResty 的国产 API 网关: Orange, 可以参考学习一下,因为中文文档的产品对国人来说太好了
  • .NET Core 领域的很多优秀库和项目可以参考 https://github.com/thangchung/awesome-dotnet-core
  • Actor Model (Actor 模型) 也可以实现一些场景的微服务,建议学习和了解一些成熟的框架(例如 AkkaAkka.NETOrleans 等);目前我比较喜欢的是 Proto.Actor
  • zeit 是一家微服务领域很神奇的公司,开源了不少不错的库,比如 pkg 可以把 nodejs 项目直接打包成可执行程序,而不必依赖 Node.js 环境;micro 轻松实现基于 Node.js 的异步 HTTP 微服务;hyper 构建在 Web 技术上的终端控制台; next.js 是在服务器端渲染 React 应用的框架。仔细学习研究一下会有很多收获的
  • Jint 是一个 .NET 版的 javascript 解释器,它提供了对 ECMA5.1 的完整兼容,并且可以运行在任何 .NET 平台上。由于它不会动态生成 .NET 代码,也不使用 DLR,所以它可以很快的运行相对较小的 js 脚本。
  • NLua 是绑定 .NET 世界和 Lua 世界的项目。它可以让你在 Windows, Linux, Mac, iOS , Android, Windows Phone 7 and 8 等几乎任何 C# 应用中实现 .NETLua 的相互调用。