從零搭建 Node.js 企業級 Web 服務器(九):配置項

服務器運行的真實環境

在企業作服務器除了本身的本地環境,還要充分地考慮部署環境。一般部署環境會有平常環境、預發環境、線上環境,在一些穩定性要求更高的項目中還會有灰度環境,不一樣環境之間會存在一些隔離,不一樣環境自己也存在一些差別,企業級 Web 服務器須要提供良好的機制在各個環境上平滑切換。本章將圍繞如何有效管理配置項實現服務器在不一樣環境平滑切換進行展開。html

29b3831144769c3ab6b319536d0e6cbdbb80fa92.jpg

關於配置項

服務器的配置項主要分爲兩類:環境變量、配置文件。前者歷史能夠追溯到上個世紀八十年代,爲程序運行提供了最基礎的輸入,Node.js 中能夠經過 process.env 訪問,其中 NODE_ENV 是最爲普遍使用的環境變量,常見的約定值好比:development。後者的使用更是自有程序以來就約定俗稱的良好習慣,受益於 Node.js 的精巧,通常直接使用寫入配置的 .js 文件做爲配置文件。node

如今從上一章已完成的工程 host1-tech/nodejs-server-examples - 08-security 着手,結合環境變量與配置文件實現程序在環境間的平滑切換。在工程根目錄執行命令安裝環境變量管理的相關模塊 cross-envdotenv 以及用於合併配置項的模塊 lodash.mergegit

$ yarn add cross-env dotenv lodash.merge
# ...
info Direct dependencies
├─ cross-env@7.0.2
├─ dotenv@8.2.0
└─ lodash.merge@4.6.2
# ...

配置項改造

接下來將會把配置項分爲 3 套,本地配置、部署配置、測試配置,分別對應 3 個關鍵詞 developmentproductiontest。本地配置用於本地環境,部署配置用於平常、預發、線上等的部署環境(本文對應容器環境),測試配置用於單元測試(後續章節再作展開)。動態的或隱私的配置項將以環境變量提供,同時環境變量 NODE_ENV 將會決定使用哪套配置。服務器最終的配置項由默認配置與 NODE_ENV 選擇的配置合併而成。github

寫入環境變量控制:sql

$ mkdir scripts # 新建 scripts 目錄存放工具腳本

$ tree -L 1     # 展現當前目錄內容結構
.
├── Dockerfile
├── database
├── node_modules
├── package.json
├── public
├── scripts
├── src
└── yarn.lock
// scripts/env.js
const fs = require('fs');
const { resolve } = require('path');
const dotenv = require('dotenv');

const dotenvTags = [
  // 本地環境
  'development',

  // 測試環境
  // 好比:單元測試
  'test',

  // 部署環境
  // 好比:平常、預發、線上
  'production',
];

if (!dotenvTags.includes(process.env.NODE_ENV)) {
  process.env.NODE_ENV = dotenvTags[0];
}

const dotenvPath = resolve('.env');

const dotenvFiles = [
  dotenvPath,
  `${dotenvPath}.local`,
  `${dotenvPath}.${process.env.NODE_ENV}`,
  `${dotenvPath}.${process.env.NODE_ENV}.local`,
].filter(fs.existsSync);

dotenvFiles
  .reverse()
  .forEach((dotenvFile) => dotenv.config({ path: dotenvFile }));
// package.json
{
  "name": "09-config",
  "version": "1.0.0",
  "scripts": {
-    "start": "node src/server.js",
+    "start": "node -r ./scripts/env src/server.js",
+    "start:prod": "cross-env NODE_ENV=production node -r ./scripts/env src/server.js",
+    "sequelize": "sequelize",
+    "sequelize:prod": "cross-env NODE_ENV=production sequelize",
    "build:yup": "rollup node_modules/yup -o src/moulds/yup.js -p @rollup/plugin-node-resolve,@rollup/plugin-commonjs,rollup-plugin-terser -f umd -n 'yup'"
  },
  // ...
}
# Dockerfile
FROM node:12.18.2

WORKDIR /usr/app/09-config
COPY . .
RUN yarn

EXPOSE 9000
-CMD yarn start
+CMD yarn start:prod

抽離建立配置文件:docker

$ mkdir src/config  # 新建 src/config 存放配置文件

$ tree src -L 1     # 展現 src 目錄內容結構
src
├── config
├── controllers
├── middlewares
├── models
├── moulds
├── server.js
├── services
└── utils
// src/config/index.js
const merge = require('lodash.merge');

