基于Typescript的全栈工程模版 - 后端 Node.js + Koa2

基于Typescript的全栈工程模版 - 后端 Node.js + Koa2

Weex Clone是基于Tyepscript开发的一套简单的点餐应用。作为一个全栈开发的完整实例,这个应用包括了基于Node.js和Koa框架的后端实现,也包含了基于Vue3开发的前端工程。这个仓库是一个完整后端的实现,采用了AWS的Cognito作为用户的鉴权(Authentication), 关系型数据库是采用Postgre。除了代码的实现,也包括了完整的CI/CD的发布流程。后端系统默认部署在Heroku,但是也可以通过Github Action部署在AWS的EC2,或自己搭建的VPS上。

前端工程[基于 Typescript 的全栈工程模版 - 前端 Vue3]可以参考(https://github.com/quboqin/vue3-typescript)

构建一个新的项目

初始化 npm 项目

  1. 建立项目目录
mkdir node-koa2-typescript && cd node-koa2-typescript
  1. 初始化 npm 项目
npm init -y
  1. 初始化 git 仓库
git init
  1. 在项目 root 目录下添加 ** .gitigonre ** 文件
# Dependency directory
node_modules

# Ignore built ts files
dist

用 typescript 创建一个简单的 Koa 服务器

  1. 安装 typescript
    typescript 可以在系统的全局中安装,但是如果考虑 typescript 的版本兼容性问题,建议安装在项目中。

全局安装

npm install -g typescript@next

局部安装

npm install typescript@next --save-dev
  1. 初始化 tsconfig.json 配置
npx tsc --init --rootDir src --outDir dist \
--esModuleInterop --target esnext --lib esnext \
--module commonjs --allowJs true --noImplicitAny true \
--resolveJsonModule true --experimentalDecorators true --emitDecoratorMetadata true \
--sourceMap true --allowSyntheticDefaultImports true
  1. 安装 Koa 运行依赖
npm i koa koa-router
  1. 安装 Koa 类型依赖
npm i @types/koa @types/koa-router --save-dev
  1. 创建一个简单 Koa 服务器, 在 src 目录下创建 server.ts 文件
import Koa from 'koa'
import Router from 'koa-router'

const app = new Koa()
const router = new Router()

// Todo: if the path is '/*', the server will crash at lint 8
router.get('/', async (ctx) => {
  ctx.body = 'Hello World!'
})

app.use(router.routes())

app.listen(3000)

console.log('Server running on port 3000')
  1. 测试
tsc
node dist/server.js
Server running on port 3000
  1. Running the server with Nodemon and TS-Node,安装 ts-node 和 nodemon
npm i ts-node nodemon --save-dev
# 如果 typescript 安装在全局环境下
npm link typescript
  1. 修改 package.json 中的脚本
  "scripts": {
    "dev": "nodemon --watch 'src/**/*' -e ts,tsx --exec ts-node ./src/server.ts",
    "build": "rm -rf dist && tsc",
    "start": "node dist/server.js"
  },

设置 ESLint 和 Prettier

  1. 安装 eslint 和 typescript 相关依赖
npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev
  • eslint: The core ESLint linting library

  • @typescript-eslint/parser: The parser that will allow ESLint to lint TypeScript code

  • @typescript-eslint/eslint-plugin: A plugin that contains a bunch of ESLint rules that are TypeScript specific

  1. 添加 .eslintrc.js 配置文件
    可以通过, 以交互的方式创建
npx eslint --init

但是建议手动在项目的 root 目录下创建这个文件

module.exports = {
  parser: '@typescript-eslint/parser',  // Specifies the ESLint parser
  parserOptions: {
    ecmaVersion: 2020,  // Allows for the parsing of modern ECMAScript features
    sourceType: 'module', // Allows for the use of imports
  },
  extends: [
    'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
  ],
  rules: {
    // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
    // e.g. "@typescript-eslint/explicit-function-return-type": "off",
  },
}
  1. Adding Prettier to the mix
npm i prettier eslint-config-prettier eslint-plugin-prettier --save-dev
  • prettier: The core prettier library
  • eslint-config-prettier: Disables ESLint rules that might conflict with prettier
  • eslint-plugin-prettier: Runs prettier as an ESLint rule
  1. In order to configure prettier, a .prettierrc.js file is required at the root project directory. Here is a sample .prettierrc.js file:
module.exports = {
  tabWidth: 2,
  semi: false,
  singleQuote: true,
  trailingComma: 'all',
}

Next, the .eslintrc.js file needs to be updated:

  extends: [
    "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
    "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
    "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
  ],

** Error: "prettier/@typescript-eslint" has been merged into "prettier" in eslint-config-prettier 8.0.0. **
So I remove the second extend

  1. Automatically Fix Code in VS Code
  • 安装 eslint 扩展
    并点击右下角激活 eslint 扩展
    [站外图片上传中...(image-1bbeaf-1632233388327)]

  • 在 VSCode 中创建 Workspace 的配置

{
  "editor.formatOnSave": false,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
}
  1. Run ESLint with the CLI
  • A useful command to add to the package.json scripts is a lint command that will run ESLint.
{
  "scripts": {
    "lint": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix"
  }
}
  • 添加 .eslintignore
# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
dist
  1. Preventing ESLint and formatting errors from being committed
npm install husky lint-staged --save-dev

To ensure all files committed to git don't have any linting or formatting errors, there is a tool called lint-staged that can be used. lint-staged allows to run linting commands on files that are staged to be committed. When lint-staged is used in combination with husky, the linting commands specified with lint-staged can be executed to staged files on pre-commit (if unfamiliar with git hooks, read about them here).

To configure lint-staged and husky, add the following configuration to the package.json file:

{
  "husky": {
      "hooks": {
          "pre-commit": "lint-staged"
      }
  },
  "lint-staged": {
      "*.{js,ts,tsx}": [
          "eslint --fix"
      ]
  }
}

重构项目,搭建MCV模型和本地开发测试环境

拆分从网络服务中拆分应用

  1. 安装 koa-bodyparser 和 koa-logger
npm i koa-bodyparser koa-logger
npm i @types/koa-bodyparser @types/koa-logger -D
  1. 关闭 typescript 严格检查, 允许隐含 any, 否则 cognito.ts, typescript 编译不过
// Todo
    "strict": false,                           /* Enable all strict type-checking options. */
    "noImplicitAny": false,
  1. 将 server.ts 拆成 app.ts 和 index.ts
  • app.ts
import Koa from 'koa'
import Router from 'koa-router'

import logger from 'koa-logger'
import bodyParser from 'koa-bodyparser'

const app = new Koa()
const router = new Router()
app.use(logger())
app.use(bodyParser())

// Todo: if the path is '/*', the server will crash at the next line
router.get('/', async (ctx) => {
  ctx.body = 'Hello World!'
})

app.use(router.routes())

export default app
  • index.ts
import * as http from 'http'

import app from './app'

const HOST = '0.0.0.0'
const HTTP_PORT = 3000

const listeningReporter = function (): void {
  const { address, port } = this.address()
  const protocol = this.addContext ? 'https' : 'http'
  console.log(`Listening on ${protocol}://${address}:${port}...`)
}

http.createServer(app.callback()).listen(HTTP_PORT, HOST, listeningReporter)

按模块拆分router, 建立 MVC Model

  1. 全局的 router 作为 app 的中间件, 全局的 router 下又包含各个模块的子 router。
  2. 全局 router 路径的前缀是 /api/{apiVersion}, 子模块的前缀是 /{moduleName}, 拼接后的URL的路径是 /api/{apiVersion}/{moduleName}
  3. 子模块分为四个文件
item
├── controller.ts     // 处理网络请求
├── index.ts          // export router 服务
├── router.ts         // 将网络请求映射到控制器
└── service.ts        // 提供服务, 对接数据库
  1. api 目录下的 index.ts 中的 wrapperRouter 作为 router 的 wrapper ** 动态 ** 加载 全局 router 的子路由。(作为 typescript 下的子模块, 动态加载意义不大, 重构成微服务或 Serverless 才是正解)
export default function wrapperRouter(isProtected: boolean): Router {
  const router = new Router({
    prefix: `/api/${apiVersion}`,
  })

  const subFolder = path.resolve(
    __dirname,
    isProtected ? './protected' : './unprotected',
  )

  // Require all the folders and create a sub-router for each feature api
  fs.readdirSync(subFolder).forEach((file) => {
    import(path.join(subFolder, file)).then((module) => {
      router.use(module.router().routes())
    })
  })

  return router
}

动态加载在production环境下读取的是相同结构的目录, 但是目录下多了一个后缀名是 '.map' 的文件要过滤掉, 否则运行会报错

  1. 应用启动封装了异步的函数, 在异步函数中初始化了数据库连接,启动服务器
try {
  ;(async function (): Promise<void> {
    await initializePostgres()

    const HTTP_PORT = serverConfig.port
    await bootstrap(+HTTP_PORT)

    logger.info(`🚀 Server listening on port ${HTTP_PORT}!`)
  })()
} catch (error) {
  setImmediate(() => {
    logger.error(
      `Unable to run the server because of the following error: ${error}`,
    )
    process.exit()
  })
}

运行时根据环境加载配置项

  1. 在 package.json 的脚本中通过 NODE_ENV 定义环境 [development, test, staging, production]
  2. 安装 dotenv 模块, 该模块通过 NODE_ENV 读取工程根目录下对应的 .env.[${NODE_ENV}] 文件, 加载该环境下的环境变量
  3. 按 topic[cognito, postgres, server, etc] 定义不同的配置, 并把 process.env.IS_ONLINE_PAYMENT 之类字符串形式的转成不同的变量类型
  4. 可以把环境变量配置在三个不同的level
  • 运行机器的 SHELL 环境变量中, 主要是定义环境的 NODE_ENV 和其他敏感的密码和密钥
  • 在以 .${NODE_ENV} 结尾的 .env 文件中
  • 最后汇总到 src/config 目录下按 topic 区分的配置信息
  • 代码的其他地方读取的都是 src/config 下的配置信息

添加 @koa/cors 支持服务端跨域请求

axois 不直接支持 URL的 Path Variable, .get('/:phone', controller.getUser) 路由无法通过 axois 直接访问, 修改 getUsers, 通过 Query Parameter 提取 phone

  1. Path parameters
    ** axois发出的请求不支持这种方式 **,可以在浏览器和Postman里测试这类接口
https://localhost:3000/api/v1/addresses/24e583d5-c0ff-4170-8131-4c40c8b1e474

对应的route是

  router
    .get('/:id', controller.getAddress)

下面的控制器里演示是如何取到参数的

  public static async getAddress(ctx: Context): Promise<void> {
    const { id } = ctx.params
    const address: Address = await postgre.getAddressById(id)

    const body = new Body()
    body.code = ERROR_CODE.NO_ERROR

    if (address) {
      body.data = address
    } else {
      body.code = ERROR_CODE.UNKNOW_ERROR
      body.message = `the card you are trying to retrieve doesn't exist in the db`
    }

    ctx.status = 200
    ctx.body = body
  }
  • 如何现在Postman里测试接口

    设置Content-Type为application/json
    [站外图片上传中...(image-7d1e6-1632233388327)]

设置Token
[站外图片上传中...(image-a2c055-1632233388327)]

  1. Query parameters
https://localhost:3000/api/v1/addresses?phone=%2B13233013227&id=24e583d5-c0ff-4170-8131-4c40c8b1e474
  1. Koa2 对应 Axios 返回的值, 以下是伪代码
// Koa ctx: Context
interface ctx {
  status,
  body: {
    code,
    data,
    message
  }
}

// Axois response
interface response {
  status,
  data
}

response.data 对应 ctx.body, 所以在 Axios 获取 response 后, 是从 response.data.data 得到最终的结果

function get<T, U>(path: string, params: T): Promise<U> {
  return new Promise((resolve, reject) => {
    axios
      .get(path, {
        params: params,
      })
      .then((response) => {
        resolve(response.data.data)
      })
      .catch((error) => {
        reject(error)
      })
  })
}

搭建本地 HTTPS 开发环境

  1. 在 node-koa2-typescript 项目的 root 目录下创建 local-ssl 子目录, 在 local-ssl 子目录下,新建一个配置文件 req.cnf
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
C = US
ST = California
L = Folsom
O = MyCompany
OU = Dev Department
CN = www.localhost.com
[v3_req]
keyUsage = critical, digitalSignature, keyAgreement
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = www.localhost.com
DNS.2 = localhost.com
DNS.3 = localhost
  1. 在 local-ssl 目录下创建本地证书和私钥
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout cert.key -out cert.pem -config req.cnf -sha256
  1. 修改 server 端代码支持本地 https
  if (config.server === SERVER.HEROKU) {
    return http.createServer(app.callback()).listen(port, HOST)
  } else if (config.server === SERVER.LOCALHOST) {
    httpsOptions = {
      key: fs.readFileSync(certPaths[config.server].key),
      cert: fs.readFileSync(certPaths[config.server].cert),
    }
  } else {
    httpsOptions = {
      key: fs.readFileSync(certPaths[config.server].key),
      cert: fs.readFileSync(certPaths[config.server].cert),
      ca: fs.readFileSync(certPaths[config.server].ca ?? ''),
    }
  }

  return https.createServer(httpsOptions, app.callback()).listen(port, HOST)
  1. 启动 https 服务后,仍然报错
curl https://localhost:3000/api/v1/users
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above

在浏览器链接该 https 服务,也报错误,下载证书,双击导入密钥管理器,手动让证书受信
[站外图片上传中...(image-fc8b4e-1632233388327)]

提供数据库服务, 这里首先支持 Postgre, 之后支持 DynamoDB

create a postgres database by docker-compose.yml on localhost

  1. 通过 docker 在部署 postgres, 在项目的根目录下创建 docker-compose.yml
services:
  db:
    image: postgres:10-alpine
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: apidb
  admin:
    image: adminer
    restart: always
    depends_on:
      - db
    ports:
      - 8081:8080
  1. 启动 postgres
docker-compose up
  1. 在项目中添加 postgres 相关模块
  "pg": "^8.6.0",
  "reflect-metadata": "^0.1.13",
  "typeorm": "^0.2.32",
  • pg: 是 postgres 引擎
  • typeorm: 数据库 mapping 到 Typescript 对象
  • reflect-metadata: 反射
  1. 用注释方式改造 quboqin-lib-typescript 共享库
  • 添加 对象映射模块
  "typeorm": "^0.2.32",
  "uuid": "^8.3.2"
  • 用 TypeORM 改造 User 和 Order, 注意 OneToMany 的映射关系
@Entity()
export class User {
  @PrimaryColumn()
  phone: string

  @Column({ nullable: true })
  email?: string

  @Column()
  firstName: string

  @Column()
  lastName: string

  @Column({ type: 'enum', enum: UserGender, default: UserGender.UNKNOWN })
  gender: UserGender

  @Column({ nullable: true })
  avatorUrl?: string

  @Column({ type: 'bigint', default: new Date().getTime() })
  registerAt?: number

  @Column({ type: 'bigint', default: new Date().getTime() })
  lastLoginAt?: number

  @Column({ default: '' })
  defaultCard?: string

  @OneToMany(() => Card, (card) => card.owner, {
    cascade: true,
    eager: true,
    nullable: true,
  })
  cards?: Card[]

  @Column({ default: '' })
  defaultAddress?: string

  @OneToMany(() => Address, (address) => address.owner, {
    cascade: true,
    eager: true,
    nullable: true,
  })
  addresses?: Address[]

  @OneToMany(() => Order, (order) => order.owner, {
    cascade: true,
    eager: true,
    nullable: true,
  })
  orders?: Order[]
}

要添加cascade: true,

  • 更新前后端项目的 quboqin-lib-typescript 共享库
# 提交代码, 更新 lib 的版本, 上传 lib
npm verion patch
npm publish
# 更新 lib
npm update quboqin-lib-typescript
  • 修改配置连接数据库
    • 连接数据库,运行API server之前后扫描entity,这里要注意填写 PostgresConfig 中的 dbEntitiesPath。要区分 development 和 production 环境
      dbEntitiesPath: [
      ...(process.env.NODE_ENV === 'development'
        ? ['node_modules/quboqin-lib-typescript/lib/**/*.js']
        : ['dist/**/entity.js']),
    ],
    
    • 根据环境变量,连接数据库
      export async function initializePostgres(): Promise<Connection | void> {
        if (postgresConfig) {
          try {
            return createConnection({
              type: 'postgres',
              url: postgresConfig.databaseUrl,
              ssl: postgresConfig.dbsslconn
                ? { rejectUnauthorized: false }
                : postgresConfig.dbsslconn,
              synchronize: true,
              logging: false,
              entities: postgresConfig.dbEntitiesPath,
            })
          } catch (error) {
            logger.error(error)
          }
        }
      }
    
    • 数据库环境变量信息属于敏感信息,除了 develeopment 写在了 .env.development 下,其他部署在云端的,要在服务器上设置,不能放在代码里。
  1. 在本地建立测试数据库
    jest 在测试用例初始化之前调用
beforeAll(async () => {
  await connection.create()
})

connection 的实现

import { createConnection, getConnection, Connection } from 'typeorm'

const connection = {
  async create(): Promise<Connection> {
    return await createConnection()
  },

  async close(): Promise<void> {
    await getConnection().close()
  },

  async clear(): Promise<void> {
    const connection = getConnection()
    const entities = connection.entityMetadatas

    try {
      for await (const entity of entities) {
        const queryRunner = connection.createQueryRunner()
        await queryRunner.getTable(entity.name.toLowerCase())
        await queryRunner.dropTable(entity.name.toLowerCase())
      }
    } catch (error) {
      console.log(error)
    }
  },
}
export default connection

createConnection 默认读取项目 root 目录下的 ormconfig.js 配置

create a postgres database on oracle CentOS8

  1. 参考How To Install and Use PostgreSQL on CentOS 8安装

  2. 将端口 5432 在 Oracle 的 VPS 上映射到外网

  3. Connecting to a Remote PostgreSQL Database,参考Connecting to a Remote PostgreSQL Database

  • 编辑 vi /var/lib/pgsql/data/pg_hba.conf 文件,添加这行在最后
host all all 0.0.0.0/0 md5
  • 编辑 vi /var/lib/pgsql/data/postgresql.conf,修改
listen_addresses = '*'
  • 重启 postgres
systemctl start postgresql
  1. 远程连接 postgresql 的两种方式
- 安装 cli
```shell
brew install pgcli
pgcli postgres://username:password@host:5432/apidb
```
- 用 NaviCat
  1. 部署在 Oracle VPS 上的几个问题
  • 在 .bash_profile 中添加 DB_* 相关的环境变量无效,是不是要 reboot ?最后在 Github Actions 中添加 Actions Secrets 才有效!
  script: |
    export NODE_ENV=production
    export SERVER=ORACLE
    export DB_HOST=${{ secrets.DB_HOST }}
    export DB_PORT=${{ secrets.DB_PORT }}
    export DB_USER=${{ secrets.DB_USER }}
    export DB_PASS=${{ secrets.DB_PASS }}
    export DB_NAME=${{ secrets.DB_NAME }}
    cd ${{ secrets.DEPLOY_ORACLE }}/production
    pm2 delete $NODE_ENV
    pm2 start dist/index.js --name $NODE_ENV
  • DB_HOST 设置为 localhost 无法连接数据库,报权限错误
  • 数据对象定义在共享的 quboqin-lib-typescript 库中,node_modules/quboqin-lib-typescript/lib/**/*.js 在 production 模式下也要添加到 dbEntitiesPath 中, 否者报如下错误 RepositoryNotFoundError: No repository for "User" was found. Looks like this entity is not registered in current "default" connection?
  dbEntitiesPath: [
  ...(process.env.NODE_ENV === 'development'
    ? ['node_modules/quboqin-lib-typescript/lib/**/*.js']
    : [
        'node_modules/quboqin-lib-typescript/lib/**/*.js',
        'dist/**/entity.js',
      ]),
],

DynamoDB

Local

docker run -v data:/data -p 8000:8000 dwmkerr/dynamodb -dbPath /data -sharedDb

搭建持续集成和持续部署环境

下面介绍三种不同的 CI/CD 流程
[站外图片上传中...(image-f38f15-1632233388327)]

部署到 CentOS 8 服务器上

在 Oracle Cloud 上申请一台免费的VPS

Oracle Cloud 可以最多申请两台免费的低配置的 VPS,并赠送一个月 400 新加坡元的 Credit。网上有很多申请的教程,这里就不展开了。我们也可以申请一台阿里云,腾讯云的VPS,后面的安装步骤基本一样。当然也可以自己搭建私有云,在家里搭建私有云主机,配置比较麻烦,没有固定 IP 的要有 DDNS 服务,并要映射好多端口,这里不建议。申请成功后,我选择安装的服务器镜像似乎 CentOS 8。

  1. 一般公有云没有开启 root 账号,下面为了操作方便,ssh 登入上去开启 root 账号和密码登陆,方便后面管理
  • 删除 ssh-rsa 前的所有内容
    [站外图片上传中...(image-97fbe5-1632233388327)]
sudo -i
vi ~/.ssh/authorized_keys
  • 编辑 /etc/ssh/sshd_config, 开启 PermitRootLogin 和 PasswordAuthentication
vi /etc/ssh/sshd_config
PermitRootLogin yes
PasswordAuthentication yes
  • 重启 ssh 服务
systemctl restart sshd
  • 添加 root 密码
passwd
  1. 开启 Oraclee VPS 的端口,关闭 CentOS 的防火墙
  • 进入 VPS 的子网的 Security Lists,添加 Ingress Rules
    [站外图片上传中...(image-6eac16-1632233388327)]
  • 关闭 CentOS 的防火墙
sudo systemctl stop firewalld
sudo systemctl disable firewalld
  1. 以 root 身份登入 VPS,安装配置 Nginx
    ** 这台 VPS 有两个用途, 一个是作用 Node Server,一个作为静态资源服务器。这里先安装 Nginx 是为了之后的证书申请 **
  • Install the nginx package with:
dnf install nginx
  • After the installation is finished, run the following commands to enable and start the server:
systemctl enable nginx
systemctl start nginx
  1. 安装 Node.js
  • First, we will need to make sure all of our packages are up to date:
dnf update
  • Next, we will need to run the following NVM installation script. This will install the latest version of NVM from GitHub.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash
source ~/.bash_profile
nvm list-remote
nvm install 14
  1. 全局安装 PM2
npm i pm2 -g
  1. 用 Let's Encrypt 申请免费证书
  • 在 Cloudflare 下创建一条 A 记录指向这台 VPS
    [站外图片上传中...(image-95f4cc-1632233388327)]
  • To add the CentOS 8 EPEL repository, run the following command:
dnf install epel-release
  • Install all of the required packages
dnf install certbot python3-certbot-nginx
  • 正式获取
certbot certonly -w /usr/share/nginx/html -d api.magicefire.com

[站外图片上传中...(image-6014ff-1632233388327)]
[站外图片上传中...(image-10ce27-1632233388327)]

  1. 编写 Github Actions 脚本
  • 在 Oracle VPS上 创建目录
mkdir -p api-server/test
mkdir -p api-server/staging
mkdir -p api-server/production
  • 在项目的根目录下创建 .github/workflows/deploy-oracle.yaml 文件
    • 这里有三个但独立的 jobs,分别用于部署三个独立的环境在一台 VPS 上,用 port [3000, 3001, 3002] 分别对应 [test, staging, production] 三个 api 服务。
    • 这里的 Action 脚本负责,编译,上传服务器,并启动服务器脚本,比之后的 AWS 上的 Action 要多一步 CD 的工作,也就是说,这里的脚本完成了 CI/CD 所有的工作。
    • 当代码提交到 oracle-[test, staging, production] 分支后,会自动启动 CI/CD 流程

部署到 Heroku

Heroku 是我们介绍的三种 CI/CD 流程中最简单的方式

  1. 创建一条 Pipeline, 在 Pipeline 下创建 staging 和 production 两个应用
    [站外图片上传中...(image-f38037-1632233388327)]
  2. 在 APP 的设置里关联 Github 上对应的仓库和分支
    [站外图片上传中...(image-2506cb-1632233388327)]
  • APP staging 选择 heroku-staging 分支
  • APP production 选择 heroku-production 分支
  1. 为每个 APP 添加 heroku/nodejs 编译插件
heroku login -i
heroku buildpacks:add heroku/nodejs
  1. 设置运行时的环境变量
    [站外图片上传中...(image-2fc083-1632233388327)]
    这里通过 SERVER 这个运行时的环境变量,告诉 index.ts 不要加载 https 的服务器, 而是用 http 的服务器。
    ** Heroku 的 API 网关自己已支持 https,后端起的 node server 在内网里是 http, 所以要修改代码 换成 http server,否者会报 503 错误**
  2. 修改 index.ts 文件,在 Heroku 下改成 HTTP
  3. APP production 一般不需要走 CI/CD 的流程,只要设置 NODE_ENV=production,然后在 APP staging 验证通过后, promote 就可以完成快速部署。

部署到 Amazon EC2

[站外图片上传中...(image-1374a6-1632233388327)]

在 AWS 上搭建环境和创建用户和角色

CodeDeploy

We'll be using CodeDeploy for this setup so we have to create a CodeDeploy application for our project and two deployment groups for the application. One for staging and the other for production.

  1. To create the api-server CodeDeploy application using AWS CLI, we run this on our terminal:
aws deploy create-application \
--application-name api-server \
--compute-platform Server
  1. Before we run the cli command to create the service role, we need to create a file with IAM specifications for the role, copy the content below into it and name it code-deploy-trust.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "codedeploy.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
  1. We can now create the role by running:
aws iam create-role \
--role-name CodeDeployServiceRole \
--assume-role-policy-document file://code-deploy-trust.json
  1. After the role is created we attach the AWSCodeDeployRole policy to the role
aws iam attach-role-policy \
--role-name CodeDeployServiceRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole
  1. To create a deployment group we would be needing the service role ARN.
aws iam get-role \
--role-name CodeDeployServiceRole \
--query "Role.Arn" \
--output text

The ARN should look something like arn:aws:iam::403593870368:role/CodeDeployServiceRole

  1. Let's go on to create a deployment group for the staging and production environments.
aws deploy create-deployment-group \
--application-name api-server \
--deployment-group-name staging \
--service-role-arn arn:aws:iam::403593870368:role/CodeDeployServiceRole \
--ec2-tag-filters Key=Name,Value=staging,Type=KEY_AND_VALUE
aws deploy create-deployment-group \
--application-name api-server \
--deployment-group-name production \
--service-role-arn arn:aws:iam::403593870368:role/CodeDeployServiceRole \
--ec2-tag-filters Key=Name,Value=production,Type=KEY_AND_VALUE

进入 Console -> Code Deploy 确认
[站外图片上传中...(image-feb3b7-1632233388327)]
[站外图片上传中...(image-d1cbac-1632233388327)]

创建 S3 Bucket

创建一个名为 node-koa2-typescript 的 S3 Bucket

aws s3api create-bucket --bucket node-koa2-typescript --region ap-northeast-1

[站外图片上传中...(image-daa663-1632233388327)]

Create and Launch EC2 instance

完整的演示,应该创建 staging 和 production 两个 EC2 实例,为了节省资源,这里只创建一个实例

  1. 创建一个具有访问 S3 权限的角色 EC2RoleFetchS3
    [站外图片上传中...(image-740a6-1632233388327)]
  2. In this article, we will be selecting the Amazon Linux 2 AMI (HVM), SSD Volume Type.
    [站外图片上传中...(image-da91fc-1632233388328)]
  • 绑定上面创建的角色,并确认开启80/22/3001/3002几个端口
    [站外图片上传中...(image-ea2842-1632233388328)]
  • 添加 tag,key 是 Name,Value 是 production
    [站外图片上传中...(image-2934ce-1632233388328)]
  • 导入用于 ssh 远程登入的公钥
    [站外图片上传中...(image-9fab5c-1632233388328)]
  • 通过 ssh 远程登入 EC2 实例,安装 CodeDeploy Agent
    安装步骤详见 CodeDeploy Agent
  1. 通过 ssh 安装 Node.js 的运行环境
  • 通过 NVM 安装 Node.js
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash
source ~/.bash_profile
nvm list-remote
nvm install 14
  • 安装 PM2 管理 node 的进程
npm i pm2 -g

  • 在项目的根目录下创建 ecosystem.config.js 文件
module.exports = {
  apps: [
    {
      script: './dist/index.js',
    },
  ],
}
  1. 在 EC2 的实例 ec2-user 账号下设置环境变量, 编辑 ~/.bash_profile
export NODE_ENV=production
export SERVER=AWS

这里放置 NODE_ENV 和具有敏感信息的环境变量, 这里 SERVER=AWS 只是演示

  • Node server 是在运行时动态加载这些环境变量的,代码里我采用了 dotenv 模块来加载环境变量
const env = dotenv({ path: `.env.${process.env.NODE_ENV}` })

这里用到了 NODE_ENV 来决定加载哪个 .env.production or .env.staging 文件

  • 在后端环境下设置 NODE_ENV 有一个副作用,在 Typescript 编译打包前,
    ** 如果 NODE_ENV 设置为 production, npm ci 不会安装 devDependencies 中的依赖包,如果在运行的 EC2 上编译打包,编译会报错。所以打包编译我放在了Github Actions 的容器中了,所以避免了这个问题 **

    We have installed all our packages using the --save-dev flag. In production, if we use npm install --production these packages will be skipped.
  • SERVER=AWS 用来动态判断在哪个服务器上,去加载不同的证书
  if (config.server === SERVER.HEROKU) {
    return http.createServer(app.callback()).listen(HTTP_PORT, HOST)
  } else if (config.server === SERVER.AWS) {
    httpsOptions = {
      key: fs.readFileSync(
        `/etc/letsencrypt/live/aws-api.magicefire.com/privkey.pem`,
      ),
      cert: fs.readFileSync(
        `/etc/letsencrypt/live/aws-api.magicefire.com/cert.pem`,
      ),
      ca: fs.readFileSync(
        `/etc/letsencrypt/live/aws-api.magicefire.com/chain.pem`,
      ),
    }
  } else {
    httpsOptions = {
      key: fs.readFileSync(
        `/etc/letsencrypt/live/api.magicefire.com/privkey.pem`,
      ),
      cert: fs.readFileSync(
        `/etc/letsencrypt/live/api.magicefire.com/cert.pem`,
      ),
      ca: fs.readFileSync(`/etc/letsencrypt/live/api.magicefire.com/chain.pem`),
    }
  }
创建用于 Github Actions 部署脚本的用户组和权限
  1. 在 IAM 中创建以一个 CodeDeployGroup 用户组,并赋予 AmazonS3FullAccess and AWSCodeDeployFullAccess 权限
  2. 在 CodeDeployGroup 添加一个 dev 用户,记录下 Access key ID 和 Secret access key
    [站外图片上传中...(image-69b7c3-1632233388328)]

编写 Github Actions 脚本

  1. 在工程的根目录下创建 .github/workflows/deploy-ec2.yaml 文件
    deploy-ec2.yaml 的作用是,当修改的代码提交到 aws-staging 或 aws-production,触发编译,打包,并上传到 S3 的 node-koa2-typescript bucket, 然后再触发 CodeDeploy 完成后续的部署。所以这个 Github Action 是属于 CI 的角色,后面的 CodeDeploy 是 CD 的角色。
  2. 在 Github 该项目的设置中添加 Environment secrets, 将刚才 dev 用户的 Access key ID 和 Secret access key 添加进Environment secrets
    [站外图片上传中...(image-3eb3e3-1632233388328)]

添加 appspec.yml 及相关脚本

CodeDeploy 从 S3 node-koa2-typescript bucket 中获取最新的打包产物后,上传到 EC2 实例,解压到对应的目录下,这里我们指定的是 /home/ec2-user/api-server。CodeDeploy Agent 会找到该目录下的 appspec.yml 文件执行不同阶段的 Hook 脚本

version: 0.0
os: linux
files:
  - source: .
    destination: /home/ec2-user/api-server
hooks:
  AfterInstall:
    - location: aws-ec2-deploy-scripts/after-install.sh
      timeout: 300
      runas: ec2-user
  ApplicationStart:
    - location: aws-ec2-deploy-scripts/application-start.sh
      timeout: 300
      runas: ec2-user

aws-ec2-deploy-scripts/application-start.sh 启动了 Node.js 的服务

#!/usr/bin/env bash
source /home/ec2-user/.bash_profile
cd /home/ec2-user/api-server/
pm2 delete $NODE_ENV
pm2 start ecosystem.config.js --name $NODE_ENV

在 EC2 实例下安装免费的域名证书,步骤详见Certificate automation: Let's Encrypt with Certbot on Amazon Linux 2

  1. 去 Cloudflare 添加 A 记录指向这台 EC2 实例,指定二级域名是 aws-api
    [站外图片上传中...(image-48e92-1632233388328)]
  2. 安装配置 Apache 服务器,用于证书认证
  3. Install and run Certbot
sudo certbot -d aws-api.magicefire.com

根据提示操作,最后证书生成在 /etc/letsencrypt/live/aws-api.magicefire.com/ 目录下

  1. 因为我们启动 Node 服务的账号是 ec2-user, 而证书是 root 的权限创建的,所以去 /etc/letsencrypt 给 live 和 archive 两个目录添加其他用户的读取权限
sudo -i
cd /etc/letsencrypt
chmod -R 755 live/
chmod -R 755 archive/
  1. Configure automated certificate renewal

部署和验证

  1. 如果没有 aws-production 分支,先创建该分支,并切换到该分支下,合并修改的代码,推送到Github
git checkout -b aws-production
git merge main
git push orign 
  1. 触发 Github Actions
    [站外图片上传中...(image-7c94aa-1632233388328)]

  2. 编译,打包并上传到 S3 后,触发 CodeDeploy
    [站外图片上传中...(image-105140-1632233388328)]

  3. 完成后在浏览器里检查
    [站外图片上传中...(image-3d8738-1632233388328)]
    或用 curl 在命令行下确认

curl https://aws-api.magicefire.com:3002/api/v1/health

[站外图片上传中...(image-1cc28c-1632233388328)]

  1. 因为我们没有创建 EC2 的 staging 实例,如果推送到 aws-staging 分支,CodeDeploy 会提示以下错误
    [站外图片上传中...(image-4af8aa-1632233388328)]

过程回顾

[站外图片上传中...(image-58719a-1632233388328)]

在 Heroku 上部署 Postgres

  1. Provisioning Heroku Postgres
heroku addons
heroku addons:create heroku-postgresql:hobby-dev
  1. Sharing Heroku Postgres between applications
heroku addons:attach my-originating-app::DATABASE --app staging-api-node-server
  1. 导入商品到线上 table
npm run import-goods-heroku-postgre
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容