前言
本文以 koa 框架為例,從0到1 搭建一個後端服務,涵蓋了後端服務的基本內容,實現 api 的功能。
適合人群:
- 未完整搭建過node服務的人
- 學寫了koa, 想實踐的人
- 正在使用 koa 搭建 node 服務的人
正文
為了實現 api 介面請求,我們考慮幾個問題:
- 整個服務程式的處理流程與錯誤處理
- 介面路由
- 介面呼叫許可權
- 介面快取
- 訪問資料庫
除了服務程式本身,還要考慮工程相關的(本文不展開講):
- 日誌處理
- 監控報警
- 快速恢復
認識一下常用中介軟體
request 引數解析外掛
引數分3種:
- url search
- url parameter
- POST body
koa-bodyparser: 把request body 上的資料掛載到 ctx.request.body, 支援 json / text / xml / form (不支援 multipart)
檔案快取相關
koa-static: 靜態檔案系統, 支援
maxage
、gzip
等屬性。這個中介軟體配合下面的中介軟體更好用:- 搭配 koa-conditional-get 做新鮮度檢測 和 配合 koa-etag 做協商快取
- 搭配 koa-mount 做路徑控制,比如訪問 /public 時候才去返回檔案內容
- koa-mount:多個子應用合成一個父應用。(也可以用作為通過 path 控制 middleware 的掛載使用 )
koa-conditional-get : 讓協商快取生效(304 判定)
- koa-etag: 支援 etag/ if-none-match 協商快取
介面快取
介面快取需要配合 Redis 來做,實現介面快取記憶體。
Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache, and message broker.
這裡用到了一個 Node 端使用的 npm: ioredis
同樣需要搭配 koa-conditional-get
和 koa-etag
實現整套快取流程。
使用快取 demo,主要知識點:
快取設定:
if (ttl) { ctx.response.set('Cache-Control', `max-age=${ttl}`); } else { ctx.response.set('Cache-Control', 'no-store'); }
生成 redis key: method + url + request body
const key = `spacex-cache:${hash(`${method}${url}${JSON.stringify(ctx.request.body)}`)}`;
Http 安全性
koa-helmet: helment 通過設定 Http 頭來使應用程式更加安全。
參考:https://juejin.cn/post/684490...
CORS
koa-cors: CORS(跨域資源訪問)設定
跨域資源訪問幾個關鍵的 header 設定:
- Access-Control-Allow-Credentials
- Access-Control-Allow-Origin
- Access-Control-Allow-Headers
- Access-Control-Allow-Methods
- Access-Control-Max-Age
debug
- koa-pino-logger: logger middleware
登入設計
使用 token 還是 session-cookie?
- token: 重計算,輕儲存
- session: 重儲存,輕計算
詳細瞭解戳這裡>> 我們這裡採用 token 驗證為例。
token 實現
token需要滿足的條件
- 唯一ID,代表獨一無二的使用者賬號
- 有效期,失效後需要重新登入,用於保護使用者賬號
簡陋的實現
- 通過UUID 實現唯一ID
- 通過 Redis 快取有效期來等效 token 有效期
優雅的實現
使用 jsonwebtoken(JWT): https://github.com/auth0/node...
特點:
- 加密/解密 機制
- 生成唯一ID
- 可支援有效期設定
舉個? :
服務端生成 token:
const token = jwt.sign(
{ // 加密引數
username:'myName',
password:'myPassword'
},
'MY_SECRET', // 密碼
{
expiresIn: 60 * 60 // 設定有效期
}
);
token:類似 :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsZGFwIjoiemh1YmVubGVpIiwicGFzc3dvcmQiOiJ6MTM2NjU0Mjc4NzEiLCJpYXQiOjE2NDM0NTEzNDQsImV4cCI6MTY0NDA1NjE0NH0.1aewCZmMIkQWoJiZWmdobcPwGY7BuPzWMygf3aw7Z6g
服務端解析token:
const decoded = jwt.verify(token,'MY_SECRET');
console.log(decoded);
// 輸出結果
// {
// username:'myName',
// password:'myPassword'
// }
我們發現token 還可以自帶引數,這可以省去將使用者資訊存在資料庫的步驟,只是在計算的時候需要消耗效能。
應用裡的實現:
- 首先,得到新 token 之後儲存到本地
- 每次請求在 x-access-token 裡攜帶 token
// config.js
export const LOCAL_KEY = `${你的域名}-token`; // 這樣避免重複
// request.js
const axiosInstance: AxiosInstance = axios.create(requestConfig);
axiosInstance.interceptors.request.use(config => {
config.headers['x-access-token'] = localStorage.getItem(LOCAL_KEY) || ''; // 帶上 token(不要把這個設定放到axios.create 裡面: 不會實時更新)
// 在傳送請求之前做些什麼
return config;
}, error => {
// 對請求錯誤做些什麼
return Promise.reject(error);
});
...
總結
這一小節介紹了服務端 token
的生成、解析和前端http 請求實戰中的使用。
關於使用者資訊的設計
使用者資訊的設計要區分使用者許可權設計
使用者資訊
使用者資訊是更加通用的資訊,只包含純粹的使用者本身資訊,比如:使用者名稱、ldap、密碼、頭像這種。
使用者表設計:
username | ldap | password | avatar |
---|---|---|---|
張三 | zhangsan01 | z123456 | https://avatar.com/z123456 |
李四 | lisil000 | l123456 | https://avatar.com/l000 |
使用者許可權
使用者許可權則是在使用者資訊上的加強,每個使用者都關聯著一系列許可權。使用者許可權可以認為是業務側的實現,資訊更豐富,業務更重。
許可權表設計:
ldap | product | permission |
---|---|---|
zhangsan01 | product | 13 |
zhangsan01 | product | 21 |
lisi | product | 17 |
使用者身份生效/失效 機制
生效:
- 登陸的時候重新生成token
- 修改了密碼的時候重新生成token
失效:
- 退出登陸的時候
- token過期
後續處理:
- 重新登陸之後檢查重定向地址進行跳轉
- 修改密碼和失效時候 要引導到重新登入
退出登陸之後
- 對於 token存在本地的方式,直接刪除本地 token 即可,然後會進行步驟2
- 對於session 方式,則需要讓 sessionId 失效
介面設計
介面設計
這裡不展開講,可以參考 Restful Api
介面驗證
- 哪些介面需要驗證使用者身份,如何驗證
- 哪些不需要驗證,如何跳過驗證
- 獲取使用者資訊之後如何在一次事務之中傳遞使用者資訊
Auth 講解
demo中的auth戳這裡>> 這個例子用在了路由裡面,我們將採用另一個方法,寫在最外層,實現按需校驗。這樣可以避免每個用到的地方都引入這個中介軟體。
koa-unless : Conditionally skip a middleware when a condition is met.
auth middle file:
var verifyToken = async(ctx, next) => {
const req = ctx.request;
const token = req.body.token || req.query.token || req.headers["x-access-token"];
};
if (!token) {
ctx.body = {
code: 0,
err_code: 401,
err_msg:'401'
};
}else{
try {
const decoded = jwt.verify(token, TOKEN_KEY);
req.user = decoded; // 掛載資料
await next();
} catch (err) {
ctx.body = {
code: 0,
err_code: 401,
err_msg:'401'
}
}
}
};
verifyToken.unless = require('koa-unless');
module.exports = verifyToken;
app.js
const verifyToken = require('middleware/auth');
...
// 身份驗證
app.use(verifyToken.unless({
path: [ // 設定不使用 auth 中介軟體的 path
/\/login/, // 登入使用的介面
],
}));
// 進入路由處理
app.use(routes());
...
使用者身份傳遞:
module.exports = async (ctx, next) => {
const key = ctx.request.headers['spacex-key'];
if (key) {
const user = await db.collection('users').findOne({ key });
if (user?.key === key) {
ctx.state.roles = user.roles; // 掛載到 ctx.state上,傳遞到後面的中介軟體
await next();
return;
}
}
ctx.status = 401;
ctx.body = 'https://youtu.be/RfiQYRn7fBg';
};
路由設計
需要考慮
- Restful 設計方法
- 沒有許可權的處理
- 介面結構&報錯資訊設計
使用路由
- 分模組管理 api,入口檔案整體匯出
- 使用了 auth middleware
- 使用了
ORM
語法和 modlel 【本文有涉及】 - 使用
Redis
做 介面快取
資料庫連線(MYSQL 版)
第一版: 使用 koa-mysql手寫SQL語句
問題是:需要自己抽象 sql 語法。因為sql 語句根據功能可以抽象(比如抽象 條件查詢),如果全部手寫會寫的比較多。
// from: https://chenshenhai.github.io/koa2-note/note/mysql/info.html
const mysql = require('mysql')
// 建立資料池
const pool = mysql.createPool({
host : '127.0.0.1', // 資料庫地址
user : 'root', // 資料庫使用者
password : '123456' // 資料庫密碼
database : 'my_database' // 選中資料庫
})
// 在資料池中進行會話操作
pool.getConnection(function(err, connection) {
connection.query('SELECT * FROM my_table', (error, results, fields) => {
// 結束會話
connection.release();
// 如果有錯誤就丟擲
if (error) throw error;
})
})
第二版: 使用 sequlize ( orm)
什麼是 ORM
? ORM 就是通過例項物件的語法,完成關係型資料庫的操作的技術。代表有: sequelize
/ openrecord
/ typeorm
缺點:
- 效能問題 -> 不寫特別複雜或者特殊的sql可以不用考慮這個問題
- 物件導向方式寫SQL,總感覺怪怪的。 -> 習慣問題
資料庫資訊儲存【作者未解決】
- 使用者名稱和密碼儲存:怎麼安全的存起來,在使用時不暴露密碼?
- 資料庫許可權問題: 連線管理員還是普通使用者?
整體順序
處理跨域
限制跨域的好處:
- 防止在別的網站被呼叫,控制請求量
- 防止使用介面工具呼叫
- 配合使用者身份驗證可以進一步控制請求量(沒有賬戶的不能訪問)
// 檢查 referer, 防止 postman 這種呼叫
app.use(async (ctx, next) => {
const { referer = ''} = ctx.header;
if(ENV === 'production' && !referer.includes(HOST)){
ctx.response.body = 'Not Allow Origin Request';
}else{
await next();
}
})
// cors
app.use(cors({
origin:(ctx) => { // 設定可訪問這個服務的來源域
return ENV === 'development' ? 'http://127.0.0.1:8080' : 'https://www.xxx.com';
},
credentials: true,
allowMethods: ['GET', 'POST', 'PUT','PATCH', 'DELETE'],
allowHeaders: [
'Content-Type',
'Accept',
],
}));
app.js
// 1. 建立 app
app = new Koa();
//2. 載入輔助中介軟體
app.use(conditional());
app.use(etag());
app.use(bodyParser());
app.use(helmet());
... // 其他中介軟體
// 3. 域名檢查
app.use(referer()) // referer 驗證
app.use(cors());
// 4. 使用者身份檢查
app.use(verifyToken.unless({
path: [
/\/login/
],
}));
// 5. 進入路由
app.use(routes());
// 0. 監聽 port
app.listen(PORT, () => {
console.log(`port ${PORT} is listening ~`);
});
錯誤處理
- uncaughtException
- unhandledRejection
// gracefulShutdown: 關機程式。可以理解為遇到錯誤時候的統一處理
// Server start
app.on('ready', () => {
SERVER.listen(PORT, '0.0.0.0', () => {
logger.info(`Running on port: ${PORT}`);
// Handle kill commands
process.on('SIGTERM', gracefulShutdown);
// Handle interrupts
process.on('SIGINT', gracefulShutdown);
// Prevent dirty exit on uncaught exceptions:
process.on('uncaughtException', gracefulShutdown);
// Prevent dirty exit on unhandled promise rejection
process.on('unhandledRejection', gracefulShutdown);
});
});
參考: https://github.com/r-spacex/S... SpaceX程式碼
error middleware: https://sourcegraph.com/githu...
logger middle (用於 debug) : https://sourcegraph.com/githu...
部署到遠端伺服器
伺服器服務快速恢復: pm2部署
優勢:
- 監聽檔案變化,自動重啟程式
- 支援效能監控【也重要】
- 負載均衡
- 程式崩潰自動重啟【重要】
- 伺服器重新啟動時自動重新啟動【重要】
- 自動化部署專案
自動部署到遠端伺服器
伺服器條件:
- 服務程式碼克隆到
/data/server/crm-server
下,這樣只需要每次git pull
即可。 - 安裝 node
- 全域性安裝 PM2
name: 服務部署
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: executing remote ssh commands using password
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORD }}
port: ${{ secrets.PORT }}
script: |
cd /data/server/crm-server
git checkout main
git pull
# 如果你的遠端伺服器是用nvm裝的node,需要下面的 export
export PATH=$PATH:/home/ubuntu/.nvm/versions/node/v16.5.0/bin
pm2 link 你的pm2連結 # 【可選】新增 pm2監控
pm2 restart start.sh # 啟動 pm2
start.sh 最終執行:
$ cross-env PORT=8080 ENV=production node app.js
ngix 配置【可選】
我這裡是把前端檔案(/data/www資料夾下)和後端API都部署到了同一臺伺服器上,以 http://www.ddup.info 為例:
server {
...
location ^~ /crm/api {
proxy_pass http://www.ddup.info:8080;
}
location ^~ /crm {
root /data/www;
index index.html index.htm;
try_files $uri $uri/ /crm/index.html;
}
location / {
root /data/www;
index index.html index.htm;
try_files $uri /app/index.html;
}
}
思考:一個合理的後端工程結構
- 參考
express
目錄結構: https://github.com/expressjs/... - 參考下
egg
目錄結構: https://eggjs.org/zh-cn/basic...
目錄結構:
- 靜態檔案:static
- view層:ejs 模版
- 資料模型: models✅
- 服務: service
- 路由: routes✅
- 中介軟體: middleware✅
- 定時任務: jobs ✅
後記
初次嘗試,難免有考慮不周之處,還請讀者指出來,一起學習進步 ~