1
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?

この記事は AI(Claude)が型作成と文章添削しました。基本人間が書いてます。

AWS Blocks使ってみた記事書こうと思ってたら既にめちゃくちゃあったので、
ついでに行ったローカルでも AWS でも同じコードが動く仕組み調査を記事にしました。

AWS Blocks とは

AWS が開発中のフルスタックバックエンドフレームワーク(記事公開時点では Preview)。
DistributedTable(DynamoDB)や Agent(Bedrock)などの
「Block」を組み合わせるだけで、型安全な API とクラウドインフラが自動で構築されます。
ローカル開発において AWS アカウント不要で動き、
npm run sandbox で本番同等の AWS 環境にデプロイできます。

従来のアプローチは「まずローカルで動かして、後からインフラを書く」でした。
AWS Blocks は「インフラ定義とアプリコードを同じクラスに同居させ、
条件分岐で実装を切り替える」という思想になっています。

  • ローカル: AWS 不要・高速・無料・インメモリなのでリセット簡単
  • sandbox: 本物のサービスで動作確認・本物の Bedrock が使える
  • 本番: そのまま CloudFormation デプロイ

はじめに

AWS Blocks でチャットアプリを作って遊んでいました。
コードは以下のように書きます。

// aws-blocks/index.ts
const table = new DistributedTable(scope, 'messages', {
  schema: messageSchema,
  key: { partitionKey: 'userId', sortKey: 'messageId' },
});

npm run dev でローカルを起動すると、このコードは インメモリで動きます
npm run sandbox で AWS にデプロイすると、同じコードが DynamoDB を操作します

初心者の私にとってはコードを1行も変えていないのに不思議に感じたので調査しました。
この記事では、AWS Blocks の内部を実際に読み解いて「なぜ同じコードが動くのか」を説明します。

結論

Node.js の標準機能「Conditional Exports」 を使っています。

同じパッケージ名でインポートしても、Node.js の起動オプション(--conditions=xxx)によって 解決されるファイルが変わる という仕組みになっていて、以下のようなイメージです。

node (オプションなし)          → index.mock.js   ← インメモリ Map
node --conditions=cdk          → index.cdk.js    ← CDK Construct (CloudFormation)
node --conditions=aws-runtime  → index.aws.js    ← AWS SDK 呼び出し

AWS Blocks をインストールすると node_modules 以下に各 Block のパッケージが展開されます。
試しに @aws-blocks/bb-distributed-tablepackage.json を開いてみると、

"exports": {
  ".": {
    "cdk": {
      "default": "./dist/index.cdk.js"
    },
    "aws-runtime": "./dist/index.aws.js",
    "default":     "./dist/index.mock.js"
  }
}

cdkaws-runtime は Node.js 標準の条件名ではなく、AWS Blocks が独自に定義した カスタム条件 なので、--conditions=cdk で起動すると index.cdk.js が、何もなければ index.mock.js が選ばれます。これで切り替えていたわけですね。

全体像

┌─────────────────────────────────────────────────────────────────┐
│  aws-blocks/index.ts(ユーザーコード)                           │
│                                                                  │
│  const table = new DistributedTable(scope, 'messages', {...})    │
│  const agent = new Agent(scope, 'chat', {...})                   │
└────────────────────────┬────────────────────────────────────────┘
                         │ import
                         │
          ┌──────────────┼──────────────┐
          │              │              │
   条件なし          --conditions=cdk   --conditions=aws-runtime
          │              │              │
          ▼              ▼              ▼
   index.mock.js   index.cdk.js   index.aws.js
          │              │              │
   Map + JSON      CDK Construct   AWS SDK 呼び出し
   .bb-data/       CloudFormation  DynamoDB / Bedrock
   CannedProvider  定義を生成      実際の API

ポイント:ユーザーコードは1行も変わりません。
--conditions の有無によって、Node.js のモジュール解決が異なるファイルを指しているだけでした。

3つの DistributedTable クラスを読む

実際に各ファイルの中身を見てみます。
AWS Blocks をインストールすると、各 Block パッケージは dist/ 以下に環境ごとのファイルを持っています。

