如何用函數式編程思想優化業務代碼

導語 | 本文將介紹函數式編程中的幾個核心概念,以及使用相關的函數式編程來優化業務代碼的實踐方案。

一、前言

日常開發中經常會遇到流程分支多、流程長的業務邏輯,如果排期較爲緊張的話通常會選擇 if elseswitch case 一把梭。然而隨着迭代的推進,會有越來越多的新增流程分支或者需求變更,長此以往下去大多就成了 “祖傳代碼”。

隨着 EPC 的落地,對代碼中函數圈複雜度提出了要求,許多同學爲了規避代碼檢查選擇拆分函數,一行代碼分成三個函數寫,或者把原來的邏輯分支改成用映射匹配,這樣看來雖然圈複雜度確實降低了,但是對代碼的可維護性實際上是產生了損耗的。由於我最近做的需求大多也是這樣的場景,於是開始嘗試找尋一種模式來解決這個問題。

下圖爲流程圖示例,實際業務中的情況遠比下圖要複雜:

二、核心概念

(一)compose

compose 是函數式編程中使用較多的一種寫法,它把邏輯解耦在各個函數中,通過 compose 的方式組合函數,將外部數據依次通過各個函數的加工,生成結果。在此處我們不對函數式編程進行展開,感興趣的同學可以學習函數式編程指北

(參考網址:https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/)

下方代碼示例是當我們不使用 compose 希望組合使用多個函數時最簡單的調用方式。這裏我們只有 3 個函數,看起來還比較直觀,那麼如果當我們有 20 個函數時呢?

const funcA = (message) => message + " A";
const funcB = (message) => message + " B";
const funcC = (message) => message + " C";
const ret = funcC(funcB(funcA("Compose Example")));
console.log(ret); // Compose Example A B C

如下便是 compose 最基礎的實現,儘管大部分對於 compose 的定義,以及其他一些 fp 工具庫(比如 ramda、lodash-fp)對 compose 的定義和實現都是從右向左,但是我們這裏選擇右傾實現,如果你希望保持左傾的話,可以將下方函數中的 reduce 替換爲 reduceRight。

const compose = (...funcs) => {
  if (funcs.length === 0) {
    return (args) => args;
  }
  if (funcs.length === 1) {
    return funcs[0];
  }
  // 如果要使用左傾實現,可以將 reduce 替換爲 reduceRight
  return funcs.reduce((a, b) => (...args) => b(a(...args)));
};

使用 compose 組合函數後看看如何使用:

const fn = compose(funcA, funcB, funcC);
const ret = fn("Compose Example");
console.log(ret); // Compose Example A B C

相比於環環相扣的嵌套調用,使用 compose 將多個函數組合生成爲單個函數調用,使我們的代碼無論從可讀性還是可擴展性上都得到了提升。

(二)異步 compose

實際的應用場景我們不可能一個流程內全部爲同步代碼,可能會需要調用接口獲得數據後再進入下一個流程,也可能會需要調用 jsApi 和客戶端進行通信展示相應的交互。

如果要將 compose 改造爲支持異步調用也非常簡單,只需修改一行代碼即可。可以選擇用 Promise 進行擴展,這裏我們爲了保持同步的代碼風格,選擇使用 async/await 進行擴展,使用這種方式的話記得使用 try catch 兜底錯誤。

const asyncCompose = (...funcs) => {
  if (funcs.length === 0) {
    return (args) => args;
  }
  if (funcs.length === 1) {
    return funcs[0];
  }
  // 只需要修改這一行即可
  return funcs.reduce((a, b) => async (...args) => b(await a(...args)));
};

改造一下我們的測試代碼,看看效果:

// 支持異步函數的調用
const funcA = (message) => new Promise((resolve, reject) => {
  setTimeout(() => resolve(message + " A"), 1000);
});
const funcB = (message) => Promise.resolve(message + " B");
// 依然支持同步函數的調用
const funcC = (message) => message + " C";
const fn = compose(funcA, funcB, funcC);
(async() => {
  const ret = await fn("Compose Example");
  console.log(ret); // Compose Example A B C
})();

三、實踐方案

**(一)**koa-compose

在上面我們解決了異步函數的組合調用,在實際應用的場景中會發現,業務流程(funcs)有時候並不需要全部執行完畢,當接口的返回值非 0,或者用戶沒有權限進入下一個流程時,我們需要提前結束流程的執行,只有當用戶滿足條件時纔可以進入下一個流程。

