0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Expressリバースプロキシを使う場面を整理する

0
Posted at

はじめに

前回は、Express と http-proxy-middleware で作ったリバースプロキシで、リクエストヘッダやパスがどう転送されるかを確認しました。

リバースプロキシは、仕組みだけ見れば「クライアントから受け取ったリクエストを別のサーバへ転送するもの」でしかありません。

ただ実際に使ってみると、フロントエンドと外部APIの間に中継点があるだけで、URLの切り替えやヘッダの追加、ログの取り方などを整理しやすくなります。

この記事では、Express と http-proxy-middleware を使ったリバースプロキシが活用できそうな場面を、具体例とともに整理します。

動作確認には、前回同様 https://httpbingo.org を使います。

使う場面の一覧

この記事では、次のような場面を想定します。

  • フロントエンドから同じ入口でAPIを呼びたい:
    画面側では /api/... のような相対パスだけを使い、実際の向き先はプロキシ側で決めます。

  • APIの実体URLをクライアント側から分離したい:
    ステージング、本番、検証用APIなどの違いを、フロントエンドコードに持ち込まないようにします。

  • 共通ヘッダをプロキシ側で付けたい:
    アプリケーション名、トレースID、内部向けヘッダ、認証情報などを中継時に追加します。

  • パスの違いを吸収したい:
    画面側の /api/users と、転送先APIの /v1/users のような差分をプロキシ側で変換します。

  • レスポンスやエラーの扱いを揃えたい:
    外部APIのステータスコードや通信失敗をプロキシ側で分類し、必要に応じて処理します。

  • リクエストやレスポンスのログを取りたい:
    ブラウザから外部APIまでの通信をプロキシ側で観測し、調査しやすくします。

  • フロントエンド側の制御をバックエンド側に寄せたい:
    ヘッダ追加、ログ、エラー処理、転送先切り替えなどを各画面ではなくプロキシ側の入口に集約します。

どの場面も、プロキシの機能自体が主役というより、フロントエンドと外部APIの間に中継点を挟むことで何を整理できるか、という話だと思っています。

以降で、それぞれの場面を具体的なイメージとともに見ていきます。

使う場面1: フロントエンドから同じ入口でAPIを呼びたい

フロントエンドから複数のAPIを呼ぶアプリケーションでは、画面側のコードに外部APIのURLが散らばりやすくなります。

fetch("https://api.example.com/users");
fetch("https://api.example.com/orders");

動くには動きますが、画面側のコードに外部APIのホスト名が直接出てきます。

そのため、APIの向き先を変えたいときや、別のAPIを追加したいときに、画面側のコードまで意識する必要が出てきます。

ここにリバースプロキシを挟むと、フロントエンド側は自分のアプリケーションの /api/... を呼ぶだけで済むようになります。

fetch("/api/users");
fetch("/api/orders");
ブラウザ
  |
  | /api/users
  v
Express
  |
  | https://api.example.com/users
  v
外部API

こうしておくと、フロントエンド側から見たAPIの入口がシンプルになります。

画面側は「アプリケーション内の /api を呼ぶ」とだけ考えればよく、実際にどのホストへ転送するかはプロキシ側の管理に任せられます。

動作確認

手元のサンプルでは、/api/get へアクセスすると https://httpbingo.org/get に転送されるようにしています。

curl -s "http://localhost:3000/api/get?name=sample"
{
  "args": {
    "name": [
      "sample"
    ]
  },
  "method": "GET",
  "url": "https://httpbingo.org/get?name=sample"
}

ここで見たいのはレスポンスの中身そのものより、クライアント側の呼び出し先を /api/get に寄せつつ、実際の転送先はプロキシ側で決められる、という点です。

使う場面2: APIの実体URLをクライアント側から分離したい

開発中は検証用APIを使いたいけれど、本番では本番APIに向けたい、というのはよくある話です。

フロントエンドに外部APIのURLを直接書いていると、環境ごとの差分が画面側のコードに入り込みます。

fetch("https://staging-api.example.com/users");

本番環境では、これを別のURLに変える必要があります。

fetch("https://api.example.com/users");

ここでもリバースプロキシを挟んでおけば、フロントエンド側は環境を意識せず同じパスを呼ぶだけで済みます。

fetch("/api/users");

転送先の違いは、プロキシ側の環境変数や設定で吸収します。

たとえば、開発環境では検証用APIに向けます。

.env.development
TARGET_URL=https://staging-api.example.com

本番環境では、本番APIに向けます。

.env.production
TARGET_URL=https://api.example.com