node_modules/@aws-blocks/bb-distributed-table/dist/
├── index.mock.js    ← ローカル開発用
├── index.cdk.js     ← CDK 合成用
└── index.aws.js     ← Lambda 実行用

つまり DistributedTable というクラスは、同じ名前で3種類の実装が存在します。
それぞれ何をしているか順番に見ていきます。

① index.mock.js(ローカル開発)

// dist/index.mock.js(抜粋)
export class DistributedTable extends Scope {
  constructor(scope, id, options) {
    super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
    // データの保存先は .bb-data/ 以下のJSONファイル
    this.filePath = join(getMockDataDir(this), 'data.json');
    this.data = this.loadFromDisk();
  }

  async get(key) {
    // ただの Map.get()
    return this.data.get(this.serializeKey(key)) ?? null;
  }

  async put(item, options) {
    // バリデーション後に Map にセットして JSON ファイルに書き出し
    const keyStr = this.serializeKey(item);
    this.data.set(keyStr, item);
    this.saveToDisk();
  }
}

JavaScript の Map オブジェクト + JSON ファイル という実装です。
npm run dev を止めてもデータが消えないのは、.bb-data/ ディレクトリに JSON として書き出しているからです。.bb-data/ を削除すればリセットできます。

② index.aws.js(Lambda 実行時)

// dist/index.aws.js(抜粋)
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';

export class DistributedTable extends Scope {
  constructor(scope, id, options) {
    super(id, { parent: scope });
    const client = new DynamoDBClient({ customUserAgent: this.buildUserAgentChain() });
    this.docClient = DynamoDBDocumentClient.from(client);
    registerSdkIdentifiers(this.fullId, { tableName: options.table?.tableName ?? this.fullId });
  }

  async get(key) {
    // 本物の DynamoDB API を呼び出す
    const result = await this.docClient.send(new GetCommand({
      TableName: getSdkIdentifiers(this).tableName,
      Key: this.buildKey(key),
    }));
    return result.Item ?? null;
  }
}

こちらは AWS SDK の DynamoDBDocumentClient を直接呼んでいます。
getSdkIdentifiers() でテーブル名を解決しており、テーブル名は CDK デプロイ時に生成されたものが使われます。

③ index.cdk.js(CDK 合成時)

// dist/index.cdk.js(抜粋)
import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';

export class DistributedTable extends Scope {
  constructor(scope, id, options) {
    super(id, { parent: scope });
    // CDK の DynamoDB テーブル Construct を生成
    this.table = new Table(scope, id, {
      partitionKey: { name: options.key.partitionKey, type: AttributeType.STRING },
      sortKey: options.key.sortKey
        ? { name: options.key.sortKey, type: AttributeType.STRING }
        : undefined,
      billingMode: BillingMode.PAY_PER_REQUEST,
    });
    // セカンダリインデックスも自動生成
    for (const [name, index] of Object.entries(options.indexes ?? {})) {
      this.table.addGlobalSecondaryIndex({ indexName: name, ... });
    }
  }
}

こちらは CDK Construct を生成する だけです。
このクラスが new DistributedTable(...) で呼ばれると、CloudFormation テンプレートの定義が積み上がっていきます。

各コマンドが実際に何をしているか

では、npm run devnpm run sandbox は内部でどう使い分けているのでしょうか。
node_modules 内のスクリプトを確認してみましょう。

npm run dev(オプションなし = mock)

// aws-blocks/scripts/server.ts
startDevServer({
  backendPath: join(__dirname, '..', 'index.ts'),
  frontendCommand: 'npx vite --port 3100',
  frontendPort: 3100,
});

--conditions を何も指定していません。
つまり package.json"default" が選ばれるので mock 実装が使われます

npm run sandbox(CDK合成 + AWS実行)

node_modules/@aws-blocks/core/dist/scripts/sandbox.js を読むと、2段階で条件が切り替わっています。

① CDK 合成フェーズ--conditions=cdk

// sandbox.js(抜粋)
runSync("npm", ["exec", "cdk", "--", "deploy", ...], {
  env: {
    ...process.env,
    NODE_OPTIONS: "--conditions=cdk",  // ← ここ
  },
});