這裏首先想到的設計方式即是 koa 的中間件模型,koa 最核心的功能就是它的中間件機制,中間件通過 app.use 註冊,運行的時候從最外層開始執行,遇到 next 後加入下一個中間件,執行完畢後回到上一個中間件,這就是大家耳熟能詳的洋蔥模型。

koa 大家基本都用過,基於 middleware 的設計模式也都非常熟悉了,同 koa middleware 保持相近的模式可以減少理解成本和心智負擔。但是我們並不需要 app.use 的註冊機制,因爲在代碼中不同的場景我們可能會需要組合不同的中間件,相比註冊機制,我更傾向於用哪些中間件則傳入哪些。

koa 中間件引擎源碼:

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }
  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

koa 已經將上方的中間件引擎提取爲單獨的 koa-compose,我們可以直接從 npm 安裝。

$ npm install koa-compose
# 或者
$ yarn add koa-compose

使用方式:

import compose from 'koa-compose';
import middlewares from './middleware';
import { Context, ContextStatus } from '@/types/libs/auth.d';
const run = async (...middlewares) => {
  const context: Context = {
    status: ContextStatus.pending,
    data: {},
  };
  try {
    const composition = compose(middlewares);
    await composition(context);
  } catch (e) {
    console.error(e);
    context.status = ContextStatus.rejected;
  }
  return context;
};
export * from './middleware';
export default run;

(二)middleware(中間件設計

最簡單的例子:

中間件的設計我們也可以參考 koa middleware 來設計,下方爲一個最簡單的示範,檢查用戶是否登錄,如果登錄則繼續執行下一個中間件,如果未登錄的話則拉起 jsApi 的登錄框。

export const checkIsLogin = async (ctx, next) => {
  console.log('checkIsLogin start');
  ctx.data.userInfo = await getUserInfo();
  if (!ctx.data.userInfo.uid) {
    ctx.data.userInfo = await jsApi.login();
  }
  if (!ctx.data.userInfo.uid) {
    return;
  }
  await next();
  console.log('checkIsLogin end');
};

支持傳參的中間件:

export const checkIsLogin = (options) => async (ctx, next) => {
  // TODO Something
  console.log(options);
  await next();
  // TODO Something
};

如何判斷中間件是否全部執行成功或者提前結束?

我們需要在 ctx.status 上記錄全部流程執行完畢的狀態,以便做最後的處理,這裏參考 Promise 的實現,選擇用 pending、fulfilled、rejected 來表示。

export enum ContextStatus {
  pending = 'pending',
  fulfilled = 'fulfilled',
  rejected = 'rejected',
}

如果在每個中間件內都需要手動設置 ctx.status 成功或者失敗,則會產生很多重複代碼,爲了我們的代碼簡潔,需要增加一個機制,可以自動檢查所有的中間件是否全部都正確的執行完畢,然後將結束狀態設置爲成功,可以自動檢查是否有中間件提前結束,將結束狀態設置爲失敗。我們需要新增 2 個通用中間件如下,分別置於全部中間件的開頭和結尾處。

  1. 檢查是否所有的中間件都從前到後執行完畢:
import { ContextStatus } from '@/types/libs/auth.d';
const checkIsEveryDone = async (ctx) => {
  console.log('checkIsEveryDone start');
  if (ctx.status === ContextStatus.pending) {
    ctx.status = ContextStatus.fulfilled;
  }
  console.log('checkIsEveryDone start');
};
export default checkIsEveryDone;
  1. 檢查是否有中間件沒有執行下去,提前結束:
import { ContextStatus } from '@/types/libs/auth.d';
const checkIsEarlyTurn = async (ctx, next) => {
  console.log('checkIsEarlyTurn start');
  await next();
  if (ctx.status !== ContextStatus.fulfilled) {
    ctx.status = ContextStatus.rejected;
  }
  console.log('checkIsEarlyTurn end');
};
export default checkIsEarlyTurn;

** 作者簡介**

**王宏宇
**

騰訊新聞前端工程師

騰訊新聞前端工程師,目前於騰訊新聞從事相關 Web 開發工作。致力於開發體驗提升,在代碼優化有較爲豐富的經驗。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9QjRFTm5KLw6HI-O7z5X7A