Skip to content

Instantly share code, notes, and snippets.

@aliyome
Forked from azu/ARCHITECTURE.md
Created October 25, 2025 07:30
Show Gist options
  • Save aliyome/293edbf0b5585895371f3fac7840b24a to your computer and use it in GitHub Desktop.
Save aliyome/293edbf0b5585895371f3fac7840b24a to your computer and use it in GitHub Desktop.
Design Doc for RSS Reaeders based on Cloudflare Workers

Ruri Reader - System Architecture

1. システム概要

Ruri Readerは、Cloudflare Workers上で動作する高効率RSSリーダーです。エッジコンピューティングの利点を活用し、CPU時間を最小化しながら高速なフィード配信を実現します。

主要な設計方針

  • オンデマンドパース戦略: クロール時は軽量なメタデータ抽出のみ、クライアント要求時にフルパース
  • 階層型ストレージ: KV(キャッシュ)、D1(メタデータ)、R2(生XML)の適材適所配置
  • Worker分離: クローラー、API、オーケストレーターを独立したWorkerとして実装
  • Service Bindings RPC: Worker間のゼロレイテンシー・型安全な通信
  • monorepo構成: コード共有と独立デプロイの両立

2. コンポーネント構成

2.1 全体アーキテクチャ図

graph TB
    subgraph "Cron Triggers"
        CRON[15分間隔Cronトリガー]
    end

    subgraph "Workers"
        ORCH[Orchestrator Worker]
        CRAWLER[Crawler Worker]
        API[API Worker]
        PARSER[Parser Service]
    end

    subgraph "Storage"
        D1[(D1: メタデータ)]
        R2[(R2: 生XML)]
        KV[(KV: キャッシュ)]
    end

    subgraph "Client"
        WEB[React Client]
    end

    CRON --> ORCH
    ORCH --> CRAWLER
    CRAWLER --> R2
    CRAWLER --> D1

    WEB --> API
    API --> KV
    API --> PARSER
    API --> R2
    API --> D1
    PARSER --> KV
Loading

2.2 Workerコンポーネント

Worker 役割 トリガー CPU時間目安
Orchestrator タスク調整・キュー管理 Cron (15分毎) 10-30ms
Crawler フィード取得・メタデータ抽出・保存 Service Binding 15-60ms/フィード
Parser XMLパース・正規化 Service Binding 5-20ms/フィード
API クライアントリクエスト処理 HTTP 1-10ms(キャッシュヒット時)

Orchestrator Worker

  • 責任

    • D1から有効なフィード一覧取得
    • バッチサイズ(6フィード)で分割
    • Crawlerへのタスク分配
    • メトリクス収集
  • 実装ポイント

    • Cron制限(512呼び出し/分)内での処理
    • fetch API同時接続制限(6)を考慮したバッチング
    • エラーハンドリングと再試行ロジック

Crawler Worker

  • 責任

    • RSS/AtomフィードのHTTP取得
    • 条件付きGET(ETag/Last-Modified)
    • 基本的なXML検証
    • フィードメタデータの軽量抽出(最終更新日)
    • R2への生XML保存
    • D1メタデータ更新
    • エラーカウント管理
  • 実装ポイント

    • 10秒タイムアウト設定
    • 304 Not Modifiedの処理
    • 正規表現による軽量なメタデータ抽出(5-10ms)
      • RSS: <lastBuildDate>, <pubDate>
      • Atom: <updated>, <published>
    • フィードタイトルと説明の抽出
    • エラー時のリトライ戦略(指数バックオフ)
    • KVキャッシュの無効化

Parser Service

  • 責任

    • fast-xml-parserによるXMLパース
    • RSS/Atom形式の正規化
    • 必要なフィールドのみ抽出
    • パース結果のキャッシュ
  • 実装ポイント

    • Service Bindingによる呼び出し
    • RSS 2.0 / Atom 1.0 両対応
    • エンティティ処理とHTMLエスケープ
    • 軽量な検証ロジック

API Worker

  • 責任

    • クライアントからのHTTPリクエスト処理
    • JWT認証・認可
    • KVキャッシュの確認と更新
    • R2からの生XML取得
    • Parserサービスの呼び出し
    • 既読/未読状態の管理
    • OPMLインポート処理
  • 実装ポイント

    • CORS設定
    • レート制限(100req/min/IP)
    • キャッシュヒット時の即座返却
    • バッチクエリによるDB負荷削減

2.3 ストレージコンポーネント

D1 Database(SQLite)

  • 用途: メタデータ管理

  • 保存データ

    • フィードメタデータ(URL、タイトル、ETag、エラーカウントなど)
    • フィードアイテムメタデータ(GUID、タイトル、公開日など)
    • ユーザー情報
    • 購読管理
    • 既読管理
    • OPMLインポート履歴
  • 特徴

    • SQLクエリとインデックスによる高速検索
    • トランザクション的なバッチ処理
    • 10-30msのアクセス速度

R2 Object Storage

  • 用途: 大容量データの長期保存

  • 保存データ

    • 生XML(最新1世代のみ)
    • 記事本文(4KB超の場合)
    • OPMLファイル
  • 特徴

    • 低コスト(10GB無料)
    • 20-50msのアクセス速度
    • ライフサイクルポリシーによる自動削除(30日)
    • カスタムメタデータによる管理
    • 同じキーで上書き保存されるため、ストレージ使用量は増えすぎない

Workers KV

  • 用途: 高速キャッシュ

  • 名前空間

    • FEED_CACHE: パース済みフィード(TTL: 1時間)
    • USER_PREFS: ユーザー設定(永続)
    • SESSION: セッション管理(TTL: 24時間)
    • RATE_LIMIT: レート制限(TTL: 2分)
  • 特徴

    • 5msの高速アクセス
    • 自動TTL管理
    • エッジロケーションでのキャッシュ

