mybook

Proxy パターン — アクセスを制御する

「APIが遅すぎる」

「ケンタくん、商品ページの表示が遅すぎてユーザーからクレームが来てる。」

Slackのメッセージを見てケンタはブラウザを開いた。商品詳細ページを開くたびに、ネットワークタブに赤い文字が見える。/api/products/123 — 480ms。

「外部の商品情報APIを叩いてるんです。1回のリクエストで200〜500msかかってます。ページが表示されるまで時間がかかるのはこれが原因ですね……」

ケンタは商品コントローラを確認した。商品詳細を表示するたびに、毎回外部APIを叩いている。同じ商品が繰り返し見られているのに、毎回ネットワーク越しにAPIを呼んでいる。

「キャッシュすれば速くなりそうですが……でもキャッシュのコードをどこに書けば?商品サービスの中?コントローラ?」

山田さんは少し考えてから言った。「キャッシュの関心事を商品サービスに混ぜるのは良くない。Proxyパターンを使って、キャッシュの責務を分離しよう。」

「Proxyって何ですか?」

「代理人だ。社長に会いたいとき、直接ノックするんじゃなく秘書を通すだろ?それがProxyだ。」

Proxy パターンとは

Proxy パターンは、別のオブジェクトへのアクセスを制御する代理オブジェクトを提供するパターンだ。

日常の比喩は秘書だ。社長(Real Subject)に直接会わず、秘書(Proxy)を通して用件を伝える。秘書は「社長は今会議中なので後ほど」とキャッシュ的な返答をしたり、「それは社長に伝えるほどの用件ではない」と権限チェックをしたり、「社長が答える前にログを取る」こともできる。重要なのは、用件を頼む側は「秘書経由かどうか」を意識しなくていいことだ。

もう一つの比喩は図書館の司書だ。本を借りるとき、直接倉庫(バックヤード)に取りに行くのではなく、司書(Proxy)に頼む。司書はよく借りられる本を手元(キャッシュ)に置いておき、そこにあればすぐに貸し出す。なければ倉庫から取ってくる。

Loading diagram...

Proxyの種類:

  • Virtual Proxy — 重い処理を遅延評価(必要になるまで実行しない)
  • Protection Proxy — アクセス権限をチェックしてから処理を通す
  • Cache Proxy — 結果をキャッシュして繰り返し実行を防ぐ
  • Remote Proxy — リモートサービスへのアクセスをローカルオブジェクトに見せる
  • Logging Proxy — 処理前後にログを記録する

キャッシュProxyの実装

まず、実際のサービス(Real Subject)を定義する。外部APIとの通信のみを担当する。

# app/services/product_api_service.rb
class ProductApiService
  BASE_URL = "https://api.example.com/v1"
  TIMEOUT_SECONDS = 5
 
  def initialize(http_client: nil)
    @http_client = http_client || default_client
  end
 
  def find_product(product_id)
    response = @http_client.get("#{BASE_URL}/products/#{product_id}")
    raise ProductNotFoundError, "商品ID #{product_id} が見つかりません" if response.status == 404
    raise ApiError, "APIエラー: #{response.status}" unless response.success?
 
    JSON.parse(response.body, symbolize_names: true)
  end
 
  def search_products(query:, limit: 20, page: 1)
    response = @http_client.get(
      "#{BASE_URL}/products/search",
      params: { q: query, limit: limit, page: page }
    )
    JSON.parse(response.body, symbolize_names: true)
  end
 
  def list_categories
    response = @http_client.get("#{BASE_URL}/categories")
    JSON.parse(response.body, symbolize_names: true)
  end
 
  private
 
  def default_client
    Faraday.new do |f|
      f.request :json
      f.response :json
      f.options.timeout = TIMEOUT_SECONDS
    end
  end
end

次に、キャッシュProxyを作る。ProductApiService同じメソッドシグネチャを持つことが重要だ。

