どのようにDeliverooはReduxのミドルウェアを使用してHTTPクライアントをラップする


この記事の読者は、非同期のデータフローについて、解釈できなければなりません.状態、アクション、および還元器のようなReduxコアの概念に関する知識は、ここで適用されますが、ここで適用される原則は、どんなHTTPクライアントでも構築されるかもしれません.
今日、Deliverooが慎重に設計された行動を通してAPIクライアント層を構成するためにReduxミドルウェアを使用した方法について話します.
Redux Middlewaresについての簡単な紹介の後、Deliverooが彼らのAPI Reduxミドルウェアを造った方法のステップバイステップの分析で問題に飛び込んでください.

Reduxミドルウェア
ミドルウェアはreduxに特有ではありません.例えば、Expressフレームワークはstack of middleware functions . それらの機能は要求/応答サイクルの中央に座ります.
によるとRedux doc about middlewares :

Redux middleware provides a third-party extension point between dispatching an action, and the moment it reaches the reducer. People use Redux middleware for logging, crash reporting, talking to an asynchronous API, routing, and more.


最も一般的なミドルウェアの一つはredux thunkです.
// https://github.com/reduxjs/redux-thunk/blob/master/src/index.js

const thunkMiddleware = ({ dispatch, getState }) => (next) => (action) => {
  if (typeof action === 'function') {
    return action(dispatch, getState, extraArgument);
  }

  return next(action);
};
ご覧の通り、それはかなり簡単ですdispatch が関数である場合、それ以外の場合には、返り値next(action) . これは、還元器への途中でアクションをインターセプトする便利な方法であり、その型に基づいていくつかのロジックを実行します.
The ({ dispatch, getState }) => (next) => (action) => { ... } 構文は奇妙に見えるかもしれませんが、実際にはarrow functions . 次のように書き換えることができます.
function thunkMiddleware({ dispatch, getState }) {
  return function wrapDispatch(next) {
    return function handleAction(action) {
      if (typeof action === 'function') {
        return action(dispatch, getState, extraArgument);
      }

      return next(action);
    }
  }
};
あなたがreduxミドルウェアについてもっと知りたいならば.the Redux documentation 実装の背後にあるロジックについてのすばらしいセクションがあります.

ミドルウェア
Deliveroo イギリスからの食糧供給のスタートアップです.彼らのフロントエンドのアプリは、2019年7月のように、NextJSとReduxで構築された反応ユニバーサルアプリだった.この部分で紹介されたコードはsourcemaps . 残念なことにSourceMapsはDeliverooでもう利用できません.したがって、このコードは2019年7月にアプリの状態を反映しています.今日は違うかもしれない.
Deliverooは、APIクライアントをラップするためにReduxミドルウェアを使用しました:特定のタイプのすべてのアクションは、APIを要求し、応答を正常化し、適切にディスパッチすることを気にするミドルウェアによってピックアップされていますSUCCESS or FAILURE API呼び出しの結果に応じた動作.
サーバーの考慮事項は以下のコードスニペットから削除されました.簡単にするために、このポストの範囲を超えています.更なるADOなしで、Deliverooのコードに飛び込んで、彼らのミドルウェア実装から重要な乗っ取りを得ましょう.

APIへのアクションコールのインターセプト
このミドルウェアのために書いた仕様書から始めましょう.
/*
  A middleware for making requests to the deliveroo API
  =====================================================

  Any action that returns an object that has the following shape will be
  picked up by this function

  {
    type: 'LOGIN',
    endpoint: '/orderapp/v1/login', // the hook for this middleware.
    authorized: false,              // send autorization headers?
    options: {
      ...fetchOptions,
    },
    onSuccess: (res) => undefined,
    onFailure: (res) => undefined,
  }

*/
ピックアップするそのようなアクションのためのprerequesiteはendpoint キー.これは以下のようになります.
// utils/requestHelper.js

const api = (store) => (next) => (action) => {
  // If the object doesn't have an endpoint, pass it off.
  if (!action.endpoint) return next(action);
}
アクションオブジェクトならばendpoint キーは未定義です、我々は使用している次のミドルウェア呼び出しを返しますreturn next(action)
リクエストオプション
アクションアーキテクチャでは、いくつかのカスタムオプションを受信API要求に渡すことができます.これらのオプションは、reduxストアで利用可能なデフォルトのオプションと設定と共に、fetch コール.
// middleware/api.js

