从零搭建 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 服务器(九):配置项