# app/services/cached_product_api_service.rb
class CachedProductApiService
  CACHE_TTL = {
    product: 10.minutes,
    search:  2.minutes,
    categories: 1.hour,
  }.freeze
 
  def initialize(real_service: ProductApiService.new, cache: Rails.cache)
    @real_service = real_service
    @cache = cache
  end
 
  def find_product(product_id)
    cache_key = cache_key_for(:product, product_id)
 
    @cache.fetch(cache_key, expires_in: CACHE_TTL[:product]) do
      @real_service.find_product(product_id)
    end
  end
 
  def search_products(query:, limit: 20, page: 1)
    # 検索結果のキャッシュキーはクエリ内容全体に基づく
    raw_key = "#{query}:#{limit}:#{page}"
    cache_key = cache_key_for(:search, Digest::SHA1.hexdigest(raw_key))
 
    @cache.fetch(cache_key, expires_in: CACHE_TTL[:search]) do
      @real_service.search_products(query: query, limit: limit, page: page)
    end
  end
 
  def list_categories
    @cache.fetch(cache_key_for(:categories, "all"), expires_in: CACHE_TTL[:categories]) do
      @real_service.list_categories
    end
  end
 
  # キャッシュを明示的に無効化するメソッド
  def invalidate_product(product_id)
    @cache.delete(cache_key_for(:product, product_id))
  end
 
  def invalidate_search_cache
    # 検索キャッシュは全件削除(パターンマッチング)
    @cache.delete_matched("product_api:search:*")
  end
 
  private
 
  def cache_key_for(type, identifier)
    "product_api:#{type}:#{identifier}"
  end
end

INFO

CachedProductApiServiceProductApiService同じインターフェース(メソッド名と引数)を持っている。使う側(コントローラ)はどちらを使っているか知らない。これがProxyパターンの核心だ。「入れ替えが可能」であることが最重要ポイント。

使う側はキャッシュを意識しない

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def show
    # ProductApiService か CachedProductApiService か、コントローラは知らない
    @product = product_service.find_product(params[:id])
  rescue ProductNotFoundError
    render json: { error: "商品が見つかりません" }, status: :not_found
  rescue ApiError => e
    Rails.logger.error "商品API呼び出し失敗: #{e.message}"
    render json: { error: "商品情報の取得に失敗しました" }, status: :service_unavailable
  end
 
  def search
    @products = product_service.search_products(
      query: params[:q],
      limit: params[:limit]&.to_i || 20,
      page: params[:page]&.to_i || 1
    )
  end
 
  private
 
  def product_service
    # 環境に応じてサービスを切り替え(Proxyパターンの威力)
    # テスト環境: 直接APIを叩く(キャッシュを挟まない)
    # 本番環境: キャッシュProxyを使う
    @product_service ||= if Rails.env.test?
                           ProductApiService.new
                         else
                           CachedProductApiService.new
                         end
  end
end

Protection Proxy(権限チェック)

管理者のみが使えるレポートサービスに、Protection Proxyで権限チェックを追加する。

# app/services/report_service.rb(Real Subject)
class ReportService
  def generate_revenue_report(period:)
    # 売上レポートのロジック
    Order.where(created_at: period, status: :completed)
         .group_by_month(:created_at)
         .sum(:total_price)
  end
 
  def export_user_data(format:)
    # ユーザーデータのエクスポートロジック
    User.all.map(&:export_attributes)
  end
 
  def generate_churn_report(period:)
    # 解約分析レポート
    { period: period, churn_rate: calculate_churn_rate(period) }
  end
 
  private
 
  def calculate_churn_rate(period)
    # 実装省略
    0.05
  end
