基于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 项目
- 建立项目目录
mkdir node-koa2-typescript && cd node-koa2-typescript
- 初始化 npm 项目
npm init -y
- 初始化 git 仓库
git init
- 在项目 root 目录下添加 ** .gitigonre ** 文件
# Dependency directory
node_modules
# Ignore built ts files
dist
用 typescript 创建一个简单的 Koa 服务器
- 安装 typescript
typescript 可以在系统的全局中安装,但是如果考虑 typescript 的版本兼容性问题,建议安装在项目中。
全局安装
npm install -g typescript@next
局部安装
npm install typescript@next --save-dev
- 初始化 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
- 安装 Koa 运行依赖
npm i koa koa-router
- 安装 Koa 类型依赖
npm i @types/koa @types/koa-router --save-dev
- 创建一个简单 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')
- 测试
tsc
node dist/server.js
Server running on port 3000
- Running the server with Nodemon and TS-Node,安装 ts-node 和 nodemon
npm i ts-node nodemon --save-dev
# 如果 typescript 安装在全局环境下
npm link typescript
- 修改 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
- 安装 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
- 添加 .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",
},
}
- 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
- 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
- Automatically Fix Code in VS Code
安装 eslint 扩展
并点击右下角激活 eslint 扩展
[站外图片上传中...(image-1bbeaf-1632233388327)]在 VSCode 中创建 Workspace 的配置
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
- 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
- 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模型和本地开发测试环境
拆分从网络服务中拆分应用
- 安装 koa-bodyparser 和 koa-logger
npm i koa-bodyparser koa-logger
npm i @types/koa-bodyparser @types/koa-logger -D
- 关闭 typescript 严格检查, 允许隐含 any, 否则 cognito.ts, typescript 编译不过
// Todo
"strict": false, /* Enable all strict type-checking options. */
"noImplicitAny": false,
- 将 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
- 全局的 router 作为 app 的中间件, 全局的 router 下又包含各个模块的子 router。
- 全局 router 路径的前缀是 /api/{moduleName}, 拼接后的URL的路径是 /api/{moduleName}
- 子模块分为四个文件
item
├── controller.ts // 处理网络请求
├── index.ts // export router 服务
├── router.ts // 将网络请求映射到控制器
└── service.ts // 提供服务, 对接数据库
- 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' 的文件要过滤掉, 否则运行会报错
- 应用启动封装了异步的函数, 在异步函数中初始化了数据库连接,启动服务器
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()
})
}
运行时根据环境加载配置项
- 在 package.json 的脚本中通过 NODE_ENV 定义环境 [development, test, staging, production]
- 安装 dotenv 模块, 该模块通过 NODE_ENV 读取工程根目录下对应的 .env.[${NODE_ENV}] 文件, 加载该环境下的环境变量
- 按 topic[cognito, postgres, server, etc] 定义不同的配置, 并把 process.env.IS_ONLINE_PAYMENT 之类字符串形式的转成不同的变量类型
- 可以把环境变量配置在三个不同的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
- 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)]
- Query parameters
https://localhost:3000/api/v1/addresses?phone=%2B13233013227&id=24e583d5-c0ff-4170-8131-4c40c8b1e474
- 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 开发环境
- 在 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
- 在 local-ssl 目录下创建本地证书和私钥
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout cert.key -out cert.pem -config req.cnf -sha256
- 修改 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)
- 启动 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
- 通过 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
- 启动 postgres
docker-compose up
- 在项目中添加 postgres 相关模块
"pg": "^8.6.0",
"reflect-metadata": "^0.1.13",
"typeorm": "^0.2.32",
-
pg
: 是 postgres 引擎 -
typeorm
: 数据库 mapping 到 Typescript 对象 -
reflect-metadata
: 反射
- 用注释方式改造 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 下,其他部署在云端的,要在服务器上设置,不能放在代码里。
- 在本地建立测试数据库
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
将端口 5432 在 Oracle 的 VPS 上映射到外网
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
- 远程连接 postgresql 的两种方式
- 安装 cli
```shell
brew install pgcli
pgcli postgres://username:password@host:5432/apidb
```
- 用 NaviCat
- 部署在 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。
- 一般公有云没有开启 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
- 开启 Oraclee VPS 的端口,关闭 CentOS 的防火墙
- 进入 VPS 的子网的 Security Lists,添加 Ingress Rules
[站外图片上传中...(image-6eac16-1632233388327)] - 关闭 CentOS 的防火墙
sudo systemctl stop firewalld
sudo systemctl disable firewalld
- 以 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
- 安装 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
- 全局安装 PM2
npm i pm2 -g
- 用 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)]
- 编写 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 流程中最简单的方式
- 创建一条 Pipeline, 在 Pipeline 下创建 staging 和 production 两个应用
[站外图片上传中...(image-f38037-1632233388327)] - 在 APP 的设置里关联 Github 上对应的仓库和分支
[站外图片上传中...(image-2506cb-1632233388327)]
- APP staging 选择 heroku-staging 分支
- APP production 选择 heroku-production 分支
- 为每个 APP 添加 heroku/nodejs 编译插件
heroku login -i
heroku buildpacks:add heroku/nodejs
- 设置运行时的环境变量
[站外图片上传中...(image-2fc083-1632233388327)]
这里通过 SERVER 这个运行时的环境变量,告诉 index.ts 不要加载 https 的服务器, 而是用 http 的服务器。
** Heroku 的 API 网关自己已支持 https,后端起的 node server 在内网里是 http, 所以要修改代码 换成 http server,否者会报 503 错误** - 修改 index.ts 文件,在 Heroku 下改成 HTTP
- 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.
- 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
- 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"
}
]
}
- We can now create the role by running:
aws iam create-role \
--role-name CodeDeployServiceRole \
--assume-role-policy-document file://code-deploy-trust.json
- 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
- 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
- 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 实例,为了节省资源,这里只创建一个实例
- 创建一个具有访问 S3 权限的角色 EC2RoleFetchS3
[站外图片上传中...(image-740a6-1632233388327)] - 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
- 通过 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',
},
],
}
- 在 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 部署脚本的用户组和权限
- 在 IAM 中创建以一个 CodeDeployGroup 用户组,并赋予 AmazonS3FullAccess and AWSCodeDeployFullAccess 权限
- 在 CodeDeployGroup 添加一个 dev 用户,记录下 Access key ID 和 Secret access key
[站外图片上传中...(image-69b7c3-1632233388328)]
编写 Github Actions 脚本
- 在工程的根目录下创建 .github/workflows/deploy-ec2.yaml 文件
deploy-ec2.yaml 的作用是,当修改的代码提交到 aws-staging 或 aws-production,触发编译,打包,并上传到 S3 的 node-koa2-typescript bucket, 然后再触发 CodeDeploy 完成后续的部署。所以这个 Github Action 是属于 CI 的角色,后面的 CodeDeploy 是 CD 的角色。 - 在 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
- 去 Cloudflare 添加 A 记录指向这台 EC2 实例,指定二级域名是 aws-api
[站外图片上传中...(image-48e92-1632233388328)] - 安装配置 Apache 服务器,用于证书认证
- Install and run Certbot
sudo certbot -d aws-api.magicefire.com
根据提示操作,最后证书生成在 /etc/letsencrypt/live/aws-api.magicefire.com/ 目录下
- 因为我们启动 Node 服务的账号是 ec2-user, 而证书是 root 的权限创建的,所以去 /etc/letsencrypt 给 live 和 archive 两个目录添加其他用户的读取权限
sudo -i
cd /etc/letsencrypt
chmod -R 755 live/
chmod -R 755 archive/
- Configure automated certificate renewal
部署和验证
- 如果没有 aws-production 分支,先创建该分支,并切换到该分支下,合并修改的代码,推送到Github
git checkout -b aws-production
git merge main
git push orign
触发 Github Actions
[站外图片上传中...(image-7c94aa-1632233388328)]编译,打包并上传到 S3 后,触发 CodeDeploy
[站外图片上传中...(image-105140-1632233388328)]完成后在浏览器里检查
[站外图片上传中...(image-3d8738-1632233388328)]
或用 curl 在命令行下确认
curl https://aws-api.magicefire.com:3002/api/v1/health
[站外图片上传中...(image-1cc28c-1632233388328)]
- 因为我们没有创建 EC2 的 staging 实例,如果推送到 aws-staging 分支,CodeDeploy 会提示以下错误
[站外图片上传中...(image-4af8aa-1632233388328)]
过程回顾
[站外图片上传中...(image-58719a-1632233388328)]
在 Heroku 上部署 Postgres
- Provisioning Heroku Postgres
heroku addons
heroku addons:create heroku-postgresql:hobby-dev
- Sharing Heroku Postgres between applications
heroku addons:attach my-originating-app::DATABASE --app staging-api-node-server
- 导入商品到线上 table
npm run import-goods-heroku-postgre