0%

转型计划之初级后端(上)

this is the way

参考

资源

前言

之前在 topcoder 写前端混过一段时间,对找真实世界的项目代码进行 Dissect 算是有点经验,
对着这个 ’蹭‘ 来的真实项目 Dissect 一番, 实践经验这都不是事。
这篇文章就是记录 Dissect 与 Refactor 过程的些许经验。

This is the way

首先要记录的是, topcoder 上的项目(以下简称: 项目)开发流程。
项目开发流程分为以下四个阶段:

  1. 设计阶段
  2. 开发阶段
  3. review 阶段
  4. 反馈阶段

具体流程是:
先是某些组织(公司)在 topcoder 上发布需求文档, 然后 topcoder 上的设计人员根据需求文档设计开发所需的设计文档,
这些设计文档一般是 ER Diagram.png (ER 图) 和 swagger.yaml(api 文档),(前端项目的设计文档会有所不同) 此为设计阶段。
然后开发人员再根据这些设计文档以及需求文档所要求的技术栈,完成项目的编码阶段,
编码完成后需要将使用的 postman 导出为 json 文档方便评审人员导入测试, 还需要编写 Verification.md (告知评审人员如何验证项目的文档) 此为开发阶段。
开发完成后再交由评审人员评审, 此为 review 阶段。
review 完成后, 如有要修改的地方则开发人员响应要求进行修改, 如此这般这个项目就算是完成。 此为反馈阶段。

这样一套流程下来, 才是真正的流水线工程, 职责分明, 分工明确, 所以效率很高。
这让我想起了 DDD (Domain-Driven Design 领域驱动设计), 说不得那些发布在 topcoder 上的需求文档就是领域层处理后的产物。
与那牢厂所谓的狼性文化一对比, 呵呵。

我之所以能了解的如此细节, 正是因为我 ‘蹭’ 到了一份源码, 由于保密协议不方便透露业务实现细节,不过框架的选型与应用都是开源的,
这个项目使用的是 nodejs + express + sequelize + sqlite3
我会对这份 ‘蹭’ 到源码进行一些 ‘优化’, 并使用 typescript + nestjs + typeorm + mysql + docker 进行重构,
以此来积累我的实践经验, 所以这篇文章就是我对其操作过程的一些笔记.

除了这个项目外, 我在 topcoder 团队的 github 帐号 下找到了以下几个开源的项目

除了 ORM 框架与数据库外, 项目架构与我手中的这个, 几乎是相同的。

前置知识

理解最重要, 切记切记

数据库设计规范

ER 图

erd
ies

ER 图设计工具

Dissect

Dissect 一个别人写的项目其实是比较枯燥的, 所以顺便做一些改动, 在证明自己真的读懂了之外, 添增些许乐趣, 让自己有兴趣持续做下去也算很重要的事情。

这里以 challenge-api 为例

Dissect 一个 node 项目的步骤如下:

  1. 读 package.json devDependencies 和 dependencies, 然后在 npm 依个检索, 搞清楚这些依赖包都是做什么的
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    {
    "name": "topcoder-challenges-api",
    "version": "1.0.0",
    "description": "TopCoder Challenges V5 API",
    "main": "app.js",
    "scripts": {
    "start": "node app.js",
    "lint": "standard",
    "lint:fix": "standard --fix",
    "init-es": "node src/init-es.js",
    "init-db": "node src/init-db.js",
    "sync-es": "node src/scripts/sync-es.js",
    "drop-tables": "node src/scripts/drop-tables.js",
    "create-tables": "node src/scripts/create-tables.js",
    "seed-tables": "node src/scripts/seed-tables.js",
    "view-data": "node src/scripts/view-data.js",
    "view-es-data": "node src/scripts/view-es-data.js",
    "test": "mocha --require test/prepare.js -t 20000 test/unit/*.test.js --exit",
    "e2e": "mocha --require test/prepare.js -t 20000 test/e2e/*.test.js --exit",
    "test:cov": "nyc --reporter=html --reporter=text npm test",
    "e2e:cov": "nyc --reporter=html --reporter=text npm run e2e"
    },
    "author": "TCSCODER",
    "license": "none",
    "devDependencies": {
    "chai": "^4.2.0",
    "chai-http": "^4.2.1",
    "mocha": "^6.1.4",
    "mocha-prepare": "^0.1.0",
    "nyc": "^14.0.0",
    "standard": "^12.0.1"
    },
    "dependencies": {
    "aws-sdk": "^2.466.0",
    "axios": "^0.19.0",
    "bluebird": "^3.5.1",
    "body-parser": "^1.15.1",
    "config": "^3.0.1",
    "cors": "^2.7.1",
    "dynamoose": "^1.8.0",
    "elasticsearch": "^16.1.1",
    "express": "^4.15.4",
    "express-fileupload": "^1.1.4",
    "express-interceptor": "^1.2.0",
    "get-parameter-names": "^0.3.0",
    "http-aws-es": "^6.0.0",
    "http-status-codes": "^1.3.0",
    "joi": "^14.0.0",
    "jsonwebtoken": "^8.3.0",
    "lodash": "^4.17.11",
    "moment": "^2.24.0",
    "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v2.6.2",
    "topcoder-bus-api-wrapper": "topcoder-platform/tc-bus-api-wrapper.git",
    "uuid": "^3.3.2",
    "winston": "^3.1.0"
    },
    "standard": {
    "ignore": [
    "mock-api"
    ],
    "env": [
    "mocha"
    ]
    },
    "engines": {
    "node": "10.x"
    }
    }