2.4 ストレージ使用方針

データ種別 保存先 理由
フィードメタデータ D1 SQLクエリ・インデックス必要
記事メタデータ D1 検索・フィルタリング対応
生XML(最新1世代) R2 大容量・低コスト
記事本文(4KB超) R2 サイズ制約回避
パース済みフィード KV 高速キャッシュ(TTL: 1時間)
ユーザー設定 KV 頻繁な読み取り
セッション KV TTL管理

3. 技術スタック

3.1 バックエンド

  • Runtime: Cloudflare Workers
  • Framework: Hono (軽量HTTPフレームワーク)
  • Language: TypeScript
  • XML Parser: fast-xml-parser
  • Storage
    • D1 Database (SQLite)
    • R2 Object Storage
    • Workers KV

3.2 フロントエンド

  • Framework: React 19(フレームワーク選択は未決定)
    • 候補: Vite + React Router、Next.js、Remix、TanStack Start
  • Build Tool: Vite(フレームワーク次第)
  • Styling: TailwindCSS
  • State Management: TanStack Query (React Query)
  • Router: React Router v7 または フレームワーク組み込み

3.3 開発ツール

  • Monorepo: pnpm workspace + Turbo
  • Deploy: Wrangler CLI
  • CI/CD: GitHub Actions
  • Testing: Vitest, Playwright
  • Linting: ESLint, Prettier

4. パフォーマンス最適化戦略

4.1 CPU時間削減

遅延パース戦略

  • クロール時: 軽量なメタデータ抽出のみ(5-10ms)

    • 正規表現による最終更新日の抽出
    • タイトルと説明の抽出
    • フルパース処理は実施しない
  • クライアントリクエスト時: 初回のみフルパース(20ms程度)

    • fast-xml-parserでフルパース
    • 全アイテムと詳細情報を抽出
  • 2回目以降: KVキャッシュから即座に返却(1-5ms)

バッチ処理最適化

// 6個ずつの並列処理でネットワークI/O待機を最小化
// 参考: https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections
const BATCH_SIZE = 6; // fetch APIの同時接続数制限(Cloudflare Workers)
const batches = chunk(feeds, BATCH_SIZE);

for (const batch of batches) {
  await Promise.all(
    batch.map(feed => crawlFeed(feed))
  );
}

4.2 階層型キャッシュ戦略

レイヤー データ種別 アクセス速度 TTL
Edge Cache 静的アセット 1ms 30秒
KV パース済みフィード 5ms 1時間
R2 生XML(1世代) 20-50ms 24時間
D1 メタデータ 10-30ms 永続

4.3 Service Bindings活用

// Worker間通信の最適化
type Env = {
  CRAWLER: Service<CrawlerService>;
  PARSER: Service<ParserService>;
  // ネットワークオーバーヘッドなし
  // サブリクエスト制限にカウントされない
}

// 型安全なRPC呼び出し
const result = await env.PARSER.parseXML(xmlContent);

5. Cloudflare Workers制限とスケーラビリティ

5.1 重要な制限事項

CPU時間制限

プラン CPU時間制限 備考
無料プラン 10ms 実用困難
有料プラン(標準) 30秒 現実的
有料プラン(新制限) 5分(300秒) 2025年3月~

重要: Service Bindings経由のCPU時間は累積される

// 例: Orchestrator → Crawler × 17バッチ
{
  Orchestrator: 30ms,
  Crawler: 60ms × 17 = 1,020ms,
  合計: 1,050ms  // 全体で30秒制限を共有
}

ソース:

Service Bindings制限

制限項目 影響
Worker呼び出し数 32回/リクエスト 最も厳しい制約
サブリクエスト(無料) 50回/リクエスト 実用困難
サブリクエスト(有料) 1,000回/リクエスト 十分

Worker呼び出し制限の影響:

// 現在の設計(6件バッチ)
{
  最大バッチ数: 32,
  最大フィード数: 32 × 6 = 192,
  安全マージン: 150件推奨
}

ソース:

同時接続数制限

const BATCH_SIZE = 6; // fetch APIの最大同時接続数

ソース: Cloudflare Workers同時接続制限

Cron Triggers制限

項目
最小間隔 1分
現在の設定 15分
実行回数制限 512回/分

ソース: Cron Triggers

5.2 スケーラビリティ分析

現在の設計での上限(15分Cron間隔)

制約要因 上限値 ボトルネック度
Worker呼び出し制限 192件 🔴 最も厳しい
サブリクエスト制限 200件 🟡 厳しい
Wall Clock Time(15分) 300件 🟡 実用的制約
CPU時間(30秒) 2,850件 🟢 余裕あり
CPU時間(5分) 28,500件 🟢 余裕あり

結論: 実質的な上限は150-190件(Worker呼び出し制限による)

Cron間隔を変更した場合の処理能力

// 各フィードの更新間隔ごとの対応可能数
{
  '1分更新': 150,    // Cron: 1分間隔
  '5分更新': 750,    // Cron: 5分間隔
  '15分更新': 150,   // Cron: 15分間隔(現状)
  '1時間更新': 600,  // Cron: 15分間隔
  '1日1回更新': 9,600// Cron: 15分間隔
}

5.3 無料プランでの制約

Workers Free プランの制限:

  • CPU時間: 10ms/リクエスト
  • サブリクエスト: 50回/リクエスト
  • その他の制限は有料プランと同じ

現実的な判断:

// 無料プランでのCPU時間計算
{
  Orchestrator: '10-30ms',  // D1クエリ + バッチ分割
  問題: 'OrchestratorだけでCPU時間制限(10ms)を超過する可能性が高い'
}

// 1フィードのみ処理する場合
{
  Orchestrator: 10ms,
  Crawler: 10ms,
  合計: 20ms,
  結論: '10ms制限を超過 → 実行不可'
}

