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

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

Weex Clone是基于Tyepscript開(kāi)發(fā)的一套簡(jiǎn)單的點(diǎn)餐應(yīng)用凤薛。作為一個(gè)全棧開(kāi)發(fā)的完整實(shí)例角塑,這個(gè)應(yīng)用包括了基于Node.js和Koa框架的后端實(shí)現(xiàn)蝎土,也包含了基于Vue3開(kāi)發(fā)的前端工程盈咳。這個(gè)倉(cāng)庫(kù)是一個(gè)完整后端的實(shí)現(xiàn)痛阻,采用了AWS的Cognito作為用戶的鑒權(quán)(Authentication), 關(guān)系型數(shù)據(jù)庫(kù)是采用Postgre油够。除了代碼的實(shí)現(xiàn)扣草,也包括了完整的CI/CD的發(fā)布流程耸黑。后端系統(tǒng)默認(rèn)部署在Heroku沟突,但是也可以通過(guò)Github Action部署在AWS的EC2糜工,或自己搭建的VPS上。

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

構(gòu)建一個(gè)新的項(xiàng)目

初始化 npm 項(xiàng)目

  1. 建立項(xiàng)目目錄
mkdir node-koa2-typescript && cd node-koa2-typescript
  1. 初始化 npm 項(xiàng)目
npm init -y
  1. 初始化 git 倉(cāng)庫(kù)
git init
  1. 在項(xiàng)目 root 目錄下添加 ** .gitigonre ** 文件
# Dependency directory
node_modules

# Ignore built ts files
dist

用 typescript 創(chuàng)建一個(gè)簡(jiǎn)單的 Koa 服務(wù)器

  1. 安裝 typescript
    typescript 可以在系統(tǒng)的全局中安裝鬼悠,但是如果考慮 typescript 的版本兼容性問(wèn)題删性,建議安裝在項(xiàng)目中。

全局安裝

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 運(yùn)行依賴
npm i koa koa-router
  1. 安裝 Koa 類(lèi)型依賴
npm i @types/koa @types/koa-router --save-dev
  1. 創(chuàng)建一個(gè)簡(jiǎn)單 Koa 服務(wù)器, 在 src 目錄下創(chuàng)建 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. 測(cè)試
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 安裝在全局環(huán)境下
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"
  },

設(shè)置 ESLint 和 Prettier

  1. 安裝 eslint 和 typescript 相關(guān)依賴
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 配置文件
    可以通過(guò), 以交互的方式創(chuàng)建
npx eslint --init

但是建議手動(dòng)在項(xiàng)目的 root 目錄下創(chuàng)建這個(gè)文件

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 擴(kuò)展
    并點(diǎn)擊右下角激活 eslint 擴(kuò)展
    [站外圖片上傳中...(image-1bbeaf-1632233388327)]

  • 在 VSCode 中創(chuàng)建 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"
      ]
  }
}

重構(gòu)項(xiàng)目蹬挺,搭建MCV模型和本地開(kāi)發(fā)測(cè)試環(huán)境

拆分從網(wǎng)絡(luò)服務(wù)中拆分應(yīng)用

  1. 安裝 koa-bodyparser 和 koa-logger
npm i koa-bodyparser koa-logger
npm i @types/koa-bodyparser @types/koa-logger -D
  1. 關(guān)閉 typescript 嚴(yán)格檢查, 允許隱含 any, 否則 cognito.ts, typescript 編譯不過(guò)
// 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 下又包含各個(gè)模塊的子 router。
  2. 全局 router 路徑的前綴是 /api/{apiVersion}, 子模塊的前綴是 /{moduleName}, 拼接后的URL的路徑是 /api/{apiVersion}/{moduleName}
  3. 子模塊分為四個(gè)文件
item
├── controller.ts     // 處理網(wǎng)絡(luò)請(qǐng)求
├── index.ts          // export router 服務(wù)
├── router.ts         // 將網(wǎng)絡(luò)請(qǐng)求映射到控制器
└── service.ts        // 提供服務(wù), 對(duì)接數(shù)據(jù)庫(kù)
  1. api 目錄下的 index.ts 中的 wrapperRouter 作為 router 的 wrapper ** 動(dòng)態(tài) ** 加載 全局 router 的子路由它掂。(作為 typescript 下的子模塊, 動(dòng)態(tài)加載意義不大, 重構(gòu)成微服務(wù)或 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
}