const config = {
  // 默認配置
  default: {
    sessionCookieSecret: '842d918ced1888c65a650f993077c3d36b8f114d',
    sessionCookieMaxAge: 7 * 24 * 60 * 60 * 1000,

    homepagePath: '/',
    loginPath: '/login.html',
    loginWhiteList: {
      '/500.html': ['get'],
      '/api/health': ['get'],
      '/api/csrf/script': ['get'],
      '/api/login': ['post'],
      '/api/login/github': ['get'],
      '/api/login/github/callback': ['get'],
    },

    githubStrategyOptions: {
      clientID: 'b8ada004c6d682426cfb',
      clientSecret: '0b13f2ab5651f33f879a535fc2b316c6c731a041',
      callbackURL: 'http://localhost:9000/api/login/github/callback',
    },

    db: {
      dialect: 'sqlite',
      storage: ':memory:',
      define: {
        underscored: true,
      },
      migrationStorageTableName: 'sequelize_meta',
    },
  },

  // 本地配置
  development: {
    db: {
      storage: 'database/dev.db',
    },
  },

  // 測試配置
  test: {
    db: {
      logging: false,
    },
  },

  // 部署配置
  production: {
    sessionCookieMaxAge: 3 * 24 * 60 * 60 * 1000,

    db: {
      storage: 'database/prod.db',
    },
  },
};

module.exports = merge(
  {},
  config.default,
  config[process.env.NODE_ENV || 'development']
);
// src/middlewares/index.js
const { Router } = require('express');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const csurf = require('csurf');
const helmet = require('helmet');
const sessionMiddleware = require('./session');
const urlnormalizeMiddleware = require('./urlnormalize');
const loginMiddleware = require('./login');
const authMiddleware = require('./auth');
+const { sessionCookieSecret } = require('../config');

-const secret = '842d918ced1888c65a650f993077c3d36b8f114d';
-
module.exports = async function initMiddlewares() {
  const router = Router();
  router.use(helmet());
  router.use(urlnormalizeMiddleware());
-  router.use(cookieParser(secret));
-  router.use(sessionMiddleware(secret));
+  router.use(cookieParser(sessionCookieSecret));
+  router.use(sessionMiddleware());
  router.use(loginMiddleware());
  router.use(authMiddleware());
  router.use(bodyParser.urlencoded({ extended: false }), csurf());
  return router;
};
// src/middlewares/session.js
const session = require('express-session');
const sessionSequelize = require('connect-session-sequelize');
const { sequelize } = require('../models');
+const { sessionCookieSecret, sessionCookieMaxAge } = require('../config');

-module.exports = function sessionMiddleware(secret) {
+module.exports = function sessionMiddleware() {
  const SequelizeStore = sessionSequelize(session.Store);

  const store = new SequelizeStore({
    db: sequelize,
    modelKey: 'Session',
    tableName: 'session',
  });

  return session({
-    secret,
-    cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 },
+    secret: sessionCookieSecret,
+    cookie: { maxAge: sessionCookieMaxAge },
    store,
    resave: false,
    proxy: true,
    saveUninitialized: false,
  });
};
// src/middlewares/auth.js
const passport = require('passport');
const { Strategy: GithubStrategy } = require('passport-github');
+const { githubStrategyOptions } = require('../config');

-const GITHUB_STRATEGY_OPTIONS = {
-  clientID: 'b8ada004c6d682426cfb',
-  clientSecret: '0b13f2ab5651f33f879a535fc2b316c6c731a041',
-  callbackURL: 'http://localhost:9000/api/login/github/callback',
-};
-
const githubStrategy = new GithubStrategy(
-  GITHUB_STRATEGY_OPTIONS,
+  githubStrategyOptions,
  (accessToken, refreshToken, profile, done) => {
    /**
     * 根據 profile 查找或新建 user 信息
     */
    const user = {};
    done(null, user);
  }
);
// ...
// src/middlewares/login.js
const { parse } = require('url');
+const { homepagePath, loginPath, loginWhiteList } = require('../config');