無料プランの結論: 実質的に不可能

理由:

  1. CPU時間10ms制限: Orchestratorの起動だけで上限に達する
  2. サブリクエスト50回制限: フィード10件程度で上限(1件5サブリクエスト)
  3. エラー頻発: CPU時間超過エラーで安定動作しない

推奨: 個人利用でも**Workers Paid($5/月)**が必須

5.4 フィード数別推奨構成(有料プラン)

フィード数 更新頻度 Cron間隔 LIMIT 月額コスト目安
< 100 15分 15分 100 $5
100-150 15分 15分 150 $5
150-600 1時間 15分 150 $5
600-5,000 1時間-6時間 15分 Workers Queue導入 $10-20
5,000+ 6時間-24時間 15分 Queue + シャーディング 要相談

Cron間隔を短縮した場合(有料プラン):

Cron間隔 LIMIT 1日実行回数 対応可能フィード数(各頻度)
1分 50 1,440回 50件(1分更新)、200件(5分更新)、3,000件(1時間更新)
5分 100 288回 100件(5分更新)、400件(30分更新)、2,400件(6時間更新)
15分(現状) 100 96回 100件(15分更新)、400件(1時間更新)、9,600件(1日更新)
30分 150 48回 150件(30分更新)、600件(2時間更新)、7,200件(1日更新)

設計上の推奨:

  • 個人利用: 15分間隔、100件、$5/月
  • 小規模サービス: 5分間隔、100件、$5/月(リアルタイム性重視)
  • 中規模サービス: 15分間隔、Workers Queue、$10-20/月

5.5 ボトルネック対策

Cron制限(512呼び出し/分)

  • 複数Cronジョブに分散
  • Queueベースの非同期処理への移行
  • 優先度付きキューイング

KV書き込み制限(1000/日 無料プラン)

  • 有料プランへの移行
  • 書き込みバッチング
  • キャッシュTTLの最適化

R2ストレージ(10GB 無料プラン)

  • ライフサイクルポリシーによる自動削除(30日)
  • 同じキーで上書き保存されるため、通常は増えすぎない
  • 大きな記事本文の圧縮(将来的に検討)

D1クエリパフォーマンス

  • 適切なインデックス設計
  • バッチクエリの活用
  • N+1問題の回避

6. エラーハンドリングとリトライ戦略

6.1 リトライポリシー

type ErrorHandling = {
  // エラー分類
  errorTypes: {
    NETWORK: 'timeout' | 'connection_refused' | 'dns_fail';
    HTTP: '4xx' | '5xx';
    PARSE: 'invalid_xml' | 'encoding_error';
    STORAGE: 'r2_fail' | 'd1_fail' | 'kv_fail';
  };

  // リトライポリシー
  retryPolicy: {
    NETWORK: { maxRetries: 3, backoff: 'exponential' };
    HTTP_5XX: { maxRetries: 2, backoff: 'linear' };
    HTTP_4XX: { maxRetries: 0 };  // リトライしない
    PARSE: { maxRetries: 0 };      // リトライしない
  };

  // フィード無効化閾値
  disableThreshold: {
    consecutiveErrors: 10;
    errorPeriodDays: 7;
  };
}

6.2 エラー監視

  • D1にエラーカウント記録
  • 連続10回エラーでフィード無効化
  • Workers Analyticsでメトリクス監視
  • ログストリーミング(Logpush)

7. セキュリティ

7.1 認証・認可

  • JWTベースの認証
  • Service Binding間は自動的に信頼
  • CORS設定で許可オリジン制限
  • レート制限(100req/min/IP)

7.2 入力検証

  • XMLサイズ制限(5MB)
  • フィード数制限(ユーザーあたり100)
  • OPMLインポート制限(1000フィード/回)
  • SQL injection対策(Prepared Statements)

8. デプロイメント戦略

8.1 デプロイ順序

  1. Parserサービス (依存元なし)
  2. Crawlerワーカー (Parser依存)
  3. APIワーカー (Parser依存)
  4. Orchestratorワーカー (Crawler依存)
  5. クライアント (API依存)

8.2 環境分離

  • Development: ローカルWrangler Dev + ローカルD1
  • Staging: Cloudflare Workers (staging環境) + 専用D1
  • Production: Cloudflare Workers (production環境) + 本番D1

9. 監視とメトリクス

9.1 収集メトリクス

type Metrics = {
  // リクエストメトリクス
  requestCount: number;
  requestDuration: number[];
  cacheHitRate: number;

  // クロールメトリクス
  feedsCrawled: number;
  crawlDuration: number[];
  crawlErrors: number;
  notModifiedResponses: number;

  // ストレージメトリクス
  r2Operations: number;
  d1Queries: number;
  kvOperations: number;

  // リソース使用
  cpuTime: number[];
  memoryUsage: number[];
}

9.2 アラート設定

  • CPU時間超過(10ms以上)
  • エラー率上昇(5%以上)
  • キャッシュヒット率低下(80%以下)
  • ストレージ容量警告(80%以上)

10. 今後の拡張計画

Phase 1(MVP)

  • 基本的なフィード取得・パース
  • 既読/未読管理
  • OPMLインポート
  • 基本的なWeb UI

Phase 2(機能拡張)

  • 全文検索(D1 FTS)
  • フィードのカテゴリ管理
  • お気に入り機能
  • View Transition実装

Phase 3(高度な機能)

  • AI要約(Workers AI)
  • プッシュ通知
  • 複数デバイス同期
  • ソーシャル機能

最終更新: 2025-10-19

Ruri Reader - Data Flow & Schema

1. データフロー概要