動(dòng)態(tài)加載在production環(huán)境下讀取的是相同結(jié)構(gòu)的目錄, 但是目錄下多了一個(gè)后綴名是 '.map' 的文件要過(guò)濾掉, 否則運(yùn)行會(huì)報(bào)錯(cuò)

  1. 應(yīng)用啟動(dòng)封裝了異步的函數(shù), 在異步函數(shù)中初始化了數(shù)據(jù)庫(kù)連接巴帮,啟動(dòng)服務(wù)器
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()
  })
}

運(yùn)行時(shí)根據(jù)環(huán)境加載配置項(xiàng)

  1. 在 package.json 的腳本中通過(guò) NODE_ENV 定義環(huán)境 [development, test, staging, production]
  2. 安裝 dotenv 模塊, 該模塊通過(guò) NODE_ENV 讀取工程根目錄下對(duì)應(yīng)的 .env.[${NODE_ENV}] 文件, 加載該環(huán)境下的環(huán)境變量
  3. 按 topic[cognito, postgres, server, etc] 定義不同的配置, 并把 process.env.IS_ONLINE_PAYMENT 之類(lèi)字符串形式的轉(zhuǎn)成不同的變量類(lèi)型
  4. 可以把環(huán)境變量配置在三個(gè)不同的level
  • 運(yùn)行機(jī)器的 SHELL 環(huán)境變量中, 主要是定義環(huán)境的 NODE_ENV 和其他敏感的密碼和密鑰
  • 在以 .${NODE_ENV} 結(jié)尾的 .env 文件中
  • 最后匯總到 src/config 目錄下按 topic 區(qū)分的配置信息
  • 代碼的其他地方讀取的都是 src/config 下的配置信息

添加 @koa/cors 支持服務(wù)端跨域請(qǐng)求

axois 不直接支持 URL的 Path Variable, .get('/:phone', controller.getUser) 路由無(wú)法通過(guò) axois 直接訪問(wèn), 修改 getUsers, 通過(guò) Query Parameter 提取 phone

  1. Path parameters
    ** axois發(fā)出的請(qǐng)求不支持這種方式 **溯泣,可以在瀏覽器和Postman里測(cè)試這類(lèi)接口
https://localhost:3000/api/v1/addresses/24e583d5-c0ff-4170-8131-4c40c8b1e474

對(duì)應(yīng)的route是

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

下面的控制器里演示是如何取到參數(shù)的

  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
  }
  • 如何現(xiàn)在Postman里測(cè)試接口

    設(shè)置Content-Type為application/json
    [站外圖片上傳中...(image-7d1e6-1632233388327)]

設(shè)置Token
[站外圖片上傳中...(image-a2c055-1632233388327)]

  1. Query parameters
https://localhost:3000/api/v1/addresses?phone=%2B13233013227&id=24e583d5-c0ff-4170-8131-4c40c8b1e474
  1. Koa2 對(duì)應(yīng) Axios 返回的值, 以下是偽代碼
// Koa ctx: Context
interface ctx {
  status,
  body: {
    code,
    data,
    message
  }
}

// Axois response
interface response {
  status,
  data
}

response.data 對(duì)應(yīng) ctx.body, 所以在 Axios 獲取 response 后, 是從 response.data.data 得到最終的結(jié)果

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 開(kāi)發(fā)環(huán)境

  1. 在 node-koa2-typescript 項(xiàng)目的 root 目錄下創(chuàng)建 local-ssl 子目錄, 在 local-ssl 子目錄下,新建一個(gè)配置文件 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 目錄下創(chuàng)建本地證書(shū)和私鑰
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. 啟動(dòng) https 服務(wù)后榕茧,仍然報(bào)錯(cuò)
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 服務(wù)垃沦,也報(bào)錯(cuò)誤,下載證書(shū)用押,雙擊導(dǎo)入密鑰管理器肢簿,手動(dòng)讓證書(shū)受信
[站外圖片上傳中...(image-fc8b4e-1632233388327)]