end
# app/services/authorized_report_service.rb(Protection Proxy)
class AuthorizedReportService
  PERMISSIONS = {
    generate_revenue_report: :view_revenue,
    export_user_data: :export_pii_data,
    generate_churn_report: :view_analytics
  }.freeze
 
  def initialize(real_service:, current_user:, audit_log: AuditLog)
    @real_service = real_service
    @current_user = current_user
    @audit_log = audit_log
  end
 
  def generate_revenue_report(period:)
    authorize!(:generate_revenue_report)
    log_access(:generate_revenue_report, { period: period.to_s })
    @real_service.generate_revenue_report(period: period)
  end
 
  def export_user_data(format:)
    authorize!(:export_user_data)
    log_access(:export_user_data, { format: format, requested_at: Time.current })
    @real_service.export_user_data(format: format)
  end
 
  def generate_churn_report(period:)
    authorize!(:generate_churn_report)
    log_access(:generate_churn_report, { period: period.to_s })
    @real_service.generate_churn_report(period: period)
  end
 
  private
 
  def authorize!(action)
    required_permission = PERMISSIONS.fetch(action) do
      raise UnknownActionError, "未知のアクション: #{action}"
    end
 
    unless @current_user.has_permission?(required_permission)
      raise Unauthorized,
            "#{action} の権限がありません(ユーザー: #{@current_user.id}, " \
            "必要な権限: #{required_permission})"
    end
  end
 
  def log_access(action, context = {})
    @audit_log.record(
      user: @current_user,
      action: action,
      context: context,
      ip_address: Current.ip_address
    )
  end
end
# コントローラでの使い方
class Admin::ReportsController < Admin::BaseController
  def revenue
    service = AuthorizedReportService.new(
      real_service: ReportService.new,
      current_user: current_user
    )
    @report = service.generate_revenue_report(period: parse_period)
  rescue Unauthorized => e
    render json: { error: e.message }, status: :forbidden
  rescue UnknownActionError => e
    render json: { error: e.message }, status: :bad_request
  end
 
  private
 
  def parse_period
    start_date = Date.parse(params[:start_date])
    end_date = Date.parse(params[:end_date])
    start_date..end_date
  rescue Date::Error
    Date.current.last_month.beginning_of_month..Date.current.last_month.end_of_month
  end
end

Virtual Proxy(遅延評価)

大きな画像や重いオブジェクトを必要になるまでロードしない。

# app/models/lazy_product_catalog.rb
class LazyProductCatalog
  def initialize(category_id)
    @category_id = category_id
    @products = nil  # まだロードしない
    @loaded_at = nil
  end
 
  def count
    load_if_needed
    @products.size
  end
 
  def each(&block)
    load_if_needed
    @products.each(&block)
  end
 
  def first(n = 1)
    load_if_needed
    @products.first(n)
  end
 
  def to_a
    load_if_needed
    @products
  end
 
  # キャッシュを無効化して再取得
  def reload
    @products = nil
    @loaded_at = nil
    self
  end
 
  def loaded?
    !@products.nil?
  end
 
  private
 
  def load_if_needed
    return if @products && fresh?
 
    @products = Product.where(category_id: @category_id)
                       .includes(:images, :inventory)
                       .order(:created_at)
                       .to_a
    @loaded_at = Time.current
  end
 
  def fresh?
    @loaded_at && @loaded_at > 5.minutes.ago
  end
end

実はActiveRecordのアソシエーションも Virtual Proxy だ:

user = User.find(1)
# ここではordersをロードしていない(@ordersはプロキシオブジェクト)
user.orders.class  # => Order::ActiveRecord_Associations_CollectionProxy
 
# アクセスした瞬間にSQLが実行される
user.orders.to_a   # ← この時点で初めてSELECTが実行される(遅延ロード)
user.orders.loaded? # => true(ロード済み)
 
# 同じトランザクション内ではキャッシュされる
user.orders  # 2回目はSQLを発行しない

Logging Proxy(横断的関心事)

決済処理のすべての呼び出しに、ログ記録と計測を追加する。