この仕組みは、APIのURLを完全に隠すというよりも、フロントエンドに外部APIの向き先を直接書かなくて済むようにするためのものです。

画面側は「ユーザー一覧を取る」という意図に集中し、どの環境のどのホストへ向けるかはプロキシ側で決めます。

たとえば手元の確認では、TARGET_URLhttps://httpbingo.org にしておけば、実際の外部APIを用意しなくてもプロキシの向き先を差し替えるイメージを確認できます。

開発環境:
  /api/... -> https://httpbingo.org/...

本番環境:
  /api/... -> https://api.example.com/...

使う場面3: 共通ヘッダをプロキシ側で付けたい

外部APIを呼ぶときに、毎回同じヘッダを付けたいことがあります。

たとえば、アプリケーション名、トレースID、内部向けの識別子、認証情報などです。

これらをフロントエンド側のすべての fetch に書くと、呼び出し箇所が増えるほど管理が面倒になります。

fetch("/api/users", {
  headers: {
    "X-App-Name": "sample-app",
  },
});

こうしたヘッダの追加は、プロキシ側でまとめて引き受けられます。

ブラウザ
  |
  | /api/users
  v
Express
  | ここで共通ヘッダを追加する
  v
外部API

ポイントは、ヘッダ追加の処理を各画面に持たせず、APIへ出ていく入口の一箇所に集約できることです。

特に、APIキーやアクセストークンのような認証情報は、ブラウザから見える場所に置きたくないことがあります。

たとえば、外部APIを呼ぶための API キーがある場合を考えます。

ブラウザ側の JavaScript に API キーを書いてしまうと、利用者から見える場所に認証情報を置くことになります。

// 避けたい例
fetch("https://api.example.com/users", {
  headers: {
    Authorization: "Bearer xxxxx",
  },
});

このような場合は、ブラウザ側は自分のアプリケーションの /api/users を呼ぶだけにして、外部APIへ出ていく直前にプロキシ側で認証ヘッダを付ける構成にできます。

ブラウザ
  |
  | /api/users
  v
Express
  | Authorization を追加する
  v
外部API

動作確認

proxyReq のタイミングでヘッダを追加すると、転送先に届くリクエストに反映されます。

proxyReq.setHeader("X-App-Name", "express-proxy-sample");
curl -s http://localhost:3000/api/headers
{
  "headers": {
    "Host": [
      "httpbingo.org"
    ],
    "X-App-Name": [
      "express-proxy-sample"
    ]
  }
}

認証情報を付ける場合は、プロキシのログにそのまま出さないように注意します。

使う場面4: パスの違いを吸収したい

フロントエンド側では /api/users と呼びたいけれど、転送先APIでは /v1/users を呼ぶ必要がある、という場面があります。

また、アプリケーション側では /api/... を入口にしたいけれど、転送先APIには /api が不要なこともあります。

このような差分をフロントエンド側に持ち込むと、画面側のコードが転送先APIの都合に引っ張られます。

fetch("/v1/users");

こうしたパスの差分もプロキシ側で書き換えてしまえば、画面側はアプリケーション内で分かりやすいパスを使うだけで済みます。

/api/users
  -> /v1/users
/api/get
  -> /get

この使い方は、外部APIのパス設計と、アプリケーション側で見せたいAPI入口を分けたいときに役立ちます。

動作確認

手元のサンプルでは、/api を外して転送しています。

pathRewrite: {
  "^/api": "",
}

転送先から見たリクエスト行は /dump/request で確認できます。

curl -s "http://localhost:3000/api/dump/request?name=sample"
GET /dump/request?name=sample HTTP/1.1
Host: httpbingo.org
X-App-Name: express-proxy-sample

GET /dump/request?name=sample HTTP/1.1 のように、転送先から見ると /api が外れていることが分かります。

使う場面5: レスポンスやエラーの扱いを揃えたい

外部APIを直接呼ぶ場合、エラーの扱いはどうしてもフロントエンド側に寄りがちです。

たとえば、画面側で次のような違いを、いちいち個別に判断することになります。

  • 転送先APIが 404 を返した
  • 転送先APIが 500 を返した
  • 転送先APIへの接続に失敗した
  • 転送先APIの応答が遅く、タイムアウトした

画面側で扱うべきエラーもあるので、全部プロキシ側に押し付けたいわけではありません。ただ、外部APIとの通信に関する分類や、ユーザーへ返すエラー形式の統一まで各画面に散らばっていると、後から原因を追いづらくなります。

プロキシを挟んでおけば、外部APIとの通信で何が起きたかを一度ここで受け取り、アプリケーションとして扱いやすい形に分類し直せます。

