前端API層架構,也許你做得還不夠

上午好,今天爲大家分享下個人對於前端API層架構的一點經驗和看法。架構設計是一條永遠走不完的路,沒有最好,只有更好。這個道理適用於軟件設計的各個場景,前端API層的設計也不例外,如果您覺得在調用接口時還存在諸多槽點,那就說明您的接口層架構還待優化。今天我以vue + axios爲例,爲大家梳理下我的一些經歷和設想。

石器時代,痛苦

直接調用axios,真的痛苦,每個調用的地方都要進行響應狀態的判斷,冗餘代碼超級多。

import axios from "axios"

axios.get('/usercenter/user/page?pageNo=1&pageSize=10').then(res => {
    const data = res.data
    // 判斷請求狀態,success字段爲true代表成功,視前後端約束而定
    if (data.success) {
        // 結果成功後的業務代碼
    } else {
        // 結果失敗後的業務代碼
    }
})

看起來確實很難受,每調用一次接口,就有這麼多重複的工作!

青銅器時代,中規中矩

爲了解決直接調用axios的痛點,我們一般會利用Promiseaxios二次封裝,對接口響應狀態進行集中判斷,對外暴露get, post, put, deletehttp方法。

axios二次封裝

import axios from "axios"
import router from "@/router"
import { BASE_URL } from "@/router/base-url"
import { errorMsg } from "@/utils/msg";
import { stringify } from "@/utils/helper";
// 創建axios實例
const v3api = axios.create({
    baseURL: process.env.BASE_API,
    timeout: 10000
});
// axios實例默認配置
v3api.defaults.headers.common['Content-Type'] = 'application/x-www-form-urlencoded';
v3api.defaults.transformRequest = data => {
    return stringify(data)
}
// 返回狀態攔截,進行狀態的集中判斷
v3api.interceptors.response.use(
    response => {
        const res = response.data;
        if (res.success) {
            return Promise.resolve(res)
        } else {
            // 內部錯誤碼處理
            if (res.code === 1401) {
                errorMsg(res.message || '登錄已過期,請重新登錄!')
                router.replace({ path: `${BASE_URL}/login` })
            } else {
                // 默認的錯誤提示
                errorMsg(res.message || '網絡異常,請稍後重試!')
            }
            return Promise.reject(res);
        }
    },
    error => {
        if (/timeout\sof\s\d+ms\sexceeded/.test(error.message)) {
            // 超時
            errorMsg('網絡出了點問題,請稍後重試!')
        }
        if (error.response) {
            // http狀態碼判斷
            switch (error.response.status) {
                // http status handler
                case 404:
                    errorMsg('請求的資源不存在!')
                    break
                case 500:
                    errorMsg('內部錯誤,請稍後重試!')
                    break
                case 503:
                    errorMsg('服務器正在維護,請稍等!')
                    break
            }
        }
        return Promise.reject(error.response)
    }
)

// 處理get請求
const get = (url, params, config = {}) => v3api.get(url, { ...config, params })
// 處理delete請求,爲了防止和關鍵詞delete衝突,方法名定義爲deletes
const deletes = (url, params, config = {}) => v3api.delete(url, { ...config, params })
// 處理post請求
const post = (url, params, config = {}) => v3api.post(url, params, config)
// 處理put請求
const put = (url, params, config = {}) => v3api.put(url, params, config)
export default {
    get,
    deletes,
    post,
    put
}

調用者不再判斷請求狀態

import api from "@/api";

methods: {
    getUserPageData() {
        api.get('/usercenter/user/page?pageNo=1&pageSize=10').then(res => {
            // 狀態已經集中判斷了,這裏直接寫成功的邏輯
            // 業務代碼......
            const result = res.result;
        }).catch(res => {
            // 失敗的情況寫在catch中
        })
    }
}

async/await改造

使用語義化的異步函數

methods: {
    async getUserPageData() {
        try {
           const res = await api.get('/usercenter/user/page?pageNo=1&pageSize=10') 
           // 業務代碼......
           const { result } = res;
        } catch(error) {
            // 失敗的情況寫在catch中
        }
    }
}

存在的問題

  • 語義化程度有限,調用接口還是需要查詢接口url
  • 前端api層難以維護,如後端接口發生改動,前端每處都需要大改。
  • 如果UI組件的數據模型與後端接口要求的數據結構存在差異,每處調用接口前都需要進行數據處理,抹平差異,比如[1,2,3]1,2,3這種(當然,這只是最簡單的一個例子)。這樣如果數據處理不慎,調用者出錯機率太高!
  • 難以滿足特殊化場景,舉個例子,一個查詢的場景,後端要求,如果輸入了搜索關鍵詞keyword,必須調用/user/search接口,如果沒有輸入關鍵詞,只能調用/user/page接口。如果每個調用者都要判斷是不是輸入了關鍵詞,再決定調用哪個接口,你覺得出錯機率有多大,用起來煩不煩?
  • 產品說,這些場景需要優化,默認按創建時間降序排序。我擦,又一個個改一遍?