var JSON_MEDIA_TYPE = 'application/json';
var JSONAPI_MEDIA_TYPE = 'application/vnd.api+json';
var defaultOptions = {
  headers: {
    'content-type': JSON_MEDIA_TYPE,
    accept: [JSON_MEDIA_TYPE, JSONAPI_MEDIA_TYPE].join(', ')
  },
  credentials: 'omit',
  // Set an aggressive default timeout, to prevent outbound calls queueing
  timeout: 5000
};

const api = (store) => (next) => (action) => {
  if (!action.endpoint) return next(action);

  // Building the request options
  const options = {};
  const { request, config } = store.getState();
  const requestOptions = {
    headers: buildHeadersFromRequest({ request, config })
  };

  defaultsDeep(options, action.options, requestOptions, defaultOptions);

  next({ type: `${action.type}_REQUEST` });

  // Default to the orderweb API host unless an action overrides
  const host = action.host || configUtil.orderappApiHost;

  if (!host) {
    throw new Error('Unable to find valid API host for fetch');
  }
  const url = `${host}${action.endpoint}`;
}
The buildHeadersFromRequest 関数はreduxストアに格納されているリクエスト関連データに関する情報を提供します.
// utils/requestHelper.js

export const buildHeadersFromRequest = ({ request, config = {} }) => {
  const {
    apiAuth,
    country,
    currentUrl,
    ip,
    locale,
    referer,
    rooGuid,
    rooSessionGuid,
    rooStickyGuid,
    userAgent,
  } = request;

  const authorizationHeader = (requestApiAuth) => {
    if (!requestApiAuth) {
      return '';
    }
    if (requestApiAuth.indexOf('.') !== -1) {
      // Only JWT based API Auth will have a period in it
      return `Bearer ${requestApiAuth}`;
    }
    // Opaque-token based authentication with Orderweb
    return `Basic ${requestApiAuth}`;
  };

  /*
    Use the sticky guid from
      - The cookie in the request if present.
      - From config if a cookie isn't set.
    If neither option has a stickyguid fallback to the users normal guid.
  */
  const stickyGuid = rooStickyGuid || config.rooStickyGuid || rooGuid;

  return Object.assign(
    {},
    {
      'Accept-Language': locale,
      Authorization: authorizationHeader(apiAuth),
      'User-Agent': `${userAgent} (deliveroo/consumer-web-app; browser)`,
      'X-Roo-Client': 'consumer-web-app',
      'X-Roo-Client-Referer': referer || '',
      'X-Roo-Country': country.tld,
      'X-Roo-Guid': rooGuid,
      'X-Roo-Session-Guid': rooSessionGuid,
      'X-Roo-Sticky-Guid': stickyGuid,
    },
  );
};
それらのヘッダーは、主にロケール、認可と追跡に関連しています.

リクエスト作成
一旦すべてがセットされると、API呼び出しは使用されますfetch :
// middleware/api.js

const api = (store) => (next) => (action) => {
  // ACTION INTERCEPTION

  // OPTIONS SETUP

  return fetch(url, options)
    .then(response) => {
      // RESPONSE HANDLING
    }
}

レスポンスの処理
呼び出し自体は非常に洞察力ではありませんが、応答の処理方法は興味深いです.まずは「不幸なパス」から始めましょう200 OK :
// middleware/api.js