提供數(shù)據(jù)庫(kù)服務(wù), 這里首先支持 Postgre, 之后支持 DynamoDB

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

  1. 通過(guò) docker 在部署 postgres, 在項(xiàng)目的根目錄下創(chuàng)建 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. 啟動(dòng) postgres
docker-compose up
  1. 在項(xiàng)目中添加 postgres 相關(guān)模塊
  "pg": "^8.6.0",
  "reflect-metadata": "^0.1.13",
  "typeorm": "^0.2.32",
  • pg: 是 postgres 引擎
  • typeorm: 數(shù)據(jù)庫(kù) mapping 到 Typescript 對(duì)象
  • reflect-metadata: 反射
  1. 用注釋方式改造 quboqin-lib-typescript 共享庫(kù)
  • 添加 對(duì)象映射模塊
  "typeorm": "^0.2.32",
  "uuid": "^8.3.2"
  • 用 TypeORM 改造 User 和 Order, 注意 OneToMany 的映射關(guān)系
@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,

  • 更新前后端項(xiàng)目的 quboqin-lib-typescript 共享庫(kù)
# 提交代碼, 更新 lib 的版本, 上傳 lib
npm verion patch
npm publish
# 更新 lib
npm update quboqin-lib-typescript
  • 修改配置連接數(shù)據(jù)庫(kù)
    • 連接數(shù)據(jù)庫(kù),運(yùn)行API server之前后掃描entity蜻拨,這里要注意填寫(xiě) PostgresConfig 中的 dbEntitiesPath池充。要區(qū)分 development 和 production 環(huán)境
      dbEntitiesPath: [
      ...(process.env.NODE_ENV === 'development'
        ? ['node_modules/quboqin-lib-typescript/lib/**/*.js']
        : ['dist/**/entity.js']),
    ],
    
    • 根據(jù)環(huán)境變量,連接數(shù)據(jù)庫(kù)
      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)
          }
        }
      }
    
    • 數(shù)據(jù)庫(kù)環(huán)境變量信息屬于敏感信息缎讼,除了 develeopment 寫(xiě)在了 .env.development 下收夸,其他部署在云端的,要在服務(wù)器上設(shè)置休涤,不能放在代碼里咱圆。
  1. 在本地建立測(cè)試數(shù)據(jù)庫(kù)
    jest 在測(cè)試用例初始化之前調(diào)用
beforeAll(async () => {
  await connection.create()
})

connection 的實(shí)現(xiàn)

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 默認(rèn)讀取項(xiàng)目 root 目錄下的 ormconfig.js 配置

create a postgres database on oracle CentOS8

  1. 參考How To Install and Use PostgreSQL on CentOS 8安裝

  2. 將端口 5432 在 Oracle 的 VPS 上映射到外網(wǎng)

  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. 遠(yuǎn)程連接 postgresql 的兩種方式
- 安裝 cli
```shell
brew install pgcli
pgcli postgres://username:password@host:5432/apidb
```
- 用 NaviCat
  1. 部署在 Oracle VPS 上的幾個(gè)問(wèn)題
  • 在 .bash_profile 中添加 DB_* 相關(guān)的環(huán)境變量無(wú)效手幢,是不是要 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 設(shè)置為 localhost 無(wú)法連接數(shù)據(jù)庫(kù)围来,報(bào)權(quán)限錯(cuò)誤
  • 數(shù)據(jù)對(duì)象定義在共享的 quboqin-lib-typescript 庫(kù)中跺涤,node_modules/quboqin-lib-typescript/lib/**/*.js 在 production 模式下也要添加到 dbEntitiesPath 中, 否者報(bào)如下錯(cuò)誤 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

搭建持續(xù)集成和持續(xù)部署環(huán)境

下面介紹三種不同的 CI/CD 流程
[站外圖片上傳中...(image-f38f15-1632233388327)]

部署到 CentOS 8 服務(wù)器上