# app/services/payment_service.rb(Real Subject)
class PaymentService
  def charge(user:, amount:, payment_token:)
    result = StripeGateway.charge(
      amount: amount,
      currency: "jpy",
      source: payment_token,
      description: "ユーザー#{user.id}の購入"
    )
    { success: true, charge_id: result.id, amount: amount }
  rescue Stripe::CardError => e
    { success: false, error: e.message, code: e.code }
  end
 
  def refund(charge_id:, amount: nil)
    result = StripeGateway.refund(charge: charge_id, amount: amount)
    { success: true, refund_id: result.id }
  end
end
# app/services/logged_payment_service.rb(Logging Proxy)
class LoggedPaymentService
  def initialize(real_service:, logger: Rails.logger)
    @real_service = real_service
    @logger = logger
  end
 
  def charge(user:, amount:, payment_token:)
    start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    @logger.info("[Payment] 決済開始: user_id=#{user.id}, amount=#{amount}")
 
    result = @real_service.charge(user: user, amount: amount, payment_token: payment_token)
 
    duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
 
    if result[:success]
      @logger.info("[Payment] 決済成功: user_id=#{user.id}, amount=#{amount}, " \
                   "charge_id=#{result[:charge_id]}, duration=#{duration}ms")
    else
      @logger.warn("[Payment] 決済失敗: user_id=#{user.id}, amount=#{amount}, " \
                   "error=#{result[:error]}, code=#{result[:code]}, duration=#{duration}ms")
    end
 
    result
  rescue StandardError => e
    @logger.error("[Payment] 予期しないエラー: user_id=#{user.id}, error=#{e.class}: #{e.message}")
    raise
  end
 
  def refund(charge_id:, amount: nil)
    @logger.info("[Payment] 返金開始: charge_id=#{charge_id}, amount=#{amount || '全額'}")
 
    result = @real_service.refund(charge_id: charge_id, amount: amount)
 
    @logger.info("[Payment] 返金成功: charge_id=#{charge_id}, refund_id=#{result[:refund_id]}")
    result
  rescue StandardError => e
    @logger.error("[Payment] 返金エラー: charge_id=#{charge_id}, error=#{e.message}")
    raise
  end
end
# Proxyをチェーンできる(複数の横断的関心事を重ねる)
payment_service = PaymentService.new
payment_service = LoggedPaymentService.new(real_service: payment_service)
payment_service = MetricsPaymentService.new(real_service: payment_service)  # メトリクス計測も追加
 
# 使う側は何重になっているか知らない
payment_service.charge(user: current_user, amount: 5000, payment_token: params[:token])

WARNING

ProxyとDecoratorパターンは実装が非常によく似ている。違いは目的にある。Decoratorは機能の追加(より豊かな表現のため)、Proxyはアクセスの制御(キャッシュ・権限・ログなど)。「何のためのラッパーか」で名前を使い分けることで、コードを読む人に意図が伝わる。

Faraday のMiddlewareもProxyチェーン

HTTPクライアントの Faraday はProxyチェーンを使っている。各ミドルウェアが前後に処理を挟んでチェーンを形成する。

# config/initializers/external_api.rb
PRODUCT_API_CLIENT = Faraday.new(url: "https://api.example.com") do |f|
  f.request  :json                    # リクエストをJSON形式に変換(Proxy)
  f.request  :retry,                  # リトライ処理(Protection Proxy的)
             max: 2,
             interval: 0.5,
             exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
  f.response :json                    # レスポンスをパース(Proxy)
  f.response :logger, Rails.logger    # ログを出力(Logging Proxy)
  f.response :raise_error             # エラーレスポンスで例外を発生(Protection Proxy)
  f.use      :http_cache,             # キャッシュ(Cache Proxy)
             store: Rails.cache,
             serializer: Marshal,
             shared_cache: false
  f.adapter  Faraday.default_adapter
end