那麼怎麼解決這些問題呢?請耐心接着看…

鐵器時代,it’s cool

我想到的方案是在底層封裝和調用者之間再增加一層API適配層(適配層,取量身定製之意),在適配層做統一處理,包括參數處理,請求頭處理,特殊化處理等,提煉出更語義化的方法,讓調用者“傻瓜式”調用,不再爲了查找接口url和處理數據結構這些重複的工作而煩惱,把ViewModel層綁定的數據模型直接丟給適配層統一處理。

對齊微服務架構

首先,爲了對齊後端微服務架構,在前端將API調用分爲三個模塊。

├─api
    index.js axios底層封裝
    ├─base  負責調用基礎服務,basecenter
    ├─iot  負責調用物聯網服務,iotcenter
    └─user  負責調用用戶相關服務,usercenter

每個模塊下都定義了統一的微服務命名空間,例如/src/api/user/index.js

export const namespace = 'usercenter';

特性模塊

每個功能特性都有獨立的js模塊,以角色管理相關接口爲例,模塊是/src/api/user/role.js

import api from '../index'
import { paramsFilter } from "@/utils/helper";
import { namespace } from "./index"
const feature = 'role'

// 添加角色
export const addRole = params => api.post(`/${namespace}/${feature}/add`, paramsFilter(params));
// 刪除角色
export const deleteRole = id => api.deletes(`/${namespace}/${feature}/delete`, { id });
// 更新角色
export const updateRole = params => api.put(`/${namespace}/${feature}/update`, paramsFilter(params));
// 條件查詢角色
export const findRoles = params => api.get(`/${namespace}/${feature}/find`, paramsFilter(params));
// 查詢所有角色,不傳參調用find接口代表查詢所有角色
export const getAllRoles = () => findRoles();
// 獲取角色詳情
export const getRoleDetail = id => api.get(`/${namespace}/${feature}/detail`, { id });
// 分頁查詢角色
export const getRolePage = params => api.get(`/${namespace}/${feature}/page`, paramsFilter(params));
// 搜索角色
export const searchRole = params => params.keyword ? api.get(`/${namespace}/${feature}/search`, paramsFilter(params)) : getRolePage(params);
  • 每一條接口都根據RESTful風格,調用增(api.post)刪(api.deletes)改(api.put)查(api.get)的底層方法,對外輸出語義化方法。

  • 調用的url由三部分組成,格式:/微服務命名空間/特性命名空間/方法

  • 接口適配層函數命名規範:

    • 新增:addXXX
    • 刪除:deleteXXX
    • 更新:updateXXX
    • 根據ID查詢記錄:getXXXDetail
    • 條件查詢一條記錄:findOneXXX
    • 條件查詢:findXXXs
    • 查詢所有記錄:getAllXXXs
    • 分頁查詢:getXXXPage
    • 搜索:searchXXX
    • 其餘個性化接口根據語義進行命名

解決問題

  • 語義化程度更高,配合vscode的代碼提示功能,用起來不要太爽!

  • 迅速響應接口改動,適配層統一處理

  • 集中進行數據處理(對於公用的數據處理,我們用paramsFilter解決,對於特殊的情況,再另行處理),調用者安心做業務即可

  • 滿足特殊場景,佛系應對後端和產品朋友

    • 針對上節提到的關鍵字查詢場景,我們在適配層通過在入參中判斷是否有keyword字段,決定調用search還是page接口。對外我們只需暴露searchRole方法,調用者只需要調用searchRole方法即可,無需做其他考慮。
    export const searchRole = params => params.keyword ? api.get(`/${namespace}/${feature}/search`, paramsFilter(params)) : getRolePage(params);
    
    • 針對產品突然加的排序需求,我們可以在適配層去做默認入參的處理。

    首先,我們新建一個專門管理默認參數的js,如src/api/default-options.js

    // 默認按創建時間降序的參數對象
    export const SORT_BY_CREATETIME_OPTIONS = {
        sortField: 'createTime',
        // desc代表降序,asc是升序
        sortType: 'desc'
    }
    

    接着,我們在接口適配層做集中化處理

    import api from '../index'
    import { SORT_BY_CREATETIME_OPTIONS } from "../default-options"
    import { paramsFilter } from "@/utils/helper";
    import { namespace } from "./index"
    const feature = 'role'
    
    export const getRolePage = params => api.get(`/${namespace}/${feature}/page`, paramsFilter({ ...SORT_BY_CREATETIME_OPTIONS, ...params }));
    

    SORT_BY_CREATETIME_OPTIONS放在前面,是爲了滿足如果出現其他排序需求,調用者傳入的排序字段能覆蓋掉默認參數。