-module.exports = function loginMiddleware(
-  homepagePath = '/',
-  loginPath = '/login.html',
-  whiteList = {
-    '/500.html': ['get'],
-    '/api/health': ['get'],
-    '/api/csrf/script': ['get'],
-    '/api/login': ['post'],
-    '/api/login/github': ['get'],
-    '/api/login/github/callback': ['get'],
-  }
-) {
-  whiteList[loginPath] = ['get'];
+module.exports = function loginMiddleware() {
+  const whiteList = Object.assign({}, loginWhiteList, {
+    [loginPath]: ['get'],
+  });

  return (req, res, next) => {
    // ...
  };
};
// src/controllers/login.js
const { Router } = require('express');
const { passport } = require('../middlewares/auth');
+const { homepagePath, loginPath } = require('../config');

class LoginController {
-  homepagePath;
-  loginPath;
-
  async init() {
    const router = Router();
    router.post('/', this.post);
    router.get(
      '/github',
      passport.authenticate('github', { scope: ['read:user'] })
    );
    router.get(
      '/github/callback',
      passport.authenticate('github', {
-        failureRedirect: this.loginPath,
+        failureRedirect: loginPath,
      }),
      this.getGithubCallback
    );
    return router;
  }

  post = (req, res) => {
    req.session.logined = true;
-    res.redirect(this.homepagePath);
+    res.redirect(homepagePath);
  };

  getGithubCallback = (req, res) => {
    req.session.logined = true;
-    res.redirect(this.homepagePath);
+    res.redirect(homepagePath);
  };
}

-module.exports = async (homepagePath = '/', loginPath = '/login.html') => {
+module.exports = async () => {
  const c = new LoginController();
-  Object.assign(c, { homepagePath, loginPath });
  return await c.init();
};
// src/models/config/index.js
-module.exports = {
-  development: {
-    dialect: 'sqlite',
-    storage: 'database/index.db',
-    define: {
-      underscored: true,
-    },
-    migrationStorageTableName: 'sequelize_meta',
-  },
-};
+const { db } = require('../../config');
+
+module.exports = { [process.env.NODE_ENV || 'development']: db };

這樣就有了 NODE_ENVdevelopment 的本地配置與 NODE_ENVproduction 的部署配置,能夠分別經過 yarn startyarn start:prod(或者容器) 在本地環境與部署環境以隔離的數據庫運行,數據庫模式與數據能夠分別使用 yarn sequelizeyarn sequelize:prod 作初始化。數據庫

如今將原來的 Github OAuth 應用只用於本地環境,再新建一個只用於部署環境的 Github OAuth 應用,將兩套 clientID 與 clientSecret 改用環境變量方式分別注入,實現認證登陸在兩套環境的隔離運行:express

cba821ef376a8cb14e5aca37160040190d6eee7b.jpg

eededb3c5e958988891e0f3ceb8428f2cdaf4b8c.jpg

# .env.local
GITHUB_CLIENT_ID='b8ada004c6d682426cfb'
GITHUB_CLIENT_SECRET='0b13f2ab5651f33f879a535fc2b316c6c731a041'
# .env.production.local
GITHUB_CLIENT_ID='a8d43bbca18811dcc63a'
GITHUB_CLIENT_SECRET='276b97b79c79cfef36c3fb1fceef8542f9e88aa6'
// src/config/index.js
// ...
const config = {
  // 默認配置
  default: {
    // ...
    githubStrategyOptions: {
-      clientID: 'b8ada004c6d682426cfb',
-      clientSecret: '0b13f2ab5651f33f879a535fc2b316c6c731a041',
+      clientID: process.env.GITHUB_CLIENT_ID,
+      clientSecret: process.env.GITHUB_CLIENT_SECRET,
      callbackURL: 'http://localhost:9000/api/login/github/callback',
    },
    // ...
  },

  // ...

  // 部署配置
  production: {
    sessionCookieMaxAge: 3 * 24 * 60 * 60 * 1000,
+
+    githubStrategyOptions: {
+      callbackURL: 'http://localhost:9090/api/login/github/callback',
+    },

    db: {
      storage: 'database/prod.db',
    },
  },
};
// ...

再經過 .gitignore 忽略掉 .env*.local,本地使用 .env.local.env.development.local,在部署環境構建鏡像時注入 .env.local.env.production.local ,便可將敏感配置徹底地保護起來。npm

本地環境運行效果:json

$ yarn start  # 本地啓動
# ...

70bd1bb7d347506af157e20cc9250517d8dd812b.gif

部署環境運行效果:

$ # 構建容器鏡像,命名爲 09-config,標籤爲 1.0.0
$ docker build -t 09-config:1.0.0 .
# ...
Successfully tagged 09-config:1.0.0

$ # 以鏡像 09-config:1.0.0 運行容器,命名爲 09-config
$ # 掛載 database 存放數據庫文件
$ # 重啓策略爲無條件重啓
$ docker run -p 9090:9000 -v "$PWD/database":/usr/app/09-config/database -d --restart always --name 09-config 09-config:1.0.0

8ab233d27989613481421ff3ae9cc80d362619c1.gif

本章源碼

host1-tech/nodejs-server-examples - 09-config

更多閱讀

從零搭建 Node.js 企業級 Web 服務器(零):靜態服務
從零搭建 Node.js 企業級 Web 服務器(一):接口與分層
從零搭建 Node.js 企業級 Web 服務器(二):校驗
從零搭建 Node.js 企業級 Web 服務器(三):中間件
從零搭建 Node.js 企業級 Web 服務器(四):異常處理
從零搭建 Node.js 企業級 Web 服務器(五):數據庫訪問
從零搭建 Node.js 企業級 Web 服務器(六):會話
從零搭建 Node.js 企業級 Web 服務器(七):認證登陸
從零搭建 Node.js 企業級 Web 服務器(八):網絡安全從零搭建 Node.js 企業級 Web 服務器(九):配置項

相關文章
相關標籤/搜索