ブラウザ
  |
  | /api/users
  v
Express
  | upstream_not_found
  | upstream_error
  | upstream_timeout
  | upstream_unreachable
  v
外部API

ここでのポイントは、転送先APIが返したエラーと、プロキシ自身が転送に失敗したエラーを分けて扱えることです。404500 は「転送先APIからレスポンスが返ってきた」状態で、接続失敗やタイムアウトは「プロキシが転送先APIから正常にレスポンスを受け取れなかった」状態です。この2つを同じ「エラー」でひとまとめにしてしまうと、画面側でも運用側でも原因が分かりにくくなります。

プロキシ側で分類しておけば、たとえば次のような方針を決められます。

起きたこと プロキシ側での扱いの例 画面側へ返す例
転送先APIが 404 を返した upstream_not_found として扱う 対象データが見つからない旨を返す
転送先APIが 500 を返した upstream_error として扱う 外部サービス側のエラーとして返す
転送先APIがタイムアウトした upstream_timeout として扱う しばらく待って再試行してほしい旨を返す
転送先APIへ接続できない upstream_unreachable として扱う 一時的に利用できない旨を返す

もちろん、実際にどのステータスコードやメッセージを返すかはアプリケーション次第です。大事なのは、各画面が外部APIの細かい失敗理由を個別に解釈するのではなく、プロキシ側で一度アプリケーション向けの分類に変換できる、という点です。

動作確認

確認したいのは、特定のステータスコードやタイムアウトそのものではなく、プロキシ側で失敗の種類を分類できることです。

たとえば、転送先からレスポンスが返ってきた場合は proxyRes でステータスコードを見て分類します。

function classifyUpstreamStatus(statusCode) {
  if (statusCode === 404) {
    return "upstream_not_found";
  }

  if (statusCode >= 500) {
    return "upstream_error";
  }

  return "upstream_success";
}

proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
  const upstreamStatusCode = proxyRes.statusCode || 500;
  const classification = classifyUpstreamStatus(upstreamStatusCode);

  console.log(
    `[Proxy] ${req.method} ${req.originalUrl} <- ${upstreamStatusCode} (${classification})`,
  );

  if (classification === "upstream_success") {
    return responseBuffer;
  }

  res.setHeader("content-type", "application/json; charset=utf-8");

  return JSON.stringify({
    error: classification,
    upstream: {
      statusCode: upstreamStatusCode,
    },
  });
})

転送先からレスポンスが返らない場合は、onError やタイムアウト設定側で別の分類にします。

proxyTimeout: 2000,
on: {
  error: (err, req, res) => {
    const classification =
      err.code === "ECONNRESET" ? "upstream_timeout" : "upstream_unreachable";

    console.error(
      `[Proxy] ${req.method} ${req.originalUrl} !! ${classification} (${err.message})`,
    );

    res.status(504).json({
      error: classification,
      message: "The upstream API did not respond in time.",
    });
  },
}

確認時には、次のような対応関係を見られると分かりやすそうです。

確認する状況 期待する分類 その後に見たいこと
/status/404 upstream_not_found 画面側へ返すエラー形式が揃っていること
/status/500 upstream_error 外部API側のエラーとして扱えること
/delay/:n でタイムアウト upstream_timeout 接続失敗とは別の理由として扱えること

見たいのは単にログが表示されることではなく、分類が想定どおりに動いているかどうかです。

[Proxy] GET /api/status/404 <- 404 (upstream_not_found)
[Proxy] GET /api/status/500 <- 500 (upstream_error)
[Proxy] GET /api/delay/3 !! upstream_timeout (socket hang up)

クライアントへ返すレスポンスも、分類に合わせた形にできます。

{
  "error": "upstream_not_found",
  "message": "The requested resource was not found on the upstream API.",
  "upstream": {
    "statusCode": 404
  },
  "request": {
    "method": "GET",
    "path": "/api/status/404"
  }
}
{
  "error": "upstream_timeout",
  "message": "The upstream API did not respond in time.",
  "request": {
    "method": "GET",
    "path": "/api/delay/3"
  }
}

ログはあくまで分類が正しく行われたことを確認する手段で、本来の目的はその分類をもとに画面側のエラー表示や再試行の案内、調査時の切り分けを揃えられることにあります。

使う場面6: リクエストやレスポンスのログを取りたい

外部APIを直接呼んでいると、ブラウザの開発者ツールではリクエストを確認できますが、プロキシ側にはその通信の記録が残りません。

後から「どの画面操作で、どのAPIを呼んで、どのステータスが返ったのか」を追いたくなる場面があります。