在 Oracle Cloud 上申請(qǐng)一臺(tái)免費(fèi)的VPS

Oracle Cloud 可以最多申請(qǐng)兩臺(tái)免費(fèi)的低配置的 VPS,并贈(zèng)送一個(gè)月 400 新加坡元的 Credit监透。網(wǎng)上有很多申請(qǐng)的教程桶错,這里就不展開(kāi)了。我們也可以申請(qǐng)一臺(tái)阿里云胀蛮,騰訊云的VPS院刁,后面的安裝步驟基本一樣。當(dāng)然也可以自己搭建私有云粪狼,在家里搭建私有云主機(jī)退腥,配置比較麻煩,沒(méi)有固定 IP 的要有 DDNS 服務(wù)再榄,并要映射好多端口狡刘,這里不建議。申請(qǐng)成功后困鸥,我選擇安裝的服務(wù)器鏡像似乎 CentOS 8嗅蔬。

  1. 一般公有云沒(méi)有開(kāi)啟 root 賬號(hào),下面為了操作方便,ssh 登入上去開(kāi)啟 root 賬號(hào)和密碼登陸购城,方便后面管理
  • 刪除 ssh-rsa 前的所有內(nèi)容
    [站外圖片上傳中...(image-97fbe5-1632233388327)]
sudo -i
vi ~/.ssh/authorized_keys
  • 編輯 /etc/ssh/sshd_config, 開(kāi)啟 PermitRootLogin 和 PasswordAuthentication
vi /etc/ssh/sshd_config
PermitRootLogin yes
PasswordAuthentication yes
  • 重啟 ssh 服務(wù)
systemctl restart sshd
  • 添加 root 密碼
passwd
  1. 開(kāi)啟 Oraclee VPS 的端口吕座,關(guān)閉 CentOS 的防火墻
  • 進(jìn)入 VPS 的子網(wǎng)的 Security Lists,添加 Ingress Rules
    [站外圖片上傳中...(image-6eac16-1632233388327)]
  • 關(guān)閉 CentOS 的防火墻
sudo systemctl stop firewalld
sudo systemctl disable firewalld
  1. 以 root 身份登入 VPS瘪板,安裝配置 Nginx
    ** 這臺(tái) VPS 有兩個(gè)用途吴趴, 一個(gè)是作用 Node Server,一個(gè)作為靜態(tài)資源服務(wù)器侮攀。這里先安裝 Nginx 是為了之后的證書(shū)申請(qǐng) **
  • 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 申請(qǐng)免費(fèi)證書(shū)
  • 在 Cloudflare 下創(chuàng)建一條 A 記錄指向這臺(tái) 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. 編寫(xiě) Github Actions 腳本
  • 在 Oracle VPS上 創(chuàng)建目錄
mkdir -p api-server/test
mkdir -p api-server/staging
mkdir -p api-server/production
  • 在項(xiàng)目的根目錄下創(chuàng)建 .github/workflows/deploy-oracle.yaml 文件
    • 這里有三個(gè)但獨(dú)立的 jobs锣枝,分別用于部署三個(gè)獨(dú)立的環(huán)境在一臺(tái) VPS 上,用 port [3000, 3001, 3002] 分別對(duì)應(yīng) [test, staging, production] 三個(gè) api 服務(wù)兰英。
    • 這里的 Action 腳本負(fù)責(zé)撇叁,編譯,上傳服務(wù)器畦贸,并啟動(dòng)服務(wù)器腳本陨闹,比之后的 AWS 上的 Action 要多一步 CD 的工作,也就是說(shuō)薄坏,這里的腳本完成了 CI/CD 所有的工作趋厉。
    • 當(dāng)代碼提交到 oracle-[test, staging, production] 分支后,會(huì)自動(dòng)啟動(dòng) CI/CD 流程

部署到 Heroku

