Eggjs 的 Controller 最佳實踐

辰秋發表於2018-08-23

起因

Router 描述了請求 URL 與 Controller 的對應關係。Eggjs 約定所有的路由都需要在 app/router.js 中申明,目錄結構如下:

┌ app
├── router.js
│  ├── controller
│  │  ├── home.js
│  │  ├── ...
複製程式碼

路由和對應的處理方法分開在 2 個地方維護,開發時經常需要在 router.jsController 之間來回切換。

前後臺協作時,後端需要為每個 Api 都生成一份對應的 Api 文件給前端。

更優雅的實現

得益於 JavaScript 加入的 decorator 特性,可以使我們跟 Java/C# 一樣,更加直觀自然的做面向切面程式設計:

// 基礎版
@route('/intro')
async intro() { }

// 定義 Method
@route('/intro', { method: 'post' })
async intro() { }

// 增加許可權
@route('/intro', { method: 'post', role: xxxRole })
async intro() { }

// Controller 級別中介軟體
@route('/intro', { method: 'post', role: xxxRole, beforeMiddleware: xxMiddleware })
async intro() { }
複製程式碼

為什麼是這樣的方案

為什麼設計如此複雜的功能,是不是在濫用 Decorator

先看看 route 的功能:

  • 路由定義
  • 引數校驗
  • 許可權
  • Controller 級別中介軟體

router 官方完整定義中包含的功能:路由定義、中介軟體、許可權,及文件中未直接寫的“許可權”:

router.verb('router-name', 'path-match', middleware1, ..., middlewareN, app.controller.action);
複製程式碼

比較下來會發現,只是多了“引數校驗”功能。

引數校驗

Eggjs 中引數校驗的官方實踐

class PostController extends Controller {
    async create() {
        const ctx = this.ctx;
        try {
            // 校驗引數
            // 如果不傳第二個引數會自動校驗 `ctx.request.body`
            ctx.validate(createRule);
        } catch (err) {
            ctx.logger.warn(err.errors);
            ctx.body = { success: false };
            return;
        }
    }
};
複製程式碼

在我們的業務實踐中這個方案會有 2 個問題:

  • 引數漏校驗

    比如使用者提交的資料為 { a: 'a', 'b': 'b', c: 'c' },如果校驗規則只定義了 a,那麼 bc 就被漏掉了,並且後續業務中可能會使用這 2 個值。

  • Eggjs 一個 request 生命週期內,可以隨時隨地通過 ctx.request 拿到使用者資料

    因為“引數漏校驗”問題的存在,導致後續業務變的不穩定,隨時可能會因為使用者的異常資料導致業務崩潰,或者出現安全問題。

解決方案

為了解決“引數漏校驗”問題,我們做了如下約定:

  • Controller 也需要申明入參

    class UserController extends Controller {
        @route('/api/user', { method: 'post' })
        async updateUser(username) {
            // ...
        }
    }
    複製程式碼

    上面的例子中,即使使用者提交了海量資料,業務程式碼中也只能拿到 username

  • Controller 之外的業務不應該直接訪問 ctx.request 上的資料

    也就是說,當某個 Service 方法依賴使用者資料時,應該通過入參獲取,而不是直接訪問 ctx.request

基於以上約定,分別看看 JS、TypeScript 下我們如何解決引數校驗問題:

  • JS

    @route('/api/user', {
        method: 'post',
        rule: {
            username: { type: 'string', max: 20 },
        }   
    })
    async updateUser(username) {
        // ...
    }
    複製程式碼

    這裡使用了 egg-validate 底層依賴的 parameter 作為校驗庫

  • TypeScript

    @route('/api/user', {
        method: 'post'
    })
    async updateUser(username: R<{ type: string, max: 20 }>) {
        // ...
    }
    複製程式碼

沒看錯,手動呼叫 ctx.validate(createRule) 並捕獲異常的邏輯確實被我們省略掉了。“懶惰”是提高生產力的第一要素。引數、規則都有了,為什麼還要自己擼程式碼呢?

新的前後端協作實踐

傳統的前後端開發協作方式中,後端提供 Api 給前端呼叫,程式碼類似這樣:

function updateUser() {
    request
        .post(`/api/user`, { username })
        .then(ret => {
            
        });
}
複製程式碼

前端同學需要關注路由、引數、返回值。而這些資訊 Controller 都已經有了,直接生成前臺 service 用起來是不是更方便呢:

  • Controller 程式碼:

    export class UserController {
    
      @route({ url: '/api/user' })
      async getUserInfo(id: number) {
        return { ... };
      }
    }
    複製程式碼
  • 生成的 service:

    export class UserService extends Base {
      /** 首頁  */
      async getUserInfo(id: number) {
        const __data = { id };
        return await this.request({
          method: `get`,
          url: `/api/user`,
          data: __data,
        });
      }
    
    }
    
    export const metaService = new UserService();
    export default new UserService();
    複製程式碼
  • 前臺使用

    import { userService } from 'service/user';
    
    const userInfo = await userService.getUserInfo(id);
    複製程式碼

    對比原來的寫法:

    function updateUser() {
        return new Promise((resolve, reject) => {
            request
            .post(`/api/user`, { username })
            .then(ret => {
                resolve(ret);
            }); 
        });
    }
    複製程式碼

    userService.getUserInfo 內部封裝了 request 邏輯,前端不需要在關心呼叫過程。

如何在自己的專案中使用

我們已經把最佳實踐抽象為了 egg-controller 外掛,可以按下面的步驟安裝使用:

  1. 安裝 egg-controller

    tnpm i -S egg-controller
    複製程式碼
  2. 啟用外掛

    開啟 config/plugin.js,增加以下配置

    aop: {
        enable: true,
        package: 'egg-aop',
    },
    controller: {
        enable: true,
        package: 'egg-controller',
    },
    複製程式碼
  3. 使用外掛

    詳細用法參考 egg-controller 文件