Ruri Readerは2つの主要なデータフローで構成されています:

  1. フィード取得フロー: Cronトリガーによる定期的なRSSフィードの取得と保存
  2. クライアントリクエストフロー: ユーザーからのリクエストに対するフィード配信

2. フィード取得フロー

2.1 シーケンス図

sequenceDiagram
    participant Cron as Cron Trigger
    participant Orch as Orchestrator Worker
    participant D1 as D1 Database
    participant Crawler as Crawler Worker
    participant RSS as RSS Server
    participant R2 as R2 Storage
    participant KV as Workers KV

    Cron->>Orch: 15分毎に起動
    Orch->>D1: SELECT active feeds
    D1-->>Orch: フィード一覧(100件)

    loop バッチ処理(6件ずつ)
        Orch->>Crawler: crawlFeeds(batch)

        par 並列取得(6フィード)
            Crawler->>RSS: GET /feed.xml<br/>(If-None-Match, If-Modified-Since)
            RSS-->>Crawler: 200 OK or 304 Not Modified

            alt 200 OK(更新あり)
                Crawler->>Crawler: 基本的なXML検証
                Crawler->>Crawler: メタデータ抽出<br/>(lastBuildDate, title, description)
                Crawler->>R2: PUT feeds/{hash}/feed.xml
                R2-->>Crawler: OK
                Crawler->>D1: UPDATE feeds<br/>(etag, last_modified, feed_last_updated, title, error_count=0)
                Crawler->>KV: DELETE feed:{id}<br/>(キャッシュ無効化)
            else 304 Not Modified
                Crawler->>D1: UPDATE feeds.last_fetched
            else エラー
                Crawler->>D1: UPDATE feeds<br/>(error_count++, error_message)
            end
        end
    end

    Orch->>Orch: メトリクス収集
Loading

2.2 フロー詳細

Step 1: Cronトリガー発火

// 15分毎に実行
crons = ["*/15 * * * *"]

Step 2: Orchestratorによるタスク調整

-- アクティブなフィード取得(最終取得日時が古い順)
SELECT id, url, etag, last_modified
FROM feeds
WHERE active = TRUE
ORDER BY last_fetched ASC
LIMIT 100

LIMIT値の制約:

Step 3: Crawlerによる並列取得

// 6個ずつバッチ処理
// 参考: https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections
const BATCH_SIZE = 6; // fetch APIの同時接続数制限(Cloudflare Workers)
const batches = chunk(feeds, BATCH_SIZE);

// 重要な制限:
// - Worker呼び出し制限: 32回/リクエスト(最も厳しい)
// - サブリクエスト制限: 1,000回/リクエスト
// - CPU時間: 30秒(全Worker累積)または5分(2025年3月~)

for (const batch of batches) {
  await Promise.all(
    batch.map(feed => crawlSingleFeed(feed))
  );
}

Step 4: メタデータ抽出

// 軽量なメタデータ抽出(正規表現、5-10ms)
function extractFeedMetadata(content: string, contentType: string) {
  const metadata = {
    lastUpdated: null as string | null,
    title: null as string | null,
    description: null as string | null
  };

  // JSON Feed対応(最速、正規表現不要)
  if (contentType.includes('json') || content.trimStart().startsWith('{')) {
    try {
      // 軽量パース(最初の1KB程度のみ読む想定)
      const partial = content.slice(0, 1024);
      const json = JSON.parse(partial + (partial.endsWith('}') ? '' : '..."}}'));

      metadata.title = json.title || null;
      metadata.description = json.description || null;
      // JSON Feedにはfeed-level更新日がないため、最初のitem日付を使用
      metadata.lastUpdated = json.items?.[0]?.date_published ||
                            json.items?.[0]?.date_modified || null;
      return metadata;
    } catch {
      // JSONパース失敗時はXMLとして処理
    }
  }

  // XML Feed対応(RSS/Atom)
  // フィード形式に応じた最適化(正規表現実行回数を削減)
  const isAtom = content.includes('<feed');

  if (isAtom) {
    // Atom: feed/updated
    const match = content.match(/<updated>([^<]+)<\/updated>/);
    metadata.lastUpdated = match?.[1] || null;
  } else {
    // RSS: lastBuildDate または最初のitem/pubDate
    const match = content.match(/<lastBuildDate>([^<]+)<\/lastBuildDate>/) ||
                  content.match(/<pubDate>([^<]+)<\/pubDate>/);
    metadata.lastUpdated = match?.[1] || null;
  }

  // タイトル抽出
  const rssTitle = content.match(/<title>([^<]+)<\/title>/);
  metadata.title = rssTitle?.[1] || null;

  // 説明抽出
  const rssDesc = content.match(/<description>([^<]+)<\/description>/);
  const atomSubtitle = content.match(/<subtitle>([^<]+)<\/subtitle>/);
  metadata.description = rssDesc?.[1] || atomSubtitle?.[1] || null;

  return metadata;
}

const metadata = extractFeedMetadata(
  content,
  response.headers.get('content-type') || ''
);

Step 5: R2への保存

// R2キー生成(拡張子は形式に応じて決定)
const contentType = response.headers.get('content-type') || '';
const ext = contentType.includes('json') ? 'json' : 'xml';
const r2Key = `feeds/${year}/${month}/${day}/${hash}/feed.${ext}`;

// R2へ保存
await env.FEED_STORAGE.put(r2Key, content, {
  httpMetadata: {
    contentType: contentType || 'application/xml',
    cacheControl: 'public, max-age=3600'
  },
  customMetadata: {
    feedId: String(feed.id),
    fetchedAt: new Date().toISOString(),
    lastUpdated: metadata.lastUpdated || ''
  }
});

Step 6: D1メタデータ更新

