网站重构-后台服务篇

写在前面

生命不息,重构不止vue

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

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

博客地址:jooger.megithub

仓库地址:后端C端A端web

总共差很少200个commits吧,欢迎star,欢迎留言 😄。废话很少说,先来看下后端的重构经历吧mongodb

为何要重构后端?

缘由有如下几点docker

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

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

  1. 日志系统不完善

之前以为日志啥的不重要,没有造成日志备份,因此不少次线上故障缘由都无从查证,只能说之前太年轻express

  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中完成,并且是直接在逻辑中调用Model的接口,这样作有三个问题

  1. 逻辑臃肿,若是逻辑复杂的话,一个Controller代码会不少,可维护性差
  2. 每次调用Model层都要catch一下,没有作统一处理,修改起来很麻烦
  3. 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了

嗯,就酱!

参考文章

原文地址:网站重构-后台服务篇

相关文章
相关标签/搜索