const api = (store) => (next) => (action) => {
  // ACTION INTERCEPTION

  // OPTIONS SETUP

  return fetch(url, options)
    .then((response) => {
      if (!response.ok) {
        // If the response is not okay and we don't recieve json content
        // return data as undefined.
        const contentType = response.headers.get('content-type');
        const contentLength = response.headers.get('content-length');

        if (contentLength === '0') {
          // eslint-disable-next-line prefer-promise-reject-errors
          return Promise.reject({
            data: { message: response.statusText },
            status: response.status,
          });
        }

        if (contentType && contentType.indexOf(JSON_MEDIA_TYPE) !== -1) {
          return response
            .json()
            .catch(
              // eslint-disable-next-line prefer-promise-reject-errors
              (err) => Promise.reject({ data: err, status: response.status }),
            )
            .then(
              // eslint-disable-next-line prefer-promise-reject-errors
              (data) => Promise.reject({ data, status: response.status }),
            );
        }

        // eslint-disable-next-line prefer-promise-reject-errors
        return Promise.reject({ data: undefined, status: response.status });
      }
}
レスポンスがOK , 拒否された約束オブジェクトが返されます.オブジェクトのデータはAPIからの応答によって異なります.JSONデータが応答に存在するとき、我々はそれを解析して、それを拒絶された約束オブジェクトに加えます.このメソッドは、catch から直接fetch コール.エーcreateExceptionHandler メソッドは、エラーがError (これは.json() 例えば、失敗したり、パイプの下にredux減速器で処理される失敗アクションをディスパッチすることによって.
// middleware/api.js

export const createExceptionHandler = (next, action) => (error) => {
  const isError = error instanceof Error;

  if (isError) {
    throw error;
  }

  let status = error.status || 500;
  const data = error.data || {};

  next({
    type: `${action.type}_FAILURE`,
    status,
    message: data.message || error.message,
    payload: {
      ...data,
    },
  });

  if (action.onFailure) action.onFailure(data);
};

const api = (store) => (next) => (action) => {
  // ACTION INTERCEPTION

  // OPTIONS SETUP

  return fetch(url, options)
    .then(response) => {
      if (!response.ok) {
        // Promis.reject(...)
      }
    }
    .catch(createExceptionHandler(next, action))
}
「ハッピーパス」も同様に扱われます.
// middleware/api.js

export const JSONResponseHandler = (response, action) => (data) => {
  let parsedData;
  try {
    parsedData = JSON.parse(data);
  } catch (error) {
    // If the JSON fails to parse report an error to Sentry and add some
    // additional context for debugging. Then return a promise rejection.
    const err = new Error(
      `API Middleware - Browser: Failed To Parse JSON`,
    );

    return Promise.reject(err);
  }

  if (!parsedData) {
    // If the JSON successfully parses but the data is a falsey value,
    // i.e null, undefined, empty string.
    // Report the error to Sentry and return a promise rejection as
    // these values are likely to crash in the Reducers.
    const err = new Error(
      `API Middleware - Browser: Invalid JSON Response`,
    );
    Sentry.withScope((scope) => {
      scope.setExtras({
        action: action.type,
        status: response.status,
        data,
      });

      captureException(err);
    });
    return Promise.reject(err);
  }

  // If the JSON parses successfully and there is a body of data then return
  // the following block.
  return {
    payload: { ...parsedData },
    status: response.status,
    headers: response.headers,
  };
};

const api = (store) => (next) => (action) => {
  // ACTION INTERCEPTION

  // OPTIONS SETUP

  return fetch(url, options)
    .then(response) => {
      if (!response.ok) {
        // Promis.reject(...)
      }
    }

    if (response.status === 204) {
        return {
          payload: {},
          status: response.status,
          headers: response.headers,
        };
      }

      return response.text().then(JSONResponseHandler(response, action));
    }
    .catch(createExceptionHandler(next, action))
}
サーバが204 No Content , 空のペイロードを持つ単純なオブジェクトは返されます、さもなければ、応答はJSONResponseHandler , 次に、JSONデータを解析し、解析エラーを処理します.応答ヘッダ、ステータス、およびそのペイロードとして解析されたデータを返すオブジェクトが返されます.
多くのケースとエラーが起こることができるので、応答処理は非常に複雑です.ここでは、外部関数を使用して応答や実行を処理することによって複雑さを減らします.エラー表面のときに約束を拒否すると、グローバルエラーハンドラをcreateExceptionHandler .

家に帰る
重税は我々の後にある.レスポンスを正常に処理した後に、いくつかのデータ処理が必要です(データの逆符号化、平坦化…)それを通過する前に、ミドルウェアパイプライン.このデータ処理は、その動作においてDeliverooのニーズに純粋に調印されており、jsonApiParser ):
// midlleware/api.js

const api = (store) => (next) => (action) => {
  // ACTION INTERCEPTION

  // OPTIONS SETUP

  return fetch(url, options)
    .then(response) => {
      if (!response.ok) {
        // Promis.reject(...)
      }

      return response.text().then(JSONResponseHandler(response, action));
    }
    .then((response) => {
      const contentType = response.headers.get('content-type');
      if (contentType === JSONAPI_MEDIA_TYPE) {
        return {
          ...response,
          payload: jsonApiParser(response.payload),
        };
      }

      return response;
    })
    .catch(createExceptionHandler(next, action))
}
一旦データが我々のニーズに合わせられるならば、我々は最終段階に動くことができます:
// middleware/api.js

const api = (store) => (next) => (action) => {
  // ACTION INTERCEPTION

  // OPTIONS SETUP

  return fetch(url, options)
    .then(response) => {
      if (!response.ok) {
        // Promis.reject(...)
      }

      return response.text().then(JSONResponseHandler(response, action));
    }
    .then((response) => {
      // DATA PROCESSING
     })
    .then((response) => {
      const requestKeys = action.payload ? Object.keys(action.payload) : [];
      const responseKeys = response.payload ? Object.keys(response.payload) : [];

      requestKeys.filter((key) => responseKeys.indexOf(key) !== -1).forEach((key) =>
        // eslint-disable-next-line no-console
        console.warn(`API middleware: clashing keys in the payload field. Overriding: ${key}`),
      );

      const newAction = {
        type: `${action.type}_SUCCESS`,
        status: response.status,
        payload: {
          ...action.payload,
          ...response.payload,
        },
        meta: {
          apiMiddleware: action,
        },
      };

      next(newAction);

      if (action.onSuccess) action.onSuccess(newAction);
    }
要求と応答キーが衝突しているなら、メッセージはコンソールに記録されます、デバッグ目的のためにSentry . 最後にSUCCESS Reduxアクションは、以前の手順からすべてのデータを使用して構築されます:応答ステータス、アクション、および応答ペイロードだけでなく、メタデータ.アクションはミドルウェアスタックをnext(newAction) . アクションオブジェクトはonSuccess いくつかのカスタム動作をアクションごとに実行するコールバック関数.

実世界行動
我々がちょうど視点に分析したものを置くために、Dreverooのクライアントからとられる現実世界の例よりよいですか?
// actions/orderActions.js

export function getOrderHistory() {
  return (dispatch, getState) => {
    const { unverifiedUserId } = getState().request;

    const currentPageIndex = getState().order.history.orderHistoryPage;
    const pageOffset = ORDERS_PER_ORDER_HISTORY_PAGE * currentPageIndex;

    if (unverifiedUserId) {
      return dispatch({
        type: ORDER_HISTORY,
        /* TODO: Remove + 1 from ORDERS_PER_ORDER_HISTORY_PAGE once we get
        proper pagination from API */
        endpoint: `/orderapp/v1/users/${unverifiedUserId}/orders?limit=${ORDERS_PER_ORDER_HISTORY_PAGE +
          1}&offset=${pageOffset}`,
        payload: {
          /*
            TODO: This is to allow dummy data. This is not on page load,
            but only after clicking load more history
          */
          clickAndCollectOn: isFeatureActive(getState(), OH_CLICK_AND_COLLECT),
        },
        onSuccess: (response) => {
          /* TODO: Remove once we get proper pagination from API */
          if (response.payload.orders.length <= ORDERS_PER_ORDER_HISTORY_PAGE) {
            dispatch(hideLoadMoreOrderHistoryButton());
          }
        },
      });
    }

    return Promise.resolve();
  };
}
ここでは、ユーザーの注文履歴を取得するためのアクションです.つは、使用の通知にonSuccess 機能は、注文の長さに応じて“隠すボタン”アクションを派遣する.

持ち帰り
この記事では、DeliverooエンジニアがどのようにAPIクライアントをラップするためにReduxミドルウェアを実装したかを発見しました.これは、別のアクションの間のロジックの重複を避けるために、標準的な方法を提供するAPIとの通信だけでなく、標準化された応答を1つの期待することができます提供していますleast surprise way .
ミドルウェアはリクエストのライフサイクルで発生する可能性のあるレスポンスやエラーをすべて扱う.さらに、慎重に実装された機器は、Entryを使用して、エンジニアが効率的に予期しない動作をデバッグすることができます.
これは、HTTPクライアントの実装とREDUXミドルウェア機能の偉大なデモンストレーションです.