mock先行

一個完善的API層設計,肯定是離不開mock的。在後端提供接口之前,前端必須通過模擬數據並行開發,否則進度無法保證。那麼如何設計一個跟真實接口契合度高的mock系統呢?我這裏簡單做下分享。

  • 首先,創建mock專用的axios實例

我們在src目錄下新建mock目錄,並在src/mock/index.js簡單封裝一個axios實例

// 僅限模擬數據使用
import axios from "axios"
const mock = axios.create({
    baseURL: ''
});
// 返回狀態攔截
mock.interceptors.response.use(
    response => {
        return Promise.resolve(response.data)
    },
    error => {
        return Promise.reject(error.response)
    }
)

export default mock
  • mock同樣也要分模塊,以usercenter微服務下的角色管理mock接口爲例
├─mock
    index.js mock底層axios封裝
    ├─user  負責調用基礎服務,usercenter
        ├─role
            ├─index.js

我們在src/mock/user/role/index.js中簡單模擬一個獲取所有角色的接口getAllRoles

import mock from "@/mock";

export const getAllRoles = () => mock.get('/static/mock/user/role/getAllRoles.json')

可以看到,我們是在mock接口中獲取了static/mock目錄下的json數據。因此我們需要根據接口文檔或者約定好的數據結構準備好getAllRoles.json數據

{
    "success": true,
    "result": {
        "pageNo": 1,
        "pageSize": 10,
        "total": 2,
        "list": [
            {
                "id": 1,
                "createTime": "2019-11-19 12:53:05",
                "updateTime": "2019-12-03 09:53:41",
                "name": "管理員",
                "code": "管理員",
                "description": "一個擁有部分權限的管理員角色",
                "sort": 1,
                "menuIds": "789,2,55,983,54",
                "menuNames": "數據字典, 後臺, 賬戶信息, 修改密碼, 賬戶中心"
            },
            {
                "id": 2,
                "createTime": "2019-11-27 17:18:54",
                "updateTime": "2019-12-01 19:14:30",
                "name": "前臺測試",
                "code": "前臺測試",
                "description": "一個擁有部分權限的前臺測試角色",
                "sort": 2,
                "menuIds": "15,4,1",
                "menuNames": "油耗統計, 車聯網, 物聯網監管系統"
            }
        ]
    },
    "message": "請求成功",
    "code": 0
}
  • 我們來看看mock是怎麼做的

先看下真實接口的調用方式

import { getAllRoles } from "@/api/user/role";

created() {
    this.getAllRolesData()
},
methods: {
    async getAllRolesData() {
        const res = await getAllRoles()
        console.log(res)
    }
}

那麼mock時怎麼做呢?非常簡單,只要將mock中提供的方法替代掉api提供的方法即可。

// import { getAllRoles } from "@/api/user/role";
import { getAllRoles } from "@/mock/user/role";

可以看到,這種mock方式與調用真實接口的契合度還是挺高的,正式調試接口時,只需將註釋的代碼調整即可,過渡非常平滑!

  • 注意,在生產環境下,爲了防止打包時將static/mock目錄下的內容copydist目錄下,我們需要配置下CopyWebpackPlugin,以vue-cli@2爲例,我們修改webpack.base.conf.js即可。
const devMode = process.env.NODE_ENV === 'development';

new CopyWebpackPlugin([
    {
        from: path.resolve(__dirname, '../static'),
        to: devMode ? config.dev.assetsSubDirectory : config.build.assetsSubDirectory,
        ignore: devMode ? '' : 'mock/**/*'
    }
])

蒸汽時代,真香

下一步的設想,使用類型安全的typescript,讓前端API層真正做到面向接口文檔編程,規範入參,出參,可選參數,等等,提高可維護性,在編碼階段就大大降低出錯機率。雖然還在重構階段,但是我想說,重拾typescript是真香,突然懷念使用Angular的那兩年了,期待vue3.0能將typescript結合得更加完美…

電氣時代,更多暢想

未來還有無限可能,面對日漸複雜和多樣化的業務場景,我們會提煉出更好的架構和設計模式。目前有一個不成熟的設想,是否能在接口設計上做到更規範化,後端輸出接口文檔的同時,提煉出API json之類的數據結構?前端拿到API json,通過nodejs文件編程的能力,自動化生成前端接口層代碼,解放雙手。

結語

當然,以上只是我的一點點經驗和設想,是在我能力範圍內能想到的東西,希望能幫助到一些有需要的同學。如果大佬們有更好的經驗,可以指點一二。

發佈了98 篇原創文章 · 獲贊 43 · 訪問量 20萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章