Heroku 是我們介紹的三種 CI/CD 流程中最簡(jiǎn)單的方式

  1. 創(chuàng)建一條 Pipeline, 在 Pipeline 下創(chuàng)建 staging 和 production 兩個(gè)應(yīng)用
    [站外圖片上傳中...(image-f38037-1632233388327)]
  2. 在 APP 的設(shè)置里關(guān)聯(lián) Github 上對(duì)應(yīng)的倉(cāng)庫(kù)和分支
    [站外圖片上傳中...(image-2506cb-1632233388327)]
  • APP staging 選擇 heroku-staging 分支
  • APP production 選擇 heroku-production 分支
  1. 為每個(gè) APP 添加 heroku/nodejs 編譯插件
heroku login -i
heroku buildpacks:add heroku/nodejs
  1. 設(shè)置運(yùn)行時(shí)的環(huán)境變量
    [站外圖片上傳中...(image-2fc083-1632233388327)]
    這里通過(guò) SERVER 這個(gè)運(yùn)行時(shí)的環(huán)境變量胶坠,告訴 index.ts 不要加載 https 的服務(wù)器君账, 而是用 http 的服務(wù)器。
    ** Heroku 的 API 網(wǎng)關(guān)自己已支持 https沈善,后端起的 node server 在內(nèi)網(wǎng)里是 http乡数, 所以要修改代碼 換成 http server,否者會(huì)報(bào) 503 錯(cuò)誤**
  2. 修改 index.ts 文件闻牡,在 Heroku 下改成 HTTP
  3. APP production 一般不需要走 CI/CD 的流程净赴,只要設(shè)置 NODE_ENV=production,然后在 APP staging 驗(yàn)證通過(guò)后罩润, promote 就可以完成快速部署玖翅。

部署到 Amazon EC2

[站外圖片上傳中...(image-1374a6-1632233388327)]

在 AWS 上搭建環(huán)境和創(chuàng)建用戶和角色

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

進(jìn)入 Console -> Code Deploy 確認(rèn)
[站外圖片上傳中...(image-feb3b7-1632233388327)]
[站外圖片上傳中...(image-d1cbac-1632233388327)]

創(chuàng)建 S3 Bucket

創(chuàng)建一個(gè)名為 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

完整的演示,應(yīng)該創(chuàng)建 staging 和 production 兩個(gè) EC2 實(shí)例哨啃,為了節(jié)省資源烧栋,這里只創(chuàng)建一個(gè)實(shí)例

  1. 創(chuàng)建一個(gè)具有訪問(wèn) S3 權(quán)限的角色 EC2RoleFetchS3
    [站外圖片上傳中...(image-740a6-1632233388327)]
  2. In this article, we will be selecting the Amazon Linux 2 AMI (HVM), SSD Volume Type.
    [站外圖片上傳中...(image-da91fc-1632233388328)]
  • 綁定上面創(chuàng)建的角色,并確認(rèn)開(kāi)啟80/22/3001/3002幾個(gè)端口
    [站外圖片上傳中...(image-ea2842-1632233388328)]
  • 添加 tag拳球,key 是 Name审姓,Value 是 production
    [站外圖片上傳中...(image-2934ce-1632233388328)]
  • 導(dǎo)入用于 ssh 遠(yuǎn)程登入的公鑰
    [站外圖片上傳中...(image-9fab5c-1632233388328)]
  • 通過(guò) ssh 遠(yuǎn)程登入 EC2 實(shí)例,安裝 CodeDeploy Agent
    安裝步驟詳見(jiàn) CodeDeploy Agent
  1. 通過(guò) ssh 安裝 Node.js 的運(yùn)行環(huán)境
  • 通過(guò) 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 的進(jìn)程
npm i pm2 -g

  • 在項(xiàng)目的根目錄下創(chuàng)建 ecosystem.config.js 文件
module.exports = {
  apps: [
    {
      script: './dist/index.js',
    },
  ],
}
  1. 在 EC2 的實(shí)例 ec2-user 賬號(hào)下設(shè)置環(huán)境變量, 編輯 ~/.bash_profile
export NODE_ENV=production
export SERVER=AWS

這里放置 NODE_ENV 和具有敏感信息的環(huán)境變量祝峻, 這里 SERVER=AWS 只是演示

  • Node server 是在運(yùn)行時(shí)動(dòng)態(tài)加載這些環(huán)境變量的魔吐,代碼里我采用了 dotenv 模塊來(lái)加載環(huán)境變量