// フィードメタデータをD1に保存
await env.DB.prepare(
  `UPDATE feeds
   SET etag = ?,
       last_modified = ?,
       feed_last_updated = ?,
       title = COALESCE(?, title),
       description = COALESCE(?, description),
       last_fetched = CURRENT_TIMESTAMP,
       last_successful_fetch = CURRENT_TIMESTAMP,
       error_count = 0,
       error_message = NULL
   WHERE id = ?`
).bind(
  response.headers.get('etag'),
  response.headers.get('last-modified'),
  metadata.lastUpdated,
  metadata.title,
  metadata.description,
  feed.id
).run();

Note: D1の並列UPDATE特性

  • 異なるフィードへの並列UPDATE: 問題なし

    • 各Crawlerは異なるフィードID(異なる行)を更新するため競合しない
    • SQLiteのMVCC(Multi-Version Concurrency Control)により安全
  • 同じフィードへの同時UPDATE: 発生しにくいが、Last Write Wins

    • Orchestratorがバッチ処理で制御するため、同じフィードが複数Crawlerに割り当てられない
    • 万が一発生した場合も、最後の書き込みが勝つだけでデータ不整合は起きない
  • D1の分散アーキテクチャ: 並列書き込みをサポート

3. クライアントリクエストフロー

3.1 フィード一覧取得フロー

GET /api/feeds - XMLパース不要、D1のみ

// フィード一覧取得(超高速、3-5ms)
const feeds = await env.DB.prepare(
  `SELECT
    f.id,
    f.url,
    f.title,
    f.description,
    f.feed_last_updated,
    f.last_fetched,
    s.category,
    s.title as custom_title,
    frs.last_read_at,
    CASE
      WHEN f.feed_last_updated > COALESCE(frs.last_read_at, '1970-01-01')
      THEN 1 ELSE 0
    END as has_unread
   FROM subscriptions s
   JOIN feeds f ON s.feed_id = f.id
   LEFT JOIN feed_read_status frs ON frs.feed_id = f.id AND frs.user_id = ?
   WHERE s.user_id = ?
   ORDER BY s.position`
).bind(userId).all();

return c.json({ feeds: feeds.results });

特徴:

  • XMLパース不要(Crawlerが抽出したメタデータを使用)
  • D1クエリのみで完結
  • レスポンス時間: 3-5ms(GROUP BYが不要で高速)
  • 未読判定: feed_last_updatedlast_read_atを比較

フィードレベルの既読管理

  • アイテムごとの既読管理は行わない
  • フィード全体を既読としてマーク
  • シンプルで高速な設計

3.2 フィードアイテム取得フロー

GET /api/feeds/:id/items - XMLパース必要

3.2.1 シーケンス図

sequenceDiagram
    participant Client as React Client
    participant API as API Worker
    participant KV as Workers KV
    participant R2 as R2 Storage
    participant Parser as Parser Service
    participant D1 as D1 Database

    Client->>API: GET /api/feeds/:id/items
    API->>API: JWT認証

    API->>KV: GET feed:{id}
    alt キャッシュヒット
        KV-->>API: パース済みフィード
        API->>D1: SELECT feed_read_status<br/>WHERE user_id=? AND feed_id=?
        D1-->>API: フィード既読状態
        API-->>Client: フィードアイテム + 既読フラグ
    else キャッシュミス
        KV-->>API: null

        API->>D1: SELECT * FROM feeds WHERE id=?
        D1-->>API: フィードメタデータ

        API->>R2: GET feeds/{hash}/feed.xml
        R2-->>API: 生XML

        API->>Parser: parseXML(xml, maxItems: 200)
        Parser->>Parser: fast-xml-parser実行(最新200件)
        Parser->>Parser: RSS/Atom正規化
        Parser-->>API: パース済みフィード

        API->>KV: PUT feed:{id}<br/>(TTL: 3600秒)

        API->>D1: SELECT feed_read_status
        D1-->>API: フィード既読状態

        API-->>Client: フィードアイテム + 既読フラグ
    end
Loading

3.2.2 フロー詳細

Step 1: クライアントリクエスト
// APIクライアント
const response = await fetch(
  `${API_BASE_URL}/api/feeds/${feedId}/items`,
  {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    }
  }
);
Step 2: KVキャッシュ確認
// L1: KVキャッシュ確認
const cacheKey = `feed:${feedId}`;
const cached = await env.FEED_CACHE.get(cacheKey, 'json');

if (cached) {
  // キャッシュヒット - 即座に返却(1-5ms)
  return cached;
}
Step 3: R2から生XML取得
// L2: R2から生XML取得
const feedData = await env.DB.prepare(
  'SELECT * FROM feeds WHERE id = ?'
).bind(feedId).first();

const r2Key = await getLatestR2Key(feedId, env);
const feedObject = await env.FEED_STORAGE.get(r2Key);
const content = await feedObject.text();
const contentType = feedObject.httpMetadata?.contentType || '';
Step 4: Parserサービスでパース
// オンデマンドパース(初回のみ20ms程度)
// 最新200件のみ取得(ハードリミット)
// JSON Feed、RSS、Atom全てに対応
const parsedFeed = await env.PARSER.parseFeed(
  content,
  contentType,
  { maxItems: 200 }
);

// KVにキャッシュ(次回は高速)
await env.FEED_CACHE.put(
  cacheKey,
  JSON.stringify(parsedFeed),
  { expirationTtl: 3600 }  // 1時間
);

ハードリミット: 200件

  • RSSフィードの最新200件のみをパース・返却
  • 理由:
    • Workers CPU時間の予測可能性
    • KVキャッシュサイズの制限(通常1-2MB以内)
    • RSSリーダーの実用性(最新のコンテンツを見るのが主目的)
  • 既読済みアイテムはクライアント側で非表示(filter処理)
Step 5: フィードレベルの既読管理

アイテムレベルの既読管理は不要 - フィード全体を既読としてマーク

