从零搭建 Node.js 企业级 Web 服务器(九):配置项

服务器运行的真实环境

在企业做服务器除了自己的本地环境,还要充分地考虑部署环境。通常部署环境会有日常环境、预发环境、线上环境,在一些稳定性要求更高的项目中还会有灰度环境,不同环境之间会存在一些隔离,不同环境本身也存在一些差异,企业级 Web 服务器需要提供良好的机制在各个环境上平滑切换。本章将围绕如何有效管理配置项实现服务器在不同环境平滑切换进行展开。

29b3831144769c3ab6b319536d0e6cbdbb80fa92.jpg

关于配置项

服务器的配置项主要分为两类:环境变量、配置文件。前者历史可以追溯到上个世纪八十年代,为程序运行提供了最基础的输入,Node.js 中可以通过 process.env 访问,其中 NODE_ENV 是最为广泛使用的环境变量,常见的约定值比如:development。后者的使用更是自有程序以来就约定俗称的良好习惯,受益于 Node.js 的精巧,一般直接使用写入配置的 .js 文件作为配置文件。

现在从上一章已完成的工程 host1-tech/nodejs-server-examples - 08-security 着手,结合环境变量与配置文件实现程序在环境间的平滑切换。在工程根目录执行命令安装环境变量管理的相关模块 cross-envdotenv 以及用于合并配置项的模块 lodash.merge

$ 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 选择的配置合并而成。

写入环境变量控制:

$ 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

抽离创建配置文件:

$ 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 改用环境变量方式分别注入,实现认证登录在两套环境的隔离运行:

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 ,即可将敏感配置完全地保护起来。

本地环境运行效果:

$ 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 服务器(九):配置项
从零搭建 Node.js 企业级 Web 服务器(十):日志
从零搭建 Node.js 企业级 Web 服务器(十一):定时任务
从零搭建 Node.js 企业级 Web 服务器(十二):远程调用
从零搭建 Node.js 企业级 Web 服务器(十三):断点调试与性能分析

阅读 410

推荐阅读