ここにプロキシを挟んでおくと、クライアントと外部APIの間を流れる通信を、アプリケーション側のログとして残せるようになります。

[Proxy] GET /api/users -> https://api.example.com/users
[Proxy] GET /api/users <- 200

この使い方は、外部APIそのものを制御できない場合でも、自分のアプリケーション側で通信の入口と出口を観測したいときに役立ちます。

ただし、ログに何でも出せばよいわけではありません。

AuthorizationCookie などの機密情報をそのまま出すと、ログが別のリスクになります。

ログに残す内容は、調査に必要な範囲に絞る必要があります。

動作確認

proxyReqproxyRes のイベントを使うと、リクエスト時とレスポンス時にログを出せます。

on: {
  proxyReq: (proxyReq, req) => {
    console.log(`[Proxy] ${req.method} ${req.originalUrl} -> ${config.targetUrl}${req.url}`);
  },
  proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req) => {
    console.log(`[Proxy] ${req.method} ${req.originalUrl} <- ${proxyRes.statusCode}`);

    return responseBuffer;
  }),
}
curl -i http://localhost:3000/api/status/404
[Proxy] GET /api/status/404 -> https://httpbingo.org/status/404
[Proxy] GET /api/status/404 <- 404 (upstream_not_found)

使う場面7: フロントエンド側の制御をバックエンド側に寄せたい

最初は、ただ外部APIへリクエストを転送したいだけかもしれません。

しかし、アプリケーションを作っていると、外部APIを呼ぶときの制御をフロントエンド側だけに置きたくない場面が出てきます。

たとえば、次のような処理です。

  • 共通ヘッダを付けたい
  • 一部のヘッダを削除したい
  • エラーレスポンスを整えたい
  • リクエストやレスポンスをログに出したい
  • 転送先を環境変数で切り替えたい
  • 特定のパスだけ別のAPIへ転送したい

フロントエンドから外部APIを直接呼ぶ構成だと、こうした制御が画面側のコードに入り込みやすくなります。

プロキシを挟んでおけば、外部APIへ出ていく入口をバックエンド側に作れて、そこにヘッダ追加・ログ・エラー処理・転送先の切り替えといった処理をまとめて寄せられます。

ブラウザ
  |
  | /api/...
  v
Express
  | ヘッダ追加 / ログ / エラー処理 / 転送先切り替え
  v
外部API

とはいえ、「将来のために何でも入れておこう」という話ではなく、外部APIを呼ぶときの制御をフロントエンドの各画面に散らばらせず、バックエンド側の入口に寄せられる、というだけの話です。

一方で、APIの入口を揃えたい、外部API向けの制御をバックエンド側に寄せたい、ログやエラー処理をまとめたい、という見通しがあるなら、http-proxy-middleware は選択肢になります。

使わなくてもよさそうな場面

ここまで、リバースプロキシを使う場面を見てきました。

ただし、何でも http-proxy-middleware を挟めばよいわけではありません。

たとえば、次のような場合は、フロントエンドから直接APIを呼ぶ方がシンプルです。

  • クライアントから直接APIを呼んでも問題ない
  • APIキーや認証情報をブラウザに置いてもよい設計になっている
  • CORSやCookieなどの制約がない
  • ログや共通ヘッダの追加など、プロキシ側でまとめたい処理がない
  • 転送先URLやパスの差分をプロキシ側で吸収する必要がない

リバースプロキシは便利ですが、アプリケーションの構成要素が1つ増えます。

なので、「中継点を挟むことで整理できることがあるかどうか」くらいの基準で考えると判断しやすい気がします。

おわりに

今回は、Express と http-proxy-middleware を使ったリバースプロキシが、どういう場面で役に立つのかを整理しました。

リバースプロキシは、見た目だけなら単なる横流しの仕組みです。

ただここまで見てきたように、フロントエンドと外部APIの間に中継点を作るだけで、APIの入口・ヘッダ・パス・ログ・エラー処理といった諸々をプロキシ側にまとめて寄せられます。

前回は、ヘッダやパスがどのように転送されるかを確認しました。

この記事の続きとして、より具体的な挙動を確認するなら、次のようなテーマが扱いやすそうです。

  • POST/PUT/DELETE のリクエストボディ転送
  • ステータスコードとエラーレスポンス
  • タイムアウト
  • Cookie
  • Authorization ヘッダ
  • レスポンスヘッダの書き換え

まずは「どういうときに使うのか」を押さえておくと、それぞれの設定や挙動確認が単なる実験ではなく、実用上の確認として見えやすくなるはずです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?