const env = dotenv({ path: `.env.${process.env.NODE_ENV}` })

這里用到了 NODE_ENV 來(lái)決定加載哪個(gè) .env.production or .env.staging 文件

  • 在后端環(huán)境下設(shè)置 NODE_ENV 有一個(gè)副作用扎筒,在 Typescript 編譯打包前,
    ** 如果 NODE_ENV 設(shè)置為 production酬姆, npm ci 不會(huì)安裝 devDependencies 中的依賴包嗜桌,如果在運(yùn)行的 EC2 上編譯打包,編譯會(huì)報(bào)錯(cuò)辞色。所以打包編譯我放在了Github Actions 的容器中了骨宠,所以避免了這個(gè)問(wèn)題 **

    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 用來(lái)動(dòng)態(tài)判斷在哪個(gè)服務(wù)器上,去加載不同的證書(shū)
  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`),
    }
  }
創(chuàng)建用于 Github Actions 部署腳本的用戶組和權(quán)限
  1. 在 IAM 中創(chuàng)建以一個(gè) CodeDeployGroup 用戶組相满,并賦予 AmazonS3FullAccess and AWSCodeDeployFullAccess 權(quán)限
  2. 在 CodeDeployGroup 添加一個(gè) dev 用戶层亿,記錄下 Access key ID 和 Secret access key
    [站外圖片上傳中...(image-69b7c3-1632233388328)]

編寫(xiě) Github Actions 腳本

  1. 在工程的根目錄下創(chuàng)建 .github/workflows/deploy-ec2.yaml 文件
    deploy-ec2.yaml 的作用是,當(dāng)修改的代碼提交到 aws-staging 或 aws-production立美,觸發(fā)編譯匿又,打包,并上傳到 S3 的 node-koa2-typescript bucket, 然后再觸發(fā) CodeDeploy 完成后續(xù)的部署建蹄。所以這個(gè) Github Action 是屬于 CI 的角色碌更,后面的 CodeDeploy 是 CD 的角色。
  2. 在 Github 該項(xiàng)目的設(shè)置中添加 Environment secrets, 將剛才 dev 用戶的 Access key ID 和 Secret access key 添加進(jìn)Environment secrets
    [站外圖片上傳中...(image-3eb3e3-1632233388328)]

添加 appspec.yml 及相關(guān)腳本

CodeDeploy 從 S3 node-koa2-typescript bucket 中獲取最新的打包產(chǎn)物后洞慎,上傳到 EC2 實(shí)例痛单,解壓到對(duì)應(yīng)的目錄下,這里我們指定的是 /home/ec2-user/api-server拢蛋。CodeDeploy Agent 會(huì)找到該目錄下的 appspec.yml 文件執(zhí)行不同階段的 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 啟動(dòng)了 Node.js 的服務(wù)

#!/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 實(shí)例下安裝免費(fèi)的域名證書(shū)桦他,步驟詳見(jiàn)Certificate automation: Let's Encrypt with Certbot on Amazon Linux 2

  1. 去 Cloudflare 添加 A 記錄指向這臺(tái) EC2 實(shí)例蔫巩,指定二級(jí)域名是 aws-api
    [站外圖片上傳中...(image-48e92-1632233388328)]
  2. 安裝配置 Apache 服務(wù)器谆棱,用于證書(shū)認(rèn)證
  3. Install and run Certbot
sudo certbot -d aws-api.magicefire.com

根據(jù)提示操作,最后證書(shū)生成在 /etc/letsencrypt/live/aws-api.magicefire.com/ 目錄下

  1. 因?yàn)槲覀儐?dòng) Node 服務(wù)的賬號(hào)是 ec2-user圆仔, 而證書(shū)是 root 的權(quán)限創(chuàng)建的垃瞧,所以去 /etc/letsencrypt 給 live 和 archive 兩個(gè)目錄添加其他用戶的讀取權(quán)限
sudo -i
cd /etc/letsencrypt
chmod -R 755 live/
chmod -R 755 archive/
  1. Configure automated certificate renewal

部署和驗(yàn)證

  1. 如果沒(méi)有 aws-production 分支,先創(chuàng)建該分支坪郭,并切換到該分支下个从,合并修改的代碼,推送到Github
git checkout -b aws-production
git merge main
git push orign 
  1. 觸發(fā) Github Actions
    [站外圖片上傳中...(image-7c94aa-1632233388328)]

  2. 編譯歪沃,打包并上傳到 S3 后嗦锐,觸發(fā) CodeDeploy
    [站外圖片上傳中...(image-105140-1632233388328)]

  3. 完成后在瀏覽器里檢查
    [站外圖片上傳中...(image-3d8738-1632233388328)]
    或用 curl 在命令行下確認(rèn)

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

[站外圖片上傳中...(image-1cc28c-1632233388328)]

  1. 因?yàn)槲覀儧](méi)有創(chuàng)建 EC2 的 staging 實(shí)例,如果推送到 aws-staging 分支沪曙,CodeDeploy 會(huì)提示以下錯(cuò)誤
    [站外圖片上傳中...(image-4af8aa-1632233388328)]

過(guò)程回顧

[站外圖片上傳中...(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. 導(dǎo)入商品到線上 table
npm run import-goods-heroku-postgre
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
禁止轉(zhuǎn)載奕污,如需轉(zhuǎn)載請(qǐng)通過(guò)簡(jiǎn)信或評(píng)論聯(lián)系作者。
  • 序言:七十年代末液走,一起剝皮案震驚了整個(gè)濱河市碳默,隨后出現(xiàn)的幾起案子贾陷,更是在濱河造成了極大的恐慌,老刑警劉巖嘱根,帶你破解...
    沈念sama閱讀 221,635評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件髓废,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡该抒,警方通過(guò)查閱死者的電腦和手機(jī)慌洪,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)凑保,“玉大人蒋譬,你說(shuō)我怎么就攤上這事∮涫剩” “怎么了犯助?”我有些...
    開(kāi)封第一講書(shū)人閱讀 168,083評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)维咸。 經(jīng)常有香客問(wèn)我剂买,道長(zhǎng),這世上最難降的妖魔是什么癌蓖? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 59,640評(píng)論 1 296
  • 正文 為了忘掉前任瞬哼,我火速辦了婚禮,結(jié)果婚禮上租副,老公的妹妹穿的比我還像新娘坐慰。我一直安慰自己,他們只是感情好用僧,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,640評(píng)論 6 397
  • 文/花漫 我一把揭開(kāi)白布结胀。 她就那樣靜靜地躺著,像睡著了一般责循。 火紅的嫁衣襯著肌膚如雪糟港。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 52,262評(píng)論 1 308
  • 那天院仿,我揣著相機(jī)與錄音秸抚,去河邊找鬼。 笑死歹垫,一個(gè)胖子當(dāng)著我的面吹牛剥汤,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播排惨,決...
    沈念sama閱讀 40,833評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼吭敢,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了若贮?” 一聲冷哼從身側(cè)響起省有,我...
    開(kāi)封第一講書(shū)人閱讀 39,736評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤痒留,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后蠢沿,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體伸头,經(jīng)...
    沈念sama閱讀 46,280評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,369評(píng)論 3 340
  • 正文 我和宋清朗相戀三年舷蟀,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了恤磷。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,503評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡野宜,死狀恐怖扫步,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情匈子,我是刑警寧澤河胎,帶...
    沈念sama閱讀 36,185評(píng)論 5 350
  • 正文 年R本政府宣布,位于F島的核電站虎敦,受9級(jí)特大地震影響游岳,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜其徙,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,870評(píng)論 3 333
  • 文/蒙蒙 一胚迫、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧唾那,春花似錦访锻、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,340評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至昌罩,卻和暖如春哭懈,著一層夾襖步出監(jiān)牢的瞬間灾馒,已是汗流浹背茎用。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,460評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留睬罗,地道東北人轨功。 一個(gè)月前我還...
    沈念sama閱讀 48,909評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像容达,于是被迫代替她去往敵國(guó)和親古涧。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,512評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容