各ミドルウェアが「次のレイヤー」へのProxyになっている。リクエストは上から順に処理を加えながら進み、レスポンスは下から上へ戻ってくる。

テスト

Proxyのテストは「キャッシュが効いているか」「本物を呼ぶ回数が期待通りか」を検証する。

# spec/services/cached_product_api_service_spec.rb
RSpec.describe CachedProductApiService do
  let(:real_service) { instance_double(ProductApiService) }
  let(:cache) { ActiveSupport::Cache::MemoryStore.new }
  let(:service) { described_class.new(real_service: real_service, cache: cache) }
 
  describe "#find_product" do
    let(:product_data) { { id: 1, name: "テスト商品", price: 1000 } }
 
    before do
      allow(real_service).to receive(:find_product).with(1).and_return(product_data)
    end
 
    it "1回目はreal_serviceを呼ぶ" do
      service.find_product(1)
      expect(real_service).to have_received(:find_product).once
    end
 
    it "2回目はキャッシュから返す(real_serviceを呼ばない)" do
      service.find_product(1)
      service.find_product(1)
      # 2回呼んでもreal_serviceは1回だけ呼ばれる
      expect(real_service).to have_received(:find_product).once
    end
 
    it "キャッシュヒット時は同じデータを返す" do
      first_result  = service.find_product(1)
      second_result = service.find_product(1)
      expect(first_result).to eq(second_result)
    end
 
    it "キャッシュ期限後は再度real_serviceを呼ぶ" do
      service.find_product(1)
 
      # キャッシュを無効化
      cache.clear
 
      service.find_product(1)
      expect(real_service).to have_received(:find_product).twice
    end
 
    it "invalidate_productでキャッシュが削除される" do
      service.find_product(1)
      service.invalidate_product(1)
      service.find_product(1)
      # invalidate後は再度real_serviceを呼ぶ
      expect(real_service).to have_received(:find_product).twice
    end
  end
 
  describe "#search_products" do
    let(:search_results) { [{ id: 1, name: "商品A" }, { id: 2, name: "商品B" }] }
 
    before do
      allow(real_service).to receive(:search_products)
        .with(query: "テスト", limit: 20, page: 1)
        .and_return(search_results)
    end
 
    it "同じクエリは2回目からキャッシュされる" do
      service.search_products(query: "テスト", limit: 20, page: 1)
      service.search_products(query: "テスト", limit: 20, page: 1)
      expect(real_service).to have_received(:search_products).once
    end
 
    it "異なるクエリはそれぞれAPIを呼ぶ" do
      allow(real_service).to receive(:search_products)
        .with(query: "別クエリ", limit: 20, page: 1)
        .and_return([])
 
      service.search_products(query: "テスト", limit: 20, page: 1)
      service.search_products(query: "別クエリ", limit: 20, page: 1)
      expect(real_service).to have_received(:search_products).twice
    end
  end
end
# spec/services/authorized_report_service_spec.rb
RSpec.describe AuthorizedReportService do
  let(:real_service) { instance_double(ReportService) }
  let(:admin_user) { create(:user, :admin) }
  let(:regular_user) { create(:user) }
  let(:audit_log) { class_double(AuditLog, record: true) }
 
  describe "#generate_revenue_report" do
    let(:period) { Date.current.last_month.beginning_of_month..Date.current.last_month.end_of_month }
 
    context "管理者ユーザーの場合" do
      let(:service) do
        described_class.new(
          real_service: real_service,
          current_user: admin_user,
          audit_log: audit_log
        )
      end
 
      before do
        allow(real_service).to receive(:generate_revenue_report).and_return({ total: 1_000_000 })
        allow(admin_user).to receive(:has_permission?).with(:view_revenue).and_return(true)
      end
 
      it "レポートを生成する" do
        result = service.generate_revenue_report(period: period)
        expect(result).to eq({ total: 1_000_000 })
      end
 
      it "監査ログを記録する" do
        service.generate_revenue_report(period: period)
        expect(audit_log).to have_received(:record).with(
          hash_including(action: :generate_revenue_report)
        )
      end
    end
 
    context "一般ユーザーの場合" do
      let(:service) do
        described_class.new(
          real_service: real_service,
          current_user: regular_user,
          audit_log: audit_log
        )
      end
 
      before do
        allow(regular_user).to receive(:has_permission?).with(:view_revenue).and_return(false)
      end
 
      it "Unauthorizedエラーを発生させる" do
        expect {
          service.generate_revenue_report(period: period)
        }.to raise_error(Unauthorized)
      end
 
      it "real_serviceを呼ばない" do
        service.generate_revenue_report(period: period) rescue nil
        expect(real_service).not_to have_received(:generate_revenue_report)
      end
    end
  end
