網站重構-後臺服務篇

Jooger發表於2018-10-10

寫在前面

生命不息,重構不止

這不是一篇純技術文章,只是一篇對這段是重構後端的總結

國慶前後差不多一個半月的時間,把自己的網站從資料庫,到後端,再到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中完成,而且是直接在邏輯中呼叫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了

嗯,就醬!

參考文章

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

相關文章