網站重構-後臺服務篇

寫在前面

生命不息,重構不止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了

嗯,就醬!

參考文章

原文地址:網站重構-後臺服務篇

相關文章
相關標籤/搜索