end

AWSでのProxy

AWS CloudFront はHTTPプロキシそのものだ。Proxyパターンをインフラレベルで実装している。

Loading diagram...
  • ユーザーはCloudFrontと通信する(Proxyの存在を意識しない)
  • CloudFrontはキャッシュがあれば数ミリ秒で返す(Cache Proxy)
  • キャッシュがない場合のみオリジンに転送する(コストを最小化)
  • WAFと連携してSQLインジェクションやXSSをブロック(Protection Proxy)
  • SSL終端・HTTP/2・圧縮も担う(付加機能)

API Gateway もProxyだ。ユーザーは直接LambdaやECSにアクセスせず、API Gatewayを経由する。API Gatewayが認証・レート制限・ルーティングを担う Protection Proxyとして機能する。

Loading diagram...

ElastiCache(Redis) もCache Proxyの発想をインフラレベルで実現している。アプリケーションコードで書いたCachedProductApiServiceと同じ考え方——読み取りはまずRedisを確認し、なければRDSに問い合わせる——をインフラ層で実装したものだ。

ケンタの気づき

パフォーマンス改善後、ケンタはモニタリングダッシュボードを開いた。

商品ページの平均レスポンスタイム: 480ms → 45ms。

キャッシュヒット率: 87%。外部APIへのリクエスト数が以前の13%に削減された。

「ProxyってCloudFrontみたいな仕組みをコードレベルで実装するパターンなんですね。キャッシュ、権限チェック、ログ——全部アクセスの制御という観点でつながってる。」

「まさに」と山田さんは言った。「インフラレベルのパターンとアプリレベルのパターンが同じ考え方を使っている。良い設計の原則は規模を超えて共通している。CloudFrontの仕組みを理解すれば、コードレベルのProxyも理解できる。逆もしかり。」

「Decoratorとの違いが最初は難しかったですが、『何のためのラッパーか』で判断すればいいですね。」

「そうだ。アクセス制御が目的ならProxy、機能追加が目的ならDecorator。コードが似ていても意図を名前で表すことが大事だ。6ヶ月後に自分のコードを読む人(多くの場合、未来の自分)へのメッセージだと思えばいい。」

ケンタはノートにメモした。

Proxyパターン = 別のオブジェクトへのアクセスを代理。キャッシュ・権限チェック・ログ・遅延評価に使える。使う側は本物かProxyかを知らない(同じインターフェース)。CloudFrontはインフラレベルのProxyそのもの。


INFO

この章のまとめ

  • Proxyパターンは別のオブジェクトへのアクセスを代理する
  • Cache・Protection・Virtual・Loggingなど用途によって種類がある
  • 使う側(クライアント)は本物かProxyかを知らない(同じインターフェース)
  • Proxyを差し替えることで、使う側のコードを変えずにキャッシュや権限チェックを追加できる
  • Faradayのミドルウェアチェーンもプロキシの連鎖で構成される
  • CloudFront・API GatewayはインフラレベルのProxy
  • ProxyとDecoratorは似ているが目的が違う(アクセス制御 vs 機能追加)