从这一行 "standard": "^12.0.1" 就可以看出这个作者是很有品味的人, 这样的话, 代码规范就不用改了
看到这一行 "lodash": "^4.17.11", 我就知道有可以改动的地方了,原因在这里,You-Dont-Need-Lodash-Underscore
其实也不是什么大问题, 但是啊!

  1. 继续读 package.json, 这次是读 script 部分, 找到项目的入口文件, 然后读入口文件

    1
    2
    3
    "scripts": {
    "start": "node app.js",
    },

    入口文件太长就不贴了, 贴个链接: https://sourcegraph.com/github.com/topcoder-platform/challenge-api/-/blob/app.js
    可以看出这是一个原生 ES5 项目, 其实对于这样的项目 ES5 完全足够了, 但是没 ES6 好看 啊!!

  2. 这一步是读 README.md 文档, 按照文档要求确保开发环境符合要求, 快速搭建 node 开发环境请参考: development-environment-manual
    确保环境符合要后就把项目 git clone 到本地
    然后先 npm install 安装依赖,再 npm run start 确保项目是可以运行起来的。

  3. 这一步是看 Verification.md 文档, 假想自己是评审人员需要评审这个项目一样,
    先把项目评审一下, 了解项目的功能有哪些, 这对接下来的深入源码很有帮助。
    在这一步我发现需要将 postman json 文件导入 postman 来测试 api, 所以需要先下载一个 postman
    scoop install postman 然后打开 postman 将 docs 目录下的 postman json 文件导入进去
    这里用到了 scoop 包管理器, 安装请参考: The Package Manager for Windows: choco and scoop 使用则请读 scoop 官网文档: scoop

  4. 上面阅读 package.json 确认了两个可以更改的地方
    一个是要将其改成 ES6 项目, 一个是要去除 lodash 依赖。

ES5 -> ES6 lodash

我现在使用的 node 版本是最新的长期支持版本 12.14.0, node 在 13.2.0 版本开始支持不带标志的 ES modules,
但是,该实现仍是实验性的, 具体请看: https://nodejs.org/api/esm.html#esm_ecmascript_modules

因此我要使用 ES6 的话, 首先要下载一个 backpack 库来帮我编译 ES modules 到 CommonJS.
backpack 就是将前端的 webpack 应用到了后端。 先将 backpack README.MD 文档 读一遍 顺便点个 star
然后就可以开始使用了:

  1. 首先将其安装到开发时依赖 npm install backpack -D

  2. 然后 touch 一个 backpack.config.js 配置文件
    touch 是 liunx 系统下新建文件的命令, 在 windows 系统中需要使用 scoop 安装 touch scoop install touch
    touch backpack.config.js 如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    const paths = {
    development: './app.js',
    production: './app.js',
    initDB: './src/init-db.js',
    testData: './src/test-data.js',
    viewData: './src/view-data.js'
    }

    const mainPath = paths[process.env.NODE_ENV_PRIVATE]

    module.exports = {
    webpack: (config, options, webpack) => {
    // Perform customizations to config
    // Important: return the modified config

    // changes the name of the entry point from index -> main.js
    config.entry.main = [mainPath]

    return config
    }
    }
  3. 修改 package.json script 如下

    1
    2
    3
    4
    5
    6
    "start": "npm run dev",
    "dev": "cross-env NODE_ENV_PRIVATE=development backpack dev",
    "build": "cross-env NODE_ENV_PRIVATE=production backpack build",
    "init-db": "cross-env NODE_ENV_PRIVATE=initDB backpack dev",
    "test-data": "cross-env NODE_ENV_PRIVATE=testData backpack dev",
    "view-data": "cross-env NODE_ENV_PRIVATE=viewData backpack dev",

    这里我们需要使用环境变量来切换入口文件, 所以需要下载一个可以跨平台的设置环境变量的命令行工具: cross-env
    添加一个开发依赖: npm install cross-env -D

  4. 现在可以在项目中使用 ES6 Modules 了, 可以一边读代码一边修改
    如: 在入口文件 app.js 中 将 require('./app-bootstrap') 改为 import './app-bootstrap'
    这是 app.js 第一行代码, 它执行了当前目录下的app-bootstrap.js, 顾名思义, 这是一个初始化项目的文件。
    打开 app-bootstrap.js 内容如下:

    1
    2
    3
    4
    5
    import bluebird from 'bluebird'
    import Joi from 'joi'

    global.Promise = bluebird
    Joi.id = () => Joi.string().required()

    可以看到这里声明了一个全局变量 Promise 并指向了导入的 bluebird
    看样子这是一个实现了 Promise 的库, 一开始我准备将其删掉, 因为我已经使用 backpack 将这个项目改为 ES6 项目了
    而 ES6 有原生实现的 Promise 了, 没必要再引入这个第三方 bluebird
    但是后来我查了下这个 bluebird
    bluebird 作者说

    Bluebird is a fully featured promise library with focus on innovative features and performance

关键字 performance 然后我就去知乎查了下这个 bluebird 与 ES6 原生 Promise 的区别:

joi 这个库, 它的作者是这样说的:

The most powerful data validation library for JS

数据验证库,没什么好说的。 点个 star 然后开始下一步。

  1. 继续看 app.js
    1
    2
    3
    4
    5
    6
    7
    8
    import * as _ from 'lodash'
    import is from 'is'
    import config from 'config'
    import express from 'express'
    import bodyParser from 'body-parser'
    import cors from 'cors'
    import logger from './src/common/logger'
    import HttpStatus from 'http-status-codes'
    is 类型断言库, 用来替换 lodash 的 类型断言函数
    lodash JavaScript 实用程序库
    config 配置文件
    express web 框架
    bodyParser 请求体解析中间件
    cors (Cross-Origin Resource Sharing) 跨域资源共享中间件
    logger 日志
    HttpStatus 状态码

没什么好说的 继续

1
2
3
4
5
6
7
8
9
10
11
12
// setup express app
const app = express()

app.use(
cors({
exposedHeaders: ['Content-Disposition', 'Content-Type']
})
)

app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.set('port', config.PORT)

同样没啥好说的, 生成一个 express 实体指向 app, 然后就是应用一些中间件, 设置了一个 express 变量 port.
继续看 app.js

1
2
// Register routes
require('./app-routes')(app)

这里导入了一个路由注册方法, 将 express 的实例 app 给传递了进去

  1. 现在可以看看 app-routes.js 搞清楚路由是如何被注册的
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    /**
    * Configure all routes for express app
    */

    import HttpStatus from 'http-status-codes'
    import helper from './src/common/helper'
    import auth from './src/common/auth'
    import errors from './src/common/errors'
    import routes from './src/routes'
    import multer from 'multer'
    import autoReap from 'multer-autoreap'
    import * as Controllers from './src/controllers'

    const upload = multer({ dest: './upload' })

    /**
    * Configure all routes for express app
    * @param app the express app
    */
    export default app => {
    // auto remove uploaded files
    app.use(autoReap)

    // Load all routes
    for (const [path, verbs] of Object.entries(routes)) {
    for (const [verb, def] of Object.entries(verbs)) {
    const method = Controllers[def.controller][def.method] // eslint-disable-line

    if (!method) {
    throw new Error(`${def.method} is undefined`)
    }

    const actions = []

    actions.push((req, res, next) => {
    req.signature = `${def.controller}#${def.method}`
    next()
    })

    // Authentication
    if (!def.public) {
    actions.push(auth())
    actions.push((req, res, next) => {
    if (def.adminOnly && !req.authUser.isAdmin) {
    next(
    new errors.ForbiddenError(
    "You don't have rights to perform this action!"
    )
    )
    } else {
    next()
    }
    })
    }

    if (def.file) {
    actions.push(upload.single(def.file))
    }

    actions.push(method)
    app[verb](path, helper.autoWrapExpress(actions))
    }
    }

    // Check if the route is not found or HTTP method is not supported
    app.use('*', (req, res) => {
    const route = routes[req.baseUrl]
    if (route) {
    res.status(HttpStatus.METHOD_NOT_ALLOWED).json({
    message: 'The requested HTTP method is not supported.'
    })
    } else {
    res.status(HttpStatus.NOT_FOUND).json({
    message: 'The requested resource cannot be found.'
    })
    }
    })
    }
    可以看到这里,我将 lodash 的方法改写成了原生的 ES6 写法
    这个 app-routes.js 的主要作用是将 ‘./src/controllers’ 中定义的 controllers 与 ‘./src/routes’ 中定义的 routes 给对应上
    打开 ‘./src/routes’ 可以看到这就是一个中心化的 routes, 这里面定义了请求路径与请求方法以及其对应的 controller 和 对应 controller 中的 method, 还有一些作用域和权限相关的选项。
    到这一步, 就可以知道这个项目所使用的架构就是大名鼎鼎的 MVC 架构。

知道 routes path 如何与 controller 中 method 绑定后, 就可以着手查看其他文件。
app-routes.js文件中使用了很多 ‘/src/common’ 下的文件, 可以从这里开始了解抽取的公用逻辑函数。

  1. challenge-api 的 ‘/src/common’ 下有三个文件
  • error.js
  • helper.js
  • logger.js
    error.js 处理程序抛出的异常, logger.js 纪录程序的运行日志, 而 helper.js 的用途就比较复杂了, 应用程序中所有频繁出现的,可抽取的公用逻辑都可以集中放在这个文件中, 以降低代码冗余.
    这里我除了将 lodash 的方法用 ES6 原生方法替代外, 还对 error.js 进行了重构,你可以看到, 原先的error.js 中定义了一个创建各种 http 异常的工厂函数, 这个工厂函数创造一些继承自 Error 的 HTTP 异常类, 继承的实现是使用的 node 自带的 util.inherits 方法, ES6 有原生的 class 语法, 所以我用 class 语法重构了这一部分, 如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    /**
    * This file defines application errors
    /**
    *
    *
    * @class ErrorFactory
    */
    class ErrorFactory {
    createCustomError (name, statusCode) {
    /**
    * Helper Class to create generic error object with http status code
    * @param {String} name the error name
    * @param {Number} statusCode the http status code
    * @returns {Class} the error constructor
    */
    class CustomError extends Error {
    constructor (message = name, cause = statusCode) {
    // Calling parent constructor of base Error class.
    super(message, statusCode)
    // Saving class name in the property of our custom error as a shortcut.
    this.name = name
    // Capturing stack trace, excluding constructor call from it.
    Error.captureStackTrace(this, this.constructor)
    this.message = message
    this.cause = cause
    this.httpStatus = statusCode
    }
    }

    return CustomError
    }
    }

    const createCustomError = new ErrorFactory().createCustomError

    export default {
    BadRequestError: createCustomError('BadRequestError', 400),
    UnauthorizedError: createCustomError('UnauthorizedError', 401),
    ForbiddenError: createCustomError('ForbiddenError', 403),
    NotFoundError: createCustomError('NotFoundError', 404),
    ConflictError: createCustomError('ConflictError', 409)
    }
    可以看到 js 是真的非常简洁和灵活, 要是用 java 来写不知道要罗嗦成什么样, 还美名其曰: 设计模式。
  1. 将 ‘/src/common’ 下的文件都读一遍后就可以回头继续读 app.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    // The error handler
    // eslint-disable-next-line no-unused-vars
    app.use((err, req, res, next) => {
    logger.logFullError(err, req.signature || `${req.method} ${req.url}`)

    const errorResponse = {}
    const status = err.isJoi
    ? HttpStatus.BAD_REQUEST
    : err.httpStatus || HttpStatus.INTERNAL_SERVER_ERROR

    if (is.array(err.details) && err.isJoi) {
    err.details.forEach(e => {
    if (e.message) {
    if (is.undef(errorResponse.message)) {
    errorResponse.message = e.message
    } else {
    errorResponse.message += `, ${e.message}`
    }
    }
    })
    }

    if (is.undef(errorResponse.message)) {
    if (err.message && status !== HttpStatus.INTERNAL_SERVER_ERROR) {
    errorResponse.message = err.message
    } else {
    errorResponse.message = 'Internal server error'
    }
    }

    res.status(status).json(errorResponse)
    })

    // if (!module.parent) {
    app.listen(app.get('port'), () => {
    logger.info(`Express server listening on port ${app.get('port')}`)
    })
    // }

    export default {
    expressApp: app
    }

    这里自定义了一个集中处理错误的中间件, 没什么好说的。

  2. 然后就可以看下在 ‘src’ 根目录和 ‘src/script’ 目录下其他入口文件, 这里是一些初始化数据库 创建表 同步表 查看表数据的一些 script
    由于这个 challenge-api 项目使用的是 AWS 云服务, 数据库是云数据库 Amazon DynamoDB, 而我手中这个是本地的 sqlite3,
    所以没什么好说的, 读一遍 AWS 文档 就能看懂。

  3. 最后一步就是看核心的 ‘src/models’ ‘src/controllers’ ‘src/services’
    如果你看过 Verification.md 文档, 然后做过这一步: 假想自己是评审人员需要评审这个项目
    你就能很容易的借助 postman 一边测试 api 一边理解这部分的业务逻辑了。

确保项目中的所有 lodash 依赖剔除后, 删除 lodash 依赖 npm uninstall lodash 然后重新 npm run start 确保项目没有因此改动而出现错误。

转型计划之初级后端上篇: Dissect 到此结束, 下一篇是使用 typescript + nestjs + typeorm + mysql + docker Refactor.

this is the way