node專案從0到1實戰

specialCoder發表於2022-02-15

前言

本文以 koa 框架為例,從0到1 搭建一個後端服務,涵蓋了後端服務的基本內容,實現 api 的功能。

適合人群:

  1. 未完整搭建過node服務的人
  2. 學寫了koa, 想實踐的人
  3. 正在使用 koa 搭建 node 服務的人

正文

為了實現 api 介面請求,我們考慮幾個問題:

  1. 整個服務程式的處理流程與錯誤處理
  2. 介面路由
  3. 介面呼叫許可權
  4. 介面快取
  5. 訪問資料庫

除了服務程式本身,還要考慮工程相關的(本文不展開講):

  • 日誌處理
  • 監控報警
  • 快速恢復

認識一下常用中介軟體

request 引數解析外掛

引數分3種:

  • url search
  • url parameter
  • POST body

koa-bodyparser: 把request body 上的資料掛載到 ctx.request.body, 支援 json / text / xml / form (不支援 multipart)

檔案快取相關

  • koa-static: 靜態檔案系統, 支援 maxagegzip 等屬性。這個中介軟體配合下面的中介軟體更好用:

    • 搭配 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-getkoa-etag 實現整套快取流程。
使用快取 demo,主要知識點:

  1. 快取設定:

      if (ttl) {
     ctx.response.set('Cache-Control', `max-age=${ttl}`);
      } else {
     ctx.response.set('Cache-Control', 'no-store');
      }
  2. 生成 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

登入設計

使用 token 還是 session-cookie?

  • token: 重計算,輕儲存
  • session: 重儲存,輕計算

詳細瞭解戳這裡>> 我們這裡採用 token 驗證為例。

token 實現

token需要滿足的條件
  1. 唯一ID,代表獨一無二的使用者賬號
  2. 有效期,失效後需要重新登入,用於保護使用者賬號
簡陋的實現
  1. 通過UUID 實現唯一ID
  2. 通過 Redis 快取有效期來等效 token 有效期
優雅的實現

使用 jsonwebtoken(JWT): https://github.com/auth0/node...
特點:

  1. 加密/解密 機制
  2. 生成唯一ID
  3. 可支援有效期設定

舉個? :
服務端生成 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 還可以自帶引數,這可以省去將使用者資訊存在資料庫的步驟,只是在計算的時候需要消耗效能。

應用裡的實現:

  1. 首先,得到新 token 之後儲存到本地
  2. 每次請求在 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、密碼、頭像這種。
使用者表設計:

usernameldappasswordavatar
張三zhangsan01z123456https://avatar.com/z123456
李四lisil000l123456https://avatar.com/l000

使用者許可權

使用者許可權則是在使用者資訊上的加強,每個使用者都關聯著一系列許可權。使用者許可權可以認為是業務側的實現,資訊更豐富,業務更重。
許可權表設計:

ldapproductpermission
zhangsan01product13
zhangsan01product21
lisiproduct17

使用者身份生效/失效 機制

生效:

  • 登陸的時候重新生成token
  • 修改了密碼的時候重新生成token

失效:

  • 退出登陸的時候
  • token過期

後續處理:

  1. 重新登陸之後檢查重定向地址進行跳轉
  2. 修改密碼和失效時候 要引導到重新登入
  3. 退出登陸之後

    • 對於 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 設計方法
  • 沒有許可權的處理
  • 介面結構&報錯資訊設計

使用路由

使用 koa-route
參考 demo :

  • 分模組管理 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;
    }
}

思考:一個合理的後端工程結構

目錄結構:

  • 靜態檔案:static
  • view層:ejs 模版
  • 資料模型: models✅
  • 服務: service
  • 路由: routes✅
  • 中介軟體: middleware✅
  • 定時任務: jobs ✅

後記

初次嘗試,難免有考慮不周之處,還請讀者指出來,一起學習進步 ~

相關文章