// フィードの既読状態を取得(1レコードのみ)
const readStatus = await env.DB.prepare(
  `SELECT last_read_at FROM feed_read_status
   WHERE user_id = ? AND feed_id = ?`
).bind(userId, feedId).first();

// フィード全体の未読判定
const hasUnread = readStatus === null ||
  (parsedFeed.metadata.lastUpdated &&
   readStatus.last_read_at < parsedFeed.metadata.lastUpdated);

return c.json({
  feed: parsedFeed.metadata,
  items: parsedFeed.items,
  total: parsedFeed.items.length,
  hasUnread: hasUnread,
  lastReadAt: readStatus?.last_read_at || null
});

フィードを既読にする

// POST /api/feeds/:id/mark-read
await env.DB.prepare(
  `INSERT INTO feed_read_status (user_id, feed_id, last_read_at)
   VALUES (?, ?, CURRENT_TIMESTAMP)
   ON CONFLICT (user_id, feed_id)
   DO UPDATE SET last_read_at = CURRENT_TIMESTAMP`
).bind(userId, feedId).run();

設計の利点

  • 超高速: 1レコードのみのクエリ(1-2ms)
  • ストレージ削減: アイテムごとの既読レコード不要
  • シンプル: フィード単位の管理のみ
  • Workers CPU時間: ほぼゼロ(既読付与処理が不要)

未読判定

フィード一覧での未読判定は、feed_last_updatedlast_read_atを比較:

CASE
  WHEN f.feed_last_updated > COALESCE(frs.last_read_at, '1970-01-01')
  THEN 1 ELSE 0
END as hasUnread

4. APIスキーマ

4.1 エンドポイント一覧

フィード管理

Method Endpoint 説明 認証
GET /api/feeds フィード一覧取得 Required
GET /api/feeds/updated 更新されたフィード一覧取得 Required
POST /api/feeds フィード追加 Required
DELETE /api/feeds/:id フィード削除 Required
GET /api/feeds/:id/items フィードアイテム取得 Required

既読管理(フィードレベル)

Method Endpoint 説明 認証
POST /api/feeds/:id/mark-read フィードを既読にマーク Required

Note: アイテムレベルの既読管理は行いません。フィード全体を既読としてマークします。

OPMLインポート/エクスポート

Method Endpoint 説明 認証
POST /api/import/opml OPMLインポート Required
GET /api/export/opml OPMLエクスポート Required

ユーザー管理

Method Endpoint 説明 認証
POST /api/auth/login ログイン -
POST /api/auth/logout ログアウト Required
GET /api/user/preferences 設定取得 Required
PUT /api/user/preferences 設定更新 Required

4.2 リクエスト/レスポンス例

GET /api/feeds

Request

GET /api/feeds HTTP/1.1
Authorization: Bearer {jwt_token}

Response

{
  "feeds": [
    {
      "id": 1,
      "url": "https://example.com/feed.xml",
      "title": "Example Feed",
      "customTitle": null,
      "description": "An example feed",
      "category": "tech",
      "hasUnread": true,
      "lastChecked": "2025-10-18T15:00:00Z",
      "lastFetched": "2025-10-19T12:00:00Z",
      "feedLastUpdated": "2025-10-19T11:00:00Z",
      "active": true
    }
  ]
}

Note: hasUnreadは未読の有無のみを示します。正確な未読件数(例: 5件)が必要な場合は、各フィードのアイテムを取得してクライアント側で計算してください。

GET /api/feeds/updated

ユーザーが最後に確認してから更新されたフィードの一覧を取得します。 feed_last_updatedと各フィードの最終既読時刻を比較して、新しいアイテムがあるフィードのみ返却します。

Request

GET /api/feeds/updated?since=2025-10-18T12:00:00Z HTTP/1.1
Authorization: Bearer {jwt_token}

Query Parameters

  • since (optional): 指定時刻以降に更新されたフィードのみ取得。省略時は全フィード

Response

{
  "feeds": [
    {
      "id": 1,
      "title": "Example Feed",
      "url": "https://example.com/feed.xml",
      "feedLastUpdated": "2025-10-19T11:00:00Z",
      "lastCheckedByUser": "2025-10-18T12:00:00Z",
      "hasUnread": true
    },
    {
      "id": 2,
      "title": "Tech News",
      "url": "https://technews.com/feed.xml",
      "feedLastUpdated": "2025-10-19T09:30:00Z",
      "lastCheckedByUser": "2025-10-18T15:00:00Z",
      "hasUnread": true
    }
  ],
  "total": 2
}

Note: hasUnreadは未読の有無のみを示します(正確な件数は含まれません)。

実装クエリ例

-- ユーザーの最終既読時刻より後に更新されたフィード
SELECT
    f.id,
    f.title,
    f.url,
    f.feed_last_updated,
    MAX(rs.read_at) AS last_checked_by_user,
    CASE
        WHEN f.feed_last_updated > COALESCE(frs.last_read_at, '1970-01-01')
        THEN 1 ELSE 0
    END AS hasUnread
FROM subscriptions s
JOIN feeds f ON s.feed_id = f.id
LEFT JOIN feed_read_status frs ON frs.feed_id = f.id AND frs.user_id = ?
WHERE s.user_id = ?
GROUP BY f.id
HAVING f.feed_last_updated > COALESCE(frs.last_read_at, '1970-01-01')
ORDER BY f.feed_last_updated DESC;

GET /api/feeds/:id/items

フィードのアイテム一覧を取得します。最大200件を返します。

Request

GET /api/feeds/1/items HTTP/1.1
Authorization: Bearer {jwt_token}

Response