NODE_OPTIONS 環境変数でプロセス全体に --conditions=cdk を適用しています。これにより aws-blocks/index.tsimport が解決されるとき、すべての Block が CDK Construct として読み込まれ、CloudFormation テンプレートが生成されます。

② クライアントコード生成フェーズ--conditions=aws-runtime

// sandbox.js(抜粋)
execFileSync('node', [
  '--conditions=aws-runtime',  // ← ここ
  '--import', 'tsx',
  workerPath,
  backendDefPath,
  clientPath,
]);

aws-blocks/client.js(フロントエンドが import { api } from 'aws-blocks' するファイル)を生成するとき、aws-runtime 条件で Block を読み込みます。これにより Lambda で動くバージョンのミドルウェア設定 でクライアントが生成されます。

③ Lambda 実行時aws-runtime 条件)

Lambda ハンドラーの起動スクリプトを見ると:

// aws-blocks/index.handler.ts
import { createLambdaHandler } from '@aws-blocks/blocks/lambda-handler';
export const handler = createLambdaHandler(() => import('./index.js'));

Lambda にデプロイされるパッケージは --conditions=aws-runtime で生成されたファイルを含んでいるため、Lambda 上では自動的に index.aws.js が使われます。

AI(Agent Block)も同じ仕組みで切り替わる

@aws-blocks/bb-agentpackage.json を見ると、同様の構造です。

"exports": {
  ".": {
    "browser":     "./dist/index.browser.js",
    "cdk":         "./dist/index.cdk.js",
    "aws-runtime": "./dist/index.aws.js",
    "default":     "./dist/index.mock.js"
  }
}

ローカルで使われる index.mock.js の中で、AI の切り替えは model-factory.js が担っています。

// model-factory.js(抜粋)
import { CannedProvider } from './providers/canned.js';

// ローカルでは設定に関わらず CannedProvider にフォールバック
if (!config || config.provider === 'canned') {
  log.info('Using canned provider (local mock, no real model)');
  return new CannedProvider();
}

CannedProvider の実装も面白いです。Bedrock や OpenAI と 同じストリーミングイベントプロトコル を喋るフェイク実装になっています。

// providers/canned.js(抜粋)
const CANNED_RESPONSES = {
  weather: 'The weather is 22°C and sunny. [canned response]',
  order:   'Order #12345 has been shipped. [canned response]',
  help:    'I can help you with weather, orders, and general questions. [canned response]',
};
const DEFAULT_RESPONSE = 'This is a canned mock response. No real model was called. [canned]';

export class CannedProvider extends Model {
  async *stream(messages, options) {
    // キーワードに一致する定型文をストリーミングイベントとして返す
    yield { type: 'modelMessageStartEvent', role: 'assistant' };
    yield { type: 'modelContentBlockStartEvent' };
    for (const word of matchResponse(prompt).split(' ')) {
      yield { type: 'modelContentBlockDeltaEvent',
               delta: { type: 'textDelta', text: word + ' ' } };
    }
    yield { type: 'modelContentBlockStopEvent' };
    yield { type: 'modelMessageStopEvent', stopReason: 'endTurn' };
  }
}

CannedProvider@strands-agents/sdkModel を継承し、Bedrock が返すのと同じ ModelStreamEvent を yield します。そのため Strands Agents SDK はローカルと本番で全く同じコードパスを通ります。ただし出力が定型文になるだけ、というわけです。

おわりに

AWS Blocks が「コードを変えずにローカルと AWS で動く」のは、Node.js の Conditional Exports という標準機能と、それを活用した Block ごとの3実装(mock / cdk / aws)によるものでした。

npm run dev の裏で何が起きているかを知ると、「なぜ .bb-data/ を削除するとリセットされるのか」「なぜローカルの AI 応答が定型文なのか」「なぜデプロイが速いのか」がすべて腑に落ちます。

フレームワークのブラックボックスを開けてみると、意外とシンプルな仕組みの上に乗っていることがわかります。そこが面白いです。

参考

1
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
1
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?