原创

网站重构-后台服务篇

戎码一生
11 条评论
12 人喜欢
380 次阅读
全文共 3363 预计阅读时长 13 分钟

写在前面

这不是一篇纯技术文章,只是一篇对这段是重构后端的总结

国庆前后差不多一个半月的时间,把自己的网站从数据库,到后端,再到A端和C端整个都重构了一篇,国庆7天妥妥地宅
在家里码代码,好在目前来看完成度还是达到我的预期的,虽说没有变的多高大上,但是好歹项目比以前工程化了一些,这个重构过程虽然漫长,但是确实还是有着自己的一些体会的。接下来会分三篇文章来介绍重构经历——后台服务篇、Nuxt应用篇和Docker集成篇

博客地址: jooger.me

仓库地址: 后端C端A端

总共差不多200个commits吧,欢迎star 😄。废话不多说,先来看下后端的重构经历吧

为什么要重构后端?

原因有以下几点

  1. 单纯想体验下传说中的企业级框架-Egg

遇到没用过的就想玩一下,后续有可能还会在来个Nest版也说不定(很喜欢注解这种形式)

  1. 日志系统不完善

以前觉得日志啥的不重要,没有形成日志备份,所以很多次线上故障原因都无从查证,只能说以前太年轻

  1. 部署流程不理想

以前是用pm2-deploy手动部署,每次都是看着console等部署完成,哈哈,“刀耕火种”,现在是用docker+jenkins,配合github webhook和阿里镜像容器实现自动化部署

  1. 代码烂(虽然现在依然很烂)

这个没啥好说的,逻辑层和controller层混合在一起,复用性差,重构是早晚的事儿

至于为啥没用TypeScript,我只想说我最开始是用了TS的,也搜了一些文章,但是使用起来莫名其妙的很不爽,然后就放弃了,不过其他俩项目都是用TS重构的

生命不息,重构不止

哪些地方重构了?

框架

以前用的是“常规操作-Koa,配合上一些插件,还算不错

重构后用的是阿里开源的 Egg ,文档是真心好评,虽然文档我没有完全看完整(进阶那部分略微搂了两眼),特别是《多进程模型和进程间通讯》那一节讲的真的很详细,并且图文并茂地介绍了Egg在多进程架构下的实践,对于我这种接触Node直接pm2,没有接触过cluster的人很有帮助。

目前社区的优质插件的话我搜了下,也不少了,没有尝试过的可以玩儿一下,另外还推荐一下 Nest.js 框架,基于express的,我只大致看了几眼,发现跟Srping很像,以后说不定会用这个再重构下

数据库

数据库这边我一直用的mongodb,driver用的mongoose,这次重构主要是重构了下setting表,并且新增了notificationstat

setting表主要存网站的配置,分四个部分

  • site C端的一些配置
  • personal 个人信息
  • keys 一些第三方插件的参数,比如阿里云OSS的,Github,阿里node平台(这个稍后要讲),个人邮箱的一些配置
  • limit 列表接口的分页,垃圾评论最大数限制的数据配置

至于keys,以前的server启动时,一些服务的初始化参数往往都是在集成工具里配置的,我这边将其迁移到数据库中存储了,server启动前先从数据库中加载这些配置参数,然后启动各服务即可,这样如果参数有变动,也就不用重新启动server了,只需要重启相对应的服务即可

notification表主要存一些C端和内部系统服务的一些操作通知,目前包括了4个大类,18个小类的通知类型, #L188

stat表则是统计一些C端操作,然后在A端展示出来,像一些关键词搜索,点赞,用户创建等操作都会生成统计记录的,目前只统计了6种操作 #L217 ,与此同时C端也用Google tag做了一些埋点,方便整个网站的统计

可以看看效果

后台管理-仪表盘

《后台管理-仪表盘》

业务逻辑层和Controller层分离

看下重构前的Controller流程图

重构前的Controller流程

《重构前的Controller流程》

图中所有业务逻辑都是在Controller中完成,而且是直接在逻辑中调用Model的接口,这样做有三个问题

  1. 逻辑臃肿,如果逻辑复杂的话,一个Controller代码会很多,可维护性差
  2. 每次调用Model层都要catch一下,没有做统一处理,修改起来很麻烦
  3. Controller之间的业务逻辑复用问题

这仨问题任何一个都是需要重视的

然后再看下重构后的流程图

重构后的Controller流程

《重构后的Controller流程》

这样逻辑分离后,很好地解决了上面的三个问题

  1. Controller很清爽,逻辑已经被拆分出来,流程一步一步来,很清晰
  2. 可以看到在Model层之上加了个Proxy层,用以统一输出接口供业务逻辑层调用,而且还可以在这里做catch统一处理
  3. 将业务逻辑层抽离出来后,各Controller都可以调用,复用问题解决

整个流程配合上Egg的logger,可以快速定位问题

至于Proxy我是这样实现的

// service/proxy.js
const { Service } = require('egg')

// 代理需要继承自EggService,因为其他模块service需要继承Proxy
module.exports = class ProxyService extends Service {
    getList (query = {}) {
        return this.model.find(query, // ...)
    }
    // ... 一些Model的统一接口
}

// service/user.js
const ProxyService = require('./proxy')

// 继承Proxy,定义当前模块所属的model
module.exports = class UserService extends ProxyService {
    get model () {
        return this.app.model.User
    }

    getListWithComments () {}
    // 其他业务逻辑方法
}

// controller/user.js
const { Controller } = require('egg')

module.exports = class UserController extends Controller {
    async list () {
        const data = await this.service.user.getListWithComments()
        data
            ? ctx.success(data, '获取用户列表成功')
            : ctx.fail('获取用户列表失败')
    }
}

日志系统

如上所述,重构前是没有所谓的日志记录的,对于一些线上问题的定位和复现很棘手,这也是我看好Egg的一个很重要的原因。

Egg的日志有以下几个特性

  • 日志分类、分级,它有4种日志类型(appLogger, coreLogger, errorLogger, agentLogger),5种日志级别(NONE, DEBUG, INFO, WARN, ERROR),而且可以根据环境变量配置打印级别
  • 统一错误日志,ERROR级别日志会统一打印到统一的错误日志(common-error.log文件)中,便于追踪
  • 日志切割,这个很赞,可以按天、小时、文件大小进行切割,生成example-app-web.log.YYYY-MM-DD形式的日志文件
  • 自定义日志,我没用到,不过能自定义,那么扩展性和灵活度就很高
  • 高性能,这个官网解释是常规的日志都是在web访问这种高频操作下生成,每次打印日志都会进行磁盘IO,而Egg采用的是日志同步写入内存,异步每隔一段时间(默认 1 秒)刷盘这种策略,可以提高性能

部署流程

这个我会在后续文章里,结合其他两个项目讲一下,目前先给个大概的重构后的流程吧

本地开发 -> github webhook -> 阿里云镜像容器 -> docker镜像构建 -> 镜像发版 -> hook通知服务端jenkins -> jenkins拉取docker镜像 -> 启动容器 -> 邮件(QQ)通知 -> 完成部署

一些解决方案

ctx.body封装

每次写reponse的时候都需要

ctx.status = 200
ctx.body = {//...}

很烦,所以我这边就实现了一个封装reponse操作的中间件

现在config里定义下code map

// config/config.default.js

module.exports = appInfo => {
    const config = exports = {}

    config.codeMap = {
        '-1': '请求失败',
        200: '请求成功',
        401: '权限校验失败',
        403: 'Forbidden',
        404: 'URL资源未找到',
        422: '参数校验失败',
        500: '服务器错误'
        // ...
    }
}

然后实现以下中间件

// app/middleware/response.js
module.exports = (opt, app) => {
    const { codeMap } = app.config
    const successMsg = codeMap[200]
    const failMsg = codeMap[-1]

    return async (ctx, next) => {
        ctx.success = (data = null, message = successMsg) => {
            if (app.utils.validate.isString(data)) {
                message = data
                data = null
            }
            ctx.status = 200
            ctx.body = {
                code: 200,
                success: true,
                message,
                data
            }
        }
        ctx.fail = (code = -1, message = '', error = null) => {
            if (app.utils.validate.isString(code)) {
                error = message || null
                message = code
                code = -1
            }
            const body = {
                code,
                success: false,
                message: message || codeMap[code] || failMsg
            }
            if (error) body.error = error
            ctx.status = code === -1 ? 200 : code
            ctx.body = body
        }

        await next()
    }
}

然后就可以在controller里这样用了

// success
ctx.success() // { code: 200, success: true, message: codeMap[200] data: null }
ctx.success(any[], '获取列表成功') // { code: 200, success: true, message: '获取列表成功' data: any[] }

// fail
ctx.fail() // { code: -1, success: false, message: codeMap[-1], data: null }
ctx.fail(-1, '请求失败', '错误信息') // { code: -1, success: false, message: '请求失败', error: '错误信息', data: null }

自定义统一错误处理

对于Controll和Service抛出来的异常,比如

  • 接口参数校验失败抛出的异常
  • 内部一些网络请求服务失败抛出的异常
  • model查询失败抛出的异常
  • 业务逻辑自身主动抛出的异常

有时我们自定义异常的统一拦截处理,在这个拦截内可以根据自己业务定义的response code来做适配,这时可以利用koamiddleware来处理

// app/middleware/error.js
module.exports = (opt, app) => {
    return async (ctx, next) => {
        try {
            await next()
        } catch (err) {
            // 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
            ctx.app.emit('error', err, ctx)
            let code = err.status || 500

            // code是200,说明是业务逻辑主动抛出的异常,code = -1是因为我约定的错误请求status是-1
            if (code === 200) code = -1
            let message = ''
            if (app.config.isProd) {
                // 如果是production环境,就跟预先约定的请求code集进行匹配
                message = app.config.codeMap[code]
            } else {
                // dev环境下,那么久返回实际的错误信息了
                message = err.message
            }
            // 这里会统一reponse给client
            ctx.fail(code, message, err.errors)
        }
    }
}

server启动前的参数初始化

场景在上面也提到了,我的一些服务的配置参数是存在数据库中的,所以在服务启动前,也就需要先查询数据库中配置参数,然后再启动对应的服务,好在Egg提供了个自启动方法来解决

// app.js
module.exports = app => {
    app.beforeStart(async () => {
        const ctx = app.createAnonymousContext()
        const setting = await ctx.service.setting.getData()
        // 然后可以启动一些服务了,比如邮件服务,反垃圾评论服务等
        ctx.service.mailer.start()
    })
}

嗯,一切都进行的很顺利,直到我遇到了egg-alinode(阿里Node.js 性能平台),它的的启动是在agent里启动的,这个理所当然,因为它只是上报node runtime的一些系统参数给平台,所以这些脏活儿累活儿都交给agent去做了,不需要主进程和各个worker来管理

所以我就需要“异步”启动alinode服务了,而egg-alinode是在主进程启动后,fork agent进程初始化的时候就启动的,所以它是不支持这种我这种启动方式的,所以我就fork了egg-alinode的仓库稍微改造了一下,可以看看 egg-alinode-async ,在支持原功能的基础上,利用egg的IPC来通知agent初始化alinode服务

所以app.js的代码变成如下

module.exports = app => {
    app.beforeStart(async () => {
        const ctx = app.createAnonymousContext()
        const setting = await ctx.service.setting.getData()
        // ... 启动一些服务
        // production环境下异步启动alinode
        if (app.config.isProd) {
            // 利用IPC向agent发送启动alinode的event来异步启动服务
            app.messenger.sendToAgent('alinode-run', setting.keys.alinode)
        }
    })
}

这样就解决了我的全部的参数初始化的问题了

docker和docker-compose加持

这个第三篇文章《网站重构-Docker+Jenkins集成》会详细讲述

vscode调试egg

可以看看 VSCode 调试 Egg 完美版 - 进化史 这篇文章

不足之处

  • 测试case不完善(虽然test case很重要,但是我是真的不想写)
  • 没有用上TS(哈哈,为了用而用)
  • 日志目前还未完全持久化,想在后续把日志打包上传到阿里云存着

总结

写了这么多,回头看一遍,发现其实重构的地方还是蛮多的,从重构的原因到最后达到的效果,目前来看都还蛮好的。而且最近公司项目也需要重构,我也看了一些相关的文章,希望这写经验重构的时候能用到,也希望上面的那些解决方案对于有相同疑问的其他人会有些微帮助吧。最后话外谈下我这断时间来读的相关文章的一些感悟吧

重构讲究的是先明确why,when,再谈how,what,最后再来review,现在why和when都已经逐渐清晰了,势在必行。而how则是技术上结合业务给出的量化指标,方案设计和规范,以及后续的一些维护规划等,what就涉及到具体的系统技术上的实现了。总体其实规划下来,重构的复杂度并不亚于一个全新的产品,而且一定要重视重构中的非技术问题,如果单纯只是技术上的重构的话,那就需要再慎重审视一下 why和when了

嗯,就酱!

参考文章

相关文章
11 条评论
中国  -  深圳市 Mac OS X Chrome | 70

写得很好,赞一个

回复
中国  -  成都市 Windows 7 Chrome | 69

console.log(‘niubi’)

回复
中国  -  海口市 Linux Chrome | 63

md-editor用的是哪个插件的?支持nuxt吗?

5条回复
CN  -  Beijing Mac OS X Chrome | 69
console.log('Honey, I love you')
回复
中国  -  上海市 Mac OS X Chrome | 69

6666

1条回复