{
  "feed": {
    "title": "Example Feed",
    "description": "An example feed",
    "link": "https://example.com",
    "language": "en"
  },
  "items": [
    {
      "guid": "item-123",
      "title": "Article Title",
      "link": "https://example.com/article",
      "description": "Article summary...",
      "pubDate": "2025-10-19T10:00:00Z",
      "author": "John Doe",
      "categories": ["tech", "programming"]
    }
  ],
  "total": 150,
  "hasUnread": true,
  "lastReadAt": null
}

Note:

  • hasUnread: フィード全体の未読状態(アイテムごとではない)
  • total: フィード内のアイテム総数(最大200)
  • クライアント側でhasUnreadがtrueの場合のみアイテムを表示

POST /api/feeds/:id/mark-read

フィード全体を既読にマークします。

Request

POST /api/feeds/1/mark-read HTTP/1.1
Authorization: Bearer {jwt_token}

Response

{
  "success": true,
  "feedId": 1,
  "markedAt": "2025-10-19T12:30:00Z"
}

POST /api/import/opml

Request

POST /api/import/opml HTTP/1.1
Authorization: Bearer {jwt_token}
Content-Type: multipart/form-data

file: opml_file.xml

Response

{
  "success": true,
  "imported": 42,
  "failed": 2,
  "errors": [
    {
      "url": "https://invalid.com/feed",
      "error": "Invalid feed URL"
    }
  ]
}

5. データベーススキーマ(D1)

5.1 テーブル定義

feeds テーブル

CREATE TABLE feeds (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    url TEXT UNIQUE NOT NULL,
    title TEXT,
    description TEXT,
    site_url TEXT,
    etag TEXT,
    last_modified TEXT,
    feed_last_updated TEXT,     -- フィード内の最終更新日(RSS: lastBuildDate, Atom: updated)
    last_fetched TIMESTAMP,
    last_successful_fetch TIMESTAMP,
    error_count INTEGER DEFAULT 0,
    error_message TEXT,
    active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_feeds_active ON feeds(active, last_fetched);
CREATE INDEX idx_feeds_url ON feeds(url);
CREATE INDEX idx_feeds_error ON feeds(error_count) WHERE error_count > 0;
CREATE INDEX idx_feeds_last_updated ON feeds(feed_last_updated);

feed_items テーブル

CREATE TABLE feed_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    feed_id INTEGER NOT NULL,
    guid TEXT NOT NULL,
    title TEXT,
    link TEXT,
    published_date TIMESTAMP,
    author TEXT,
    r2_key TEXT,
    content_hash TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
    UNIQUE(feed_id, guid)
);

CREATE INDEX idx_items_feed_date ON feed_items(feed_id, published_date DESC);
CREATE INDEX idx_items_hash ON feed_items(content_hash);
CREATE INDEX idx_items_r2_key ON feed_items(r2_key);

users テーブル

CREATE TABLE users (
    id TEXT PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

subscriptions テーブル

CREATE TABLE subscriptions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id TEXT NOT NULL,
    feed_id INTEGER NOT NULL,
    title TEXT,
    category TEXT,
    position INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
    UNIQUE(user_id, feed_id)
);

CREATE INDEX idx_subscriptions_user ON subscriptions(user_id, position);
CREATE INDEX idx_subscriptions_category ON subscriptions(user_id, category);

feed_read_status テーブル

フィードレベルの既読管理(アイテムレベルではない)

CREATE TABLE feed_read_status (
    user_id TEXT NOT NULL,
    feed_id INTEGER NOT NULL,
    last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id, feed_id),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE
);

CREATE INDEX idx_feed_read_status_user ON feed_read_status(user_id);
CREATE INDEX idx_feed_read_status_feed ON feed_read_status(feed_id);

Note: アイテムごとの既読管理は行いません。フィード全体を既読としてマークします。

import_history テーブル

CREATE TABLE import_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id TEXT NOT NULL,
    total_feeds INTEGER,
    imported_feeds INTEGER,
    failed_feeds INTEGER,
    status TEXT CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
    error_message TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    completed_at TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_import_history_user ON import_history(user_id, created_at DESC);

5.2 よく使うクエリ例

ユーザーの未読フィード一覧

SELECT
    f.id,
    f.title,
    f.url,
    f.feed_last_updated,
    s.category,
    frs.last_read_at,
    CASE
        WHEN f.feed_last_updated > COALESCE(frs.last_read_at, '1970-01-01')
        THEN 1 ELSE 0
    END AS hasUnread
FROM subscriptions s
JOIN feeds f ON s.feed_id = f.id
LEFT JOIN feed_read_status frs ON frs.feed_id = f.id AND frs.user_id = ?
WHERE s.user_id = ?
GROUP BY f.id
ORDER BY s.position;

フィードを既読にマーク

INSERT INTO feed_read_status (user_id, feed_id, last_read_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, feed_id)
DO UPDATE SET last_read_at = CURRENT_TIMESTAMP;

6. R2ストレージスキーマ

6.1 バケット構造

rurireader-feeds/
├── feeds/
│   └── {year}/{month}/{day}/{feed_hash}/
│       ├── feed.xml          # 最新のフィードXML
│       └── metadata.json     # フィードメタデータ
├── items/
│   └── {year}/{month}/{day}/{item_hash}/
│       ├── content.html      # 記事本文(4KB超)
│       └── attachments/      # 画像等の添付ファイル
└── imports/
    └── {user_id}/{import_id}/
        └── opml.xml          # インポートされたOPML

6.2 キー設計

// フィードキー生成
function generateFeedKey(url: string): string {
  const hash = crypto.createHash('sha256')
    .update(url)
    .digest('hex')
    .substring(0, 8);

  const date = new Date();
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');

  return `feeds/${year}/${month}/${day}/${hash}/feed.xml`;
}

// アイテムキー生成
function generateItemKey(feedId: number, guid: string): string {
  const hash = crypto.createHash('sha256')
    .update(`${feedId}:${guid}`)
    .digest('hex')
    .substring(0, 8);

  const date = new Date();
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');

  return `items/${year}/${month}/${day}/${hash}/content.html`;
}

