Proxy パターン — アクセスを制御する
「APIが遅すぎる」
「ケンタくん、商品ページの表示が遅すぎてユーザーからクレームが来てる。」
Slackのメッセージを見てケンタはブラウザを開いた。商品詳細ページを開くたびに、ネットワークタブに赤い文字が見える。/api/products/123 — 480ms。
「外部の商品情報APIを叩いてるんです。1回のリクエストで200〜500msかかってます。ページが表示されるまで時間がかかるのはこれが原因ですね……」
ケンタは商品コントローラを確認した。商品詳細を表示するたびに、毎回外部APIを叩いている。同じ商品が繰り返し見られているのに、毎回ネットワーク越しにAPIを呼んでいる。
「キャッシュすれば速くなりそうですが……でもキャッシュのコードをどこに書けば?商品サービスの中?コントローラ?」
山田さんは少し考えてから言った。「キャッシュの関心事を商品サービスに混ぜるのは良くない。Proxyパターンを使って、キャッシュの責務を分離しよう。」
「Proxyって何ですか?」
「代理人だ。社長に会いたいとき、直接ノックするんじゃなく秘書を通すだろ?それがProxyだ。」
Proxy パターンとは
Proxy パターンは、別のオブジェクトへのアクセスを制御する代理オブジェクトを提供するパターンだ。
日常の比喩は秘書だ。社長(Real Subject)に直接会わず、秘書(Proxy)を通して用件を伝える。秘書は「社長は今会議中なので後ほど」とキャッシュ的な返答をしたり、「それは社長に伝えるほどの用件ではない」と権限チェックをしたり、「社長が答える前にログを取る」こともできる。重要なのは、用件を頼む側は「秘書経由かどうか」を意識しなくていいことだ。
もう一つの比喩は図書館の司書だ。本を借りるとき、直接倉庫(バックヤード)に取りに行くのではなく、司書(Proxy)に頼む。司書はよく借りられる本を手元(キャッシュ)に置いておき、そこにあればすぐに貸し出す。なければ倉庫から取ってくる。
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
endINFO
CachedProductApiService は ProductApiService と同じインターフェース(メソッド名と引数)を持っている。使う側(コントローラ)はどちらを使っているか知らない。これが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
endProtection 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
endVirtual 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
endAWSでのProxy
AWS CloudFront はHTTPプロキシそのものだ。Proxyパターンをインフラレベルで実装している。
- ユーザーはCloudFrontと通信する(Proxyの存在を意識しない)
- CloudFrontはキャッシュがあれば数ミリ秒で返す(Cache Proxy)
- キャッシュがない場合のみオリジンに転送する(コストを最小化)
- WAFと連携してSQLインジェクションやXSSをブロック(Protection Proxy)
- SSL終端・HTTP/2・圧縮も担う(付加機能)
API Gateway もProxyだ。ユーザーは直接LambdaやECSにアクセスせず、API Gatewayを経由する。API Gatewayが認証・レート制限・ルーティングを担う Protection Proxyとして機能する。
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 機能追加)