基于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)目
- 建立項(xiàng)目目錄
mkdir node-koa2-typescript && cd node-koa2-typescript
- 初始化 npm 項(xiàng)目
npm init -y
- 初始化 git 倉(cāng)庫(kù)
git init
- 在項(xiàng)目 root 目錄下添加 ** .gitigonre ** 文件
# Dependency directory
node_modules
# Ignore built ts files
dist
用 typescript 創(chuàng)建一個(gè)簡(jiǎn)單的 Koa 服務(wù)器
- 安裝 typescript
typescript 可以在系統(tǒng)的全局中安裝鬼悠,但是如果考慮 typescript 的版本兼容性問(wèn)題删性,建議安裝在項(xiàng)目中。
全局安裝
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 運(yùn)行依賴
npm i koa koa-router
- 安裝 Koa 類(lèi)型依賴
npm i @types/koa @types/koa-router --save-dev
- 創(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')
- 測(cè)試
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 安裝在全局環(huán)境下
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"
},
設(shè)置 ESLint 和 Prettier
- 安裝 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
- 添加 .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",
},
}
- 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 擴(kuò)展
并點(diǎn)擊右下角激活 eslint 擴(kuò)展
[站外圖片上傳中...(image-1bbeaf-1632233388327)]在 VSCode 中創(chuàng)建 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"
]
}
}
重構(gòu)項(xiàng)目蹬挺,搭建MCV模型和本地開(kāi)發(fā)測(cè)試環(huán)境
拆分從網(wǎng)絡(luò)服務(wù)中拆分應(yīng)用
- 安裝 koa-bodyparser 和 koa-logger
npm i koa-bodyparser koa-logger
npm i @types/koa-bodyparser @types/koa-logger -D
- 關(guān)閉 typescript 嚴(yán)格檢查, 允許隱含 any, 否則 cognito.ts, typescript 編譯不過(guò)
// 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 下又包含各個(gè)模塊的子 router。
- 全局 router 路徑的前綴是 /api/
{moduleName}, 拼接后的URL的路徑是 /api/
{moduleName}
- 子模塊分為四個(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ù)
- 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ò)
- 應(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)
- 在 package.json 的腳本中通過(guò) NODE_ENV 定義環(huán)境 [development, test, staging, production]
- 安裝 dotenv 模塊, 該模塊通過(guò) NODE_ENV 讀取工程根目錄下對(duì)應(yīng)的 .env.[${NODE_ENV}] 文件, 加載該環(huán)境下的環(huán)境變量
- 按 topic[cognito, postgres, server, etc] 定義不同的配置, 并把 process.env.IS_ONLINE_PAYMENT 之類(lèi)字符串形式的轉(zhuǎn)成不同的變量類(lèi)型
- 可以把環(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
- 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)]
- Query parameters
https://localhost:3000/api/v1/addresses?phone=%2B13233013227&id=24e583d5-c0ff-4170-8131-4c40c8b1e474
- 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)境
- 在 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
- 在 local-ssl 目錄下創(chuàng)建本地證書(shū)和私鑰
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)
- 啟動(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
- 通過(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
- 啟動(dòng) postgres
docker-compose up
- 在項(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
: 反射
- 用注釋方式改造 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è)置休涤,不能放在代碼里咱圆。
- 在本地建立測(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
將端口 5432 在 Oracle 的 VPS 上映射到外網(wǎng)
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
- 遠(yuǎn)程連接 postgresql 的兩種方式
- 安裝 cli
```shell
brew install pgcli
pgcli postgres://username:password@host:5432/apidb
```
- 用 NaviCat
- 部署在 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嗅蔬。
- 一般公有云沒(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
- 開(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
- 以 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
- 安裝 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 申請(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)]
- 編寫(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)單的方式
- 創(chuàng)建一條 Pipeline, 在 Pipeline 下創(chuàng)建 staging 和 production 兩個(gè)應(yīng)用
[站外圖片上傳中...(image-f38037-1632233388327)] - 在 APP 的設(shè)置里關(guān)聯(lián) Github 上對(duì)應(yīng)的倉(cāng)庫(kù)和分支
[站外圖片上傳中...(image-2506cb-1632233388327)]
- APP staging 選擇 heroku-staging 分支
- APP production 選擇 heroku-production 分支
- 為每個(gè) APP 添加 heroku/nodejs 編譯插件
heroku login -i
heroku buildpacks:add heroku/nodejs
- 設(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ò)誤** - 修改 index.ts 文件闻牡,在 Heroku 下改成 HTTP
- 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.
- 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
進(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í)例
- 創(chuàng)建一個(gè)具有訪問(wèn) S3 權(quán)限的角色 EC2RoleFetchS3
[站外圖片上傳中...(image-740a6-1632233388327)] - 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
- 通過(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',
},
],
}
- 在 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)限
- 在 IAM 中創(chuàng)建以一個(gè) CodeDeployGroup 用戶組相满,并賦予 AmazonS3FullAccess and AWSCodeDeployFullAccess 權(quán)限
- 在 CodeDeployGroup 添加一個(gè) dev 用戶层亿,記錄下 Access key ID 和 Secret access key
[站外圖片上傳中...(image-69b7c3-1632233388328)]
編寫(xiě) Github Actions 腳本
- 在工程的根目錄下創(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 的角色。 - 在 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
- 去 Cloudflare 添加 A 記錄指向這臺(tái) EC2 實(shí)例蔫巩,指定二級(jí)域名是 aws-api
[站外圖片上傳中...(image-48e92-1632233388328)] - 安裝配置 Apache 服務(wù)器谆棱,用于證書(shū)認(rèn)證
- Install and run Certbot
sudo certbot -d aws-api.magicefire.com
根據(jù)提示操作,最后證書(shū)生成在 /etc/letsencrypt/live/aws-api.magicefire.com/ 目錄下
- 因?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/
- Configure automated certificate renewal
部署和驗(yàn)證
- 如果沒(méi)有 aws-production 分支,先創(chuàng)建該分支坪郭,并切換到該分支下个从,合并修改的代碼,推送到Github
git checkout -b aws-production
git merge main
git push orign
觸發(fā) Github Actions
[站外圖片上傳中...(image-7c94aa-1632233388328)]編譯歪沃,打包并上傳到 S3 后嗦锐,觸發(fā) CodeDeploy
[站外圖片上傳中...(image-105140-1632233388328)]完成后在瀏覽器里檢查
[站外圖片上傳中...(image-3d8738-1632233388328)]
或用 curl 在命令行下確認(rèn)
curl https://aws-api.magicefire.com:3002/api/v1/health
[站外圖片上傳中...(image-1cc28c-1632233388328)]
- 因?yàn)槲覀儧](méi)有創(chuàng)建 EC2 的 staging 實(shí)例,如果推送到 aws-staging 分支沪曙,CodeDeploy 會(huì)提示以下錯(cuò)誤
[站外圖片上傳中...(image-4af8aa-1632233388328)]
過(guò)程回顧
[站外圖片上傳中...(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
- 導(dǎo)入商品到線上 table
npm run import-goods-heroku-postgre