6.3 カスタムメタデータ

type R2CustomMetadata = {
  feedId: string;          // フィードID
  fetchedAt: string;       // 取得日時(ISO 8601)
  contentType: string;     // コンテンツタイプ
  originalUrl?: string;    // 元のURL
  etag?: string;           // ETag
  lastModified?: string;   // Last-Modified
}

6.4 ライフサイクルポリシー

{
  "rules": [
    {
      "id": "delete-old-feeds",
      "status": "Enabled",
      "filter": {
        "prefix": "feeds/"
      },
      "expiration": {
        "days": 30
      },
      "description": "同じキーで上書きされるため、通常は増えない。念のため30日で削除"
    },
    {
      "id": "delete-old-items",
      "status": "Enabled",
      "filter": {
        "prefix": "items/"
      },
      "expiration": {
        "days": 30
      },
      "description": "記事本文は永続的に保存"
    },
    {
      "id": "delete-old-imports",
      "status": "Enabled",
      "filter": {
        "prefix": "imports/"
      },
      "expiration": {
        "days": 1
      },
      "description": "インポート完了後は不要"
    }
  ]
}

7. KVスキーマ

7.1 名前空間: FEED_CACHE

用途: パース済みフィードのキャッシュ

type FeedCache = {
  key: `feed:${number}`;  // feed:{feed_id}
  value: {
    title: string;
    items: Array<{
      guid: string;
      title: string;
      link: string;
      pubDate: string;
      description: string;
      author?: string;
      categories?: string[];
    }>;
    parsedAt: number;  // Unix timestamp
  };
  metadata: {
    expirationTtl: 3600;  // 1時間
  };
}

使用例

// 書き込み
await env.FEED_CACHE.put(
  `feed:${feedId}`,
  JSON.stringify(parsedFeed),
  { expirationTtl: 3600 }
);

// 読み込み
const cached = await env.FEED_CACHE.get(`feed:${feedId}`, 'json');

7.2 名前空間: USER_PREFS

用途: ユーザー設定

type UserPrefs = {
  key: `prefs:${string}`;  // prefs:{user_id}
  value: {
    theme: 'light' | 'dark' | 'auto';
    itemsPerPage: number;
    showImages: boolean;
    autoMarkAsRead: boolean;
    defaultView: 'list' | 'card' | 'magazine';
  };
  metadata: {
    expirationTtl: null;  // 永続
  };
}

7.3 名前空間: SESSION

用途: セッション管理

type Session = {
  key: `session:${string}`;  // session:{session_id}
  value: {
    userId: string;
    createdAt: number;
    lastActivity: number;
    ipAddress: string;
  };
  metadata: {
    expirationTtl: 86400;  // 24時間
  };
}

7.4 名前空間: RATE_LIMIT

用途: レート制限

type RateLimit = {
  key: `rate:${string}:${number}`;  // rate:{ip}:{minute}
  value: string;  // カウント(文字列)
  metadata: {
    expirationTtl: 120;  // 2分
  };
}

使用例

async function rateLimit(ip: string, env: Env): Promise<boolean> {
  const key = `rate:${ip}:${Math.floor(Date.now() / 60000)}`;
  const count = await env.RATE_LIMIT.get(key);

  if (count && parseInt(count) >= 100) {
    return false; // 制限超過
  }

  await env.RATE_LIMIT.put(
    key,
    String((parseInt(count || '0') + 1)),
    { expirationTtl: 120 }
  );

  return true;
}

8. データ整合性とバックアップ

8.1 トランザクション的処理

D1ではバッチ処理を使用してトランザクション的な動作を実現:

// OPMLインポートのバッチ処理
async function importOPML(userId: string, feeds: Feed[]) {
  const statements = [];

  // フィード挿入
  for (const feed of feeds) {
    statements.push(
      db.prepare(
        'INSERT OR IGNORE INTO feeds (url, title) VALUES (?, ?)'
      ).bind(feed.url, feed.title)
    );
  }

  // 購読追加
  for (const feed of feeds) {
    statements.push(
      db.prepare(
        'INSERT OR IGNORE INTO subscriptions (user_id, feed_id) ' +
        'SELECT ?, id FROM feeds WHERE url = ?'
      ).bind(userId, feed.url)
    );
  }

  // バッチ実行(オールオアナッシング)
  const results = await db.batch(statements);
  return results;
}

8.2 データクリーンアップ

// 定期クリーンアップジョブ
async function cleanupOldData(env: Env) {
  // 30日以上前の既読記録を削除
  await env.DB.prepare(
    'DELETE FROM read_status WHERE read_at < datetime("now", "-30 days")'
  ).run();

  // エラー続きのフィードを無効化
  await env.DB.prepare(
    'UPDATE feeds SET active = FALSE WHERE error_count > 10'
  ).run();

  // 使われていないフィードを削除
  await env.DB.prepare(
    `DELETE FROM feeds
     WHERE id NOT IN (SELECT DISTINCT feed_id FROM subscriptions)
     AND created_at < datetime("now", "-7 days")`
  ).run();
}

9. パフォーマンス考慮事項

9.1 クエリ最適化

  • インデックスの適切な設計
  • N+1問題の回避(JOIN使用)
  • バッチクエリの活用
  • 必要なカラムのみSELECT

9.2 キャッシュ戦略

  • KVキャッシュヒット率の最適化(目標: 80%以上)
  • TTLの適切な設定
  • キャッシュウォーミング
  • プリフェッチング

9.3 データサイズ制限

  • KV Value: 最大25MB
  • D1 Row: 推奨4KB以下
  • R2 Object: 制限なし(推奨5MB以下/フィード)

最終更新: 2025-10-19

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment