MCP Server の試作してみる(C#版)

目的

MCP Server の試作を行い、MCP の機能を確認する。

  • 既存のデータベースを AI エージェントから使えるようにする.
  • たとえば「2025年08月02日の春日部の予約状況は?」とチャットで質問したときに、

  日付「2025年08月02日」、店舗「春日部」を見つけ出して、既存のデータベースから予約状況を取得する。

ちょっと前は RAG(Retrieval-Augmented Generation)と呼ばれていたが、今は MCP(Model Context Protocol)と呼ばれている。vscode の Chat 機能から特定の MCP Server にアクセスして、既存のシステムを参照できるようになる。

構成

  • – kkc-webapi: 既存システムを Laravel で実装した Web API
      OpenAPI 仕様が定義されていて、これを使う
  • – kkc-mcp-server: MCP Server の実装(標準入出力形式)
      Chat からの入力を受け取り、必要な情報を kkc-webapi から取得して、Chat に返す
  • – kkc-mcp-server-api: MCP Server の実装(HTTP API 形式)
      Chat からの入力を受け取り、必要な情報を kkc-webapi から取得して、Chat に返す
  • – kkc-mcp-client: MCP Server にアクセスするクライアント
      vscode の Chat 機能ではなく、独自の GUI アプリを提供する

kkc-webapi の作成

既存システムが独自 MVC と ChakPHP のハイブリッドになっているので、これを Laravel の Web API 形式に書き直します。手作業で書き直すのは大変なので、

  • ddl.md の作成
  • openapi.yaml の作成

ということで、既存データベースを CRUD できる Web API を自動生成してしまいます。このあたりはまめに php artisan make コマンドを使うことになるのですが、Claude Sonnet を使って作成してもらいます。

プロンプトでは、以下のように指示するだけで十分です。

ddl.md に従って CRUD できる openapi 仕様を作成して。
openapi.yaml に従って Laravel の Controller を作成して。

実際のところは、途中でトークンを忘れてしまうらしく、なんどか修正を指示しないとけないのですが、それでも手作業でつくるよりはるかに早いです。openapi.yaml についても、すべてを一遍に作成するのではなく、一定のカテゴリごとに分けてプロンプトで指示をするとうまくいく率が高いです。

このあたり、うまくやらば Claude Code を使って夜間バッチ的に作ることもできるのでしょうが、まあ、そこまでしなくても Claude Sonnet で十分でしょう。今回の場合はテーブル数が20弱ぐらいなのでプロンプトで指示をしていますが、100を超えるような場合は、ddl.md から openapi.yaml を作成して Laravel の Controller を作成する、ツールを作ったほうがよいでしょう。これを「治具」と呼ぶのですが、この手の使い捨てのツールは良く作ります。環境変数などはコードに埋め込みにしてしまうので他のプロジェクトで使うことはできないのでが、あえてそのような汎用性を捨ててしまって、スピードと精度を優先させます。

まあ、そんなこんなで kkc-webapi を作成しておきます。

ModelContextProtocol の活用

MCP Server の実装にあたっては、ModelContextProtocol を活用します。これは、Chat からの入力を受け取り、必要な情報を kkc-webapi から取得して、Chat に返すためのプロトコルです。以前は、JSON 形式のリクエストを作ってちまちまやっていたのですが、ModelContextProtocol を使うことで、より効率的にデータを取得できるようになります。現在はプレビュー版 0.3.0-preview.3 なのが難点ではあるのですが。

現状で MCP Server は標準入出力型と HTTP API 型の2つの実装があります。

  • 標準入出力型: コマンドラインから実行し、標準入力と標準出力を使ってやり取りする。
  • HTTP API 型: HTTP リクエストを受け取り、レスポンスを返す形式。

ということになっていて、ModelContextProtocol を使った標準入出力型の型の例があります。

Build a Model Context Protocol (MCP) server in C# – .NET Blog
https://devblogs.microsoft.com/dotnet/build-a-model-context-protocol-mcp-server-in-csharp/

ただし、この標準入出力型には難点があって、ストリーム形式なのでローカルな MCP Server を建てる必要が(たぶん)あります。レスポンス&リクエストが一連のストリームになっているので、エラーを返すことができません。適当にログを出力する必要がでてきます。お手軽ではあるのですが、あまり実用的ではないかもしれません。

最初に標準入出力型を実装してみたのですが、あまりうまく拡張できないので HTTP API 型の kkc-mcp-server-api で実装しなおしています。

実は実装自体はあまり手間ではなくて、

dotnet new console

でプロジェクトを作成した後に、以下のプロンプトで作成して貰っています。

内部で kkc-webapi を呼び出す MCP Server を作成して。

kkc-mcp-server は、vscode のチャットと OpenAPI の kkc-webapi を繋ぐ役目になっています。

動作状況

作成したコードはさておき、vscode の Chat からどういう風に呼び出されるのかを見ておきましょう。

なぜか、powershell のコマンドを実行しようとするのですが、これが HTTP API 型のためなのかよくわかりません。

ひとまず、自前の MCP Server を呼び出すことはできているようです。

実際に利用するときは、vscode の Chat 機能ではなくて、適当なブラウザアプリかデスクトップアプリを作ることになるので、このあたりはもうちょっと作り込みが必要そうです。

コマンドパレットから、MCP Server を指定

コマンドパレットで「MCP サーバーの追加」を見つけ出して、「コマンド(stdio)」か「HTTP」を選択します。

標準入出力型の stdio のほうは、デバッグがしづらいので、ある程度 HTTP API 型のようで調節をしていから、stdio のほうに戻したほうがよいかもしれません。

ちなみに、私の現状の環境では、まだ HTTP API 型の MCP Server から kkc-webapi のほうにパラメーターが正確にわたっていなくて、完成していません。これは近いうちに。

MCP Server の呼び出し

$request = @{
    jsonrpc = "2.0"
    id = "1"
    method = "tools/call"
    params = @{
        name = "search_reservations_by_name"
        arguments = @{
            Store = "春日部"
            ReservationDate = "2025-08-02"
        }
    }
} | ConvertTo-Json -Depth 10

$response = Invoke-RestMethod -Uri "http://localhost:5000" -Method Post -Body $request -ContentType "application/json"
Write-Host "=== 2025年08月02日 春日部店の予約状況 ===" -ForegroundColor Green
$response | ConvertTo-Json -Depth 10

Chat が MCP Server を呼び出すときのコマンドを見ると解るのですが、jsonrpc 形式でリクエストを送信しています。vscode の Chat 部分で「2025年08月02日の「春日部」の予約状況を示して。」と入力すると、うまく JSON RPC 形式のリクエストに変換してくれることがわかります。このために、ユーザーは search_reservations_by_name などの関数名を意識することなく、自然言語を使って指示を与えるあるいは質問をすることができるのです。

このあたりで Chat 形式のユーザ―インターフェースが変わってくるという話です。

まあ、もっとも、予約のチェックのように画面で何かを選択するほうが早い場合もあるので、このチャット形式も善しあしでああるのですが。JR の忘れ物の問い合わせのような検索システムの場合は、入力する項目をうまく誘導するためにこの手のチャット形式が有効でしょう。

kkc-mcp-server-api

Program.cs

using System.Text.Json;
using KkcMcpServerApi.Models;
using KkcMcpServerApi.Services;
using Microsoft.OpenApi.Models;

var builder = WebApplication.CreateBuilder(args);

// ロギング設定
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Information);

// サービス登録
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo 
    { 
        Title = "KKC MCP Server API", 
        Version = "v1",
        Description = "Model Context Protocol (MCP) サーバーAPI - kkc-webapi のプロキシとして動作します"
    });
});

// HttpClient設定
builder.Services.AddHttpClient<IWebApiClient, WebApiClient>(client =>
{
    client.BaseAddress = new Uri("http://localhost:8000");
    client.Timeout = TimeSpan.FromSeconds(30);
});

// MCPツールプロバイダー登録
builder.Services.AddScoped<McpToolProvider>();

// CORS設定
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.AllowAnyOrigin()
              .AllowAnyMethod()
              .AllowAnyHeader();
    });
});

var app = builder.Build();

app.UseCors();

// MCP over HTTP のルートエンドポイント
app.MapPost("/", async (HttpContext context, McpToolProvider toolProvider) =>
{
    try
    {
        using var reader = new StreamReader(context.Request.Body);
        var jsonRequest = await reader.ReadToEndAsync();
        
        var requestDoc = JsonDocument.Parse(jsonRequest);
        var root = requestDoc.RootElement;
        
        if (!root.TryGetProperty("method", out var methodElement))
        {
            return Results.BadRequest(new { error = "Missing method property" });
        }
        
        var method = methodElement.GetString();
        var id = root.TryGetProperty("id", out var idElement) ? idElement.GetString() : null;
        
        object? response = method switch
        {
            "initialize" => new
            {
                jsonrpc = "2.0",
                id = id,
                result = new
                {
                    protocolVersion = "2024-11-05",
                    capabilities = new
                    {
                        tools = new { }
                    },
                    serverInfo = new
                    {
                        name = "kkc-mcp-server-api",
                        version = "1.0.0"
                    }
                }
            },
            "tools/list" => new
            {
                jsonrpc = "2.0",
                id = id,
                result = new
                {
                    tools = new object[]
                    {
                        new
                        {
                            name = "search_reservations",
                            description = "予約を検索します(ID指定)",
                            inputSchema = new
                            {
                                type = "object",
                                properties = new
                                {
                                    storeId = new { type = "integer", description = "店舗ID" },
                                    reservationDate = new { type = "string", description = "予約日 (YYYY-MM-DD)" },
                                    customerName = new { type = "string", description = "顧客名" },
                                    limit = new { type = "integer", description = "取得件数", @default = 10 }
                                }
                            }
                        },
                        new
                        {
                            name = "search_reservations_by_name",
                            description = "予約を検索します(名前指定)",
                            inputSchema = new
                            {
                                type = "object",
                                properties = new
                                {
                                    store = new { type = "string", description = "店舗名" },
                                    areaGroup = new { type = "string", description = "エリアグループ名" },
                                    customerName = new { type = "string", description = "顧客名" },
                                    reservationDate = new { type = "string", description = "予約日 (YYYY-MM-DD)" },
                                    limit = new { type = "integer", description = "取得件数", @default = 10 }
                                }
                            }
                        },
                        new
                        {
                            name = "get_calendar",
                            description = "カレンダー情報を取得します",
                            inputSchema = new
                            {
                                type = "object",
                                properties = new
                                {
                                    date = new { type = "string", description = "日付 (YYYY-MM-DD)" },
                                    areaGroupId = new { type = "integer", description = "エリアグループID" }
                                },
                                required = new[] { "date", "areaGroupId" }
                            }
                        }
                    }
                }
            },
            "tools/call" => await HandleToolCall(root, toolProvider, id),
            _ => new
            {
                jsonrpc = "2.0",
                id = id,
                error = new
                {
                    code = -32601,
                    message = $"Method not found: {method}"
                }
            }
        };
        
        return Results.Json(response);
    }
    catch (Exception ex)
    {
        return Results.Json(new
        {
            jsonrpc = "2.0",
            error = new
            {
                code = -32603,
                message = "Internal error",
                data = ex.Message
            }
        });
    }
});

async Task<object> HandleToolCall(JsonElement root, McpToolProvider toolProvider, string? id)
{
    try
    {
        if (!root.TryGetProperty("params", out var paramsElement))
        {
            return new
            {
                jsonrpc = "2.0",
                id = id,
                error = new { code = -32602, message = "Missing params" }
            };
        }
        
        if (!paramsElement.TryGetProperty("name", out var nameElement))
        {
            return new
            {
                jsonrpc = "2.0",
                id = id,
                error = new { code = -32602, message = "Missing tool name" }
            };
        }
        
        var toolName = nameElement.GetString();
        var arguments = paramsElement.TryGetProperty("arguments", out var argsElement) 
            ? argsElement.GetRawText() 
            : "{}";
        
        var result = toolName switch
        {
            "search_reservations" => await toolProvider.CallToolAsync(toolName, JsonSerializer.Deserialize<Dictionary<string, object>>(arguments)),
            "search_reservations_by_name" => await toolProvider.CallToolAsync(toolName, JsonSerializer.Deserialize<Dictionary<string, object>>(arguments)),
            "get_calendar" => await toolProvider.CallToolAsync(toolName, JsonSerializer.Deserialize<Dictionary<string, object>>(arguments)),
            _ => throw new ArgumentException($"Unknown tool: {toolName}")
        };
        
        return new
        {
            jsonrpc = "2.0",
            id = id,
            result = new
            {
                content = result.Content.Select(c => new
                {
                    type = c.Type,
                    text = c.Text
                }).ToArray()
            }
        };
    }
    catch (Exception ex)
    {
        return new
        {
            jsonrpc = "2.0",
            id = id,
            error = new
            {
                code = -32603,
                message = "Tool execution failed",
                data = ex.Message
            }
        };
    }
}

// 既存のエンドポイントも保持(開発・テスト用)
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));

app.MapGet("/info", () => Results.Ok(new { 
    name = "KKC MCP Server API",
    version = "1.0.0",
    description = "ASP.NET Core Minimal API - MCP Server",
    endpoints = new[] { "/", "/health", "/info" }
}));

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.Run();

McpToolProvider.cs

using System.Text.Json;
using KkcMcpServerApi.Models;

namespace KkcMcpServerApi.Services;

/// <summary>
/// MCPツールプロバイダー
/// </summary>
public class McpToolProvider
{
    private readonly IWebApiClient _webApiClient;
    private readonly ILogger<McpToolProvider> _logger;

    public McpToolProvider(IWebApiClient webApiClient, ILogger<McpToolProvider> logger)
    {
        _webApiClient = webApiClient;
        _logger = logger;
    }

    /// <summary>
    /// 利用可能なツールの一覧を取得
    /// </summary>
    public ToolsListResult GetToolsList()
    {
        var tools = new List<ToolInfo>
        {
            new ToolInfo
            {
                Name = "search_reservations",
                Description = "予約を検索します。複数の条件で絞り込み検索が可能です。",
                InputSchema = new
                {
                    type = "object",
                    properties = new
                    {
                        customerName = new { type = "string", description = "顧客名での検索" },
                        customerNameKana = new { type = "string", description = "顧客名カナでの検索" },
                        phoneNumber = new { type = "string", description = "電話番号での検索" },
                        email = new { type = "string", description = "メールアドレスでの検索" },
                        receptionDate = new { type = "string", description = "受付日での検索(YYYY-MM-DD形式)" },
                        areaGroupId = new { type = "integer", description = "エリアグループIDでの検索" },
                        storeId = new { type = "integer", description = "店舗IDでの検索" },
                        reservationDate = new { type = "string", description = "予約日での検索(YYYY-MM-DD形式)" },
                        timeZone = new { type = "string", description = "時間帯での検索" },
                        status = new { type = "string", description = "予約ステータスでの検索" },
                        page = new { type = "integer", description = "ページ番号(デフォルト: 1)" },
                        perPage = new { type = "integer", description = "1ページあたりの件数(デフォルト: 10)" }
                    }
                }
            },
            new ToolInfo
            {
                Name = "search_reservations_by_name",
                Description = "販売会社名、店舗名、エリアグループ名などの名前を使用して予約を検索します。",
                InputSchema = new
                {
                    type = "object",
                    properties = new
                    {
                        sellingCompany = new { type = "string", description = "販売会社名での検索" },
                        store = new { type = "string", description = "店舗名での検索" },
                        areaGroup = new { type = "string", description = "エリアグループ名での検索" },
                        customerName = new { type = "string", description = "顧客名での検索" },
                        phoneNumber = new { type = "string", description = "電話番号での検索" },
                        email = new { type = "string", description = "メールアドレスでの検索" },
                        acceptanceDate = new { type = "string", description = "受付日での検索(YYYY-MM-DD形式)" },
                        reservationDate = new { type = "string", description = "予約日での検索(YYYY-MM-DD形式)" },
                        timeZone = new { type = "string", description = "時間帯での検索" },
                        model = new { type = "string", description = "車種名での検索" },
                        plateNumber = new { type = "string", description = "登録番号での検索" },
                        color = new { type = "string", description = "塗色名での検索" },
                        remarks = new { type = "string", description = "備考での検索" },
                        page = new { type = "integer", description = "ページ番号(デフォルト: 1)" },
                        limit = new { type = "integer", description = "1ページあたりの件数(デフォルト: 10)" }
                    }
                }
            },
            new ToolInfo
            {
                Name = "get_calendar",
                Description = "指定した日付の予約カレンダー情報と空き状況を取得します。",
                InputSchema = new
                {
                    type = "object",
                    properties = new
                    {
                        date = new { type = "string", description = "カレンダー表示する日付(YYYY-MM-DD形式)" },
                        areaGroupId = new { type = "integer", description = "エリアグループID(オプション)" },
                        storeId = new { type = "integer", description = "店舗ID(オプション)" }
                    },
                    required = new[] { "date" }
                }
            }
        };

        return new ToolsListResult { Tools = tools };
    }

    /// <summary>
    /// 指定されたツールを実行
    /// </summary>
    public async Task<ToolCallResult> CallToolAsync(string toolName, Dictionary<string, object>? arguments)
    {
        try
        {
            _logger.LogInformation("Calling tool: {ToolName} with arguments: {Arguments}", 
                toolName, JsonSerializer.Serialize(arguments));

            string result = toolName switch
            {
                "search_reservations" => await CallSearchReservationsAsync(arguments),
                "search_reservations_by_name" => await CallSearchReservationsByNameAsync(arguments),
                "get_calendar" => await CallGetCalendarAsync(arguments),
                _ => throw new ArgumentException($"Unknown tool: {toolName}")
            };

            return new ToolCallResult
            {
                Content = new List<ToolContent>
                {
                    new ToolContent { Type = "text", Text = result }
                },
                IsError = false
            };
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error calling tool: {ToolName}", toolName);
            return new ToolCallResult
            {
                Content = new List<ToolContent>
                {
                    new ToolContent { Type = "text", Text = $"Error: {ex.Message}" }
                },
                IsError = true
            };
        }
    }

    /// <summary>
    /// 予約検索ツール実行
    /// </summary>
    private async Task<string> CallSearchReservationsAsync(Dictionary<string, object>? arguments)
    {
        var request = new ReservationSearchRequest();

        if (arguments != null)
        {
            if (arguments.TryGetValue("CustomerName", out var customerName))
                request.CustomerName = customerName?.ToString();
            if (arguments.TryGetValue("CustomerNameKana", out var customerNameKana))
                request.CustomerNameKana = customerNameKana?.ToString();
            if (arguments.TryGetValue("PhoneNumber", out var phoneNumber))
                request.PhoneNumber = phoneNumber?.ToString();
            if (arguments.TryGetValue("Email", out var email))
                request.Email = email?.ToString();
            if (arguments.TryGetValue("ReceptionDate", out var receptionDate))
                request.ReceptionDate = receptionDate?.ToString();
            if (arguments.TryGetValue("AreaGroupId", out var areaGroupId) && int.TryParse(areaGroupId?.ToString(), out var areaGroupIdInt))
                request.AreaGroupId = areaGroupIdInt;
            if (arguments.TryGetValue("StoreId", out var storeId) && int.TryParse(storeId?.ToString(), out var storeIdInt))
                request.StoreId = storeIdInt;
            if (arguments.TryGetValue("Status", out var status))
                request.Status = status?.ToString();
            if (arguments.TryGetValue("page", out var page) && int.TryParse(page?.ToString(), out var pageInt))
                request.Page = pageInt;
            if (arguments.TryGetValue("perPage", out var perPage) && int.TryParse(perPage?.ToString(), out var perPageInt))
                request.PerPage = perPageInt;

            // 予約日の設定
            if (arguments.TryGetValue("ReservationDate", out var reservationDate))
            {
                var dateStr = reservationDate?.ToString();
                if (!string.IsNullOrEmpty(dateStr))
                {
                    request.ReservationDate = new ReservationDateRange
                    {
                        Start = dateStr,
                        End = dateStr
                    };
                }
            }

            // 時間帯の設定
            if (arguments.TryGetValue("TimeZone", out var timeZone))
            {
                var timeZoneStr = timeZone?.ToString();
                if (!string.IsNullOrEmpty(timeZoneStr))
                {
                    request.TimeZone = new[] { timeZoneStr };
                }
            }
        }

        return await _webApiClient.SearchReservationsAsync(request);
    }

    /// <summary>
    /// 名前による予約検索ツール実行
    /// </summary>
    private async Task<string> CallSearchReservationsByNameAsync(Dictionary<string, object>? arguments)
    {
        var request = new ReservationSearchByNameRequest();

        if (arguments != null)
        {
            if (arguments.TryGetValue("SellingCompany", out var sellingCompany))
                request.SellingCompany = sellingCompany?.ToString();
            if (arguments.TryGetValue("Store", out var store))
                request.Store = store?.ToString();
            if (arguments.TryGetValue("AreaGroup", out var areaGroup))
                request.AreaGroup = areaGroup?.ToString();
            if (arguments.TryGetValue("CustomerName", out var customerName))
                request.CustomerName = customerName?.ToString();
            if (arguments.TryGetValue("PhoneNumber", out var phoneNumber))
                request.PhoneNumber = phoneNumber?.ToString();
            if (arguments.TryGetValue("Email", out var email))
                request.Email = email?.ToString();
            if (arguments.TryGetValue("AcceptanceDate", out var acceptanceDate))
                request.AcceptanceDate = acceptanceDate?.ToString();
            if (arguments.TryGetValue("Model", out var model))
                request.Model = model?.ToString();
            if (arguments.TryGetValue("PlateNumber", out var plateNumber))
                request.PlateNumber = plateNumber?.ToString();
            if (arguments.TryGetValue("Color", out var color))
                request.Color = color?.ToString();
            if (arguments.TryGetValue("Remarks", out var remarks))
                request.Remarks = remarks?.ToString();
            if (arguments.TryGetValue("page", out var page) && int.TryParse(page?.ToString(), out var pageInt))
                request.Page = pageInt;
            if (arguments.TryGetValue("limit", out var limit) && int.TryParse(limit?.ToString(), out var limitInt))
                request.Limit = limitInt;

            // 予約日の設定
            if (arguments.TryGetValue("ReservationDate", out var reservationDate))
            {
                var dateStr = reservationDate?.ToString();
                var storeValue = arguments.TryGetValue("Store", out var storeArg) ? storeArg?.ToString() : null;
                
                if (!string.IsNullOrEmpty(dateStr))
                {
                    request.ReservationDate = new ReservationDateRange
                    {
                        Store = storeValue,
                        Start = dateStr,
                        End = dateStr
                    };
                }
            }

            // 時間帯の設定
            if (arguments.TryGetValue("TimeZone", out var timeZone))
            {
                var timeZoneStr = timeZone?.ToString();
                if (!string.IsNullOrEmpty(timeZoneStr))
                {
                    request.TimeZone = new[] { timeZoneStr };
                }
            }
        }

        return await _webApiClient.SearchReservationsByNameAsync(request);
    }

    /// <summary>
    /// カレンダー取得ツール実行
    /// </summary>
    private async Task<string> CallGetCalendarAsync(Dictionary<string, object>? arguments)
    {
        var request = new CalendarRequest();

        if (arguments != null)
        {
            if (arguments.TryGetValue("date", out var date))
                request.Date = date?.ToString() ?? string.Empty;
            if (arguments.TryGetValue("AreaGroupId", out var areaGroupId) && int.TryParse(areaGroupId?.ToString(), out var areaGroupIdInt))
                request.AreaGroupId = areaGroupIdInt;
        }

        if (string.IsNullOrEmpty(request.Date))
        {
            throw new ArgumentException("date parameter is required");
        }

        return await _webApiClient.GetCalendarAsync(request);
    }
}

WebApiClient.cs

using System.Text.Json;
using KkcMcpServerApi.Models;

namespace KkcMcpServerApi.Services;

/// <summary>
/// WebAPIクライアントのインターフェース
/// </summary>
public interface IWebApiClient
{
    Task<string> SearchReservationsAsync(ReservationSearchRequest request);
    Task<string> SearchReservationsByNameAsync(ReservationSearchByNameRequest request);
    Task<string> GetCalendarAsync(CalendarRequest request);
}

/// <summary>
/// kkc-webapi を呼び出すクライアント
/// </summary>
public class WebApiClient : IWebApiClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<WebApiClient> _logger;
    private readonly JsonSerializerOptions _jsonOptions;

    public WebApiClient(HttpClient httpClient, ILogger<WebApiClient> logger)
    {
        _httpClient = httpClient;
        _logger = logger;
        _jsonOptions = new JsonSerializerOptions
        {
            // Laravel APIはPascalCaseを期待しているため、PropertyNamingPolicyを設定しない
            WriteIndented = true
        };
    }

    /// <summary>
    /// 予約検索API呼び出し
    /// </summary>
    public async Task<string> SearchReservationsAsync(ReservationSearchRequest request)
    {
        try
        {
            var json = JsonSerializer.Serialize(request, _jsonOptions);
            var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
            
            _logger.LogInformation("Calling reservations/search API with: {Request}", json);
            
            var response = await _httpClient.PostAsync("/api/v1/reservations/search", content);
            
            if (!response.IsSuccessStatusCode)
            {
                var errorContent = await response.Content.ReadAsStringAsync();
                _logger.LogError("API call failed with status {StatusCode}: {Error}", 
                    response.StatusCode, errorContent);
                throw new HttpRequestException($"API call failed: {response.StatusCode} - {errorContent}");
            }
            
            var result = await response.Content.ReadAsStringAsync();
            _logger.LogInformation("API response received: {Response}", result);
            
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error calling reservations search API");
            throw;
        }
    }

    /// <summary>
    /// 名前による予約検索API呼び出し
    /// </summary>
    public async Task<string> SearchReservationsByNameAsync(ReservationSearchByNameRequest request)
    {
        try
        {
            var json = JsonSerializer.Serialize(request, _jsonOptions);
            var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
            
            _logger.LogInformation("Calling reservations/search-by-name API with: {Request}", json);
            
            var response = await _httpClient.PostAsync("/api/v1/reservations/search-by-name", content);
            
            if (!response.IsSuccessStatusCode)
            {
                var errorContent = await response.Content.ReadAsStringAsync();
                _logger.LogError("API call failed with status {StatusCode}: {Error}", 
                    response.StatusCode, errorContent);
                throw new HttpRequestException($"API call failed: {response.StatusCode} - {errorContent}");
            }
            
            var result = await response.Content.ReadAsStringAsync();
            _logger.LogInformation("API response received: {Response}", result);
            
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error calling reservations search-by-name API");
            throw;
        }
    }

    /// <summary>
    /// カレンダーAPI呼び出し
    /// </summary>
    public async Task<string> GetCalendarAsync(CalendarRequest request)
    {
        try
        {
            var json = JsonSerializer.Serialize(request, _jsonOptions);
            var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
            
            _logger.LogInformation("Calling reservations/calendar API with: {Request}", json);
            
            var response = await _httpClient.PostAsync("/api/v1/reservations/calendar", content);
            
            if (!response.IsSuccessStatusCode)
            {
                var errorContent = await response.Content.ReadAsStringAsync();
                _logger.LogError("API call failed with status {StatusCode}: {Error}", 
                    response.StatusCode, errorContent);
                throw new HttpRequestException($"API call failed: {response.StatusCode} - {errorContent}");
            }
            
            var result = await response.Content.ReadAsStringAsync();
            _logger.LogInformation("API response received: {Response}", result);
            
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error calling calendar API");
            throw;
        }
    }
}
カテゴリー: 開発 | MCP Server の試作してみる(C#版) はコメントを受け付けていません

Claude Sonnet で Android アプリに Compose UI のページを追加する。

下準備として、Github Pro の課金が必要です。月10ドルなので、本家 Claude Code よりも安め(月20ドルだったか?)でいけます。

GitHub のプラン – GitHub Docs https://docs.github.com/ja/get-started/learning-about-github/githubs-plans

Android Studio から直接 Claude Sonnet は使えない(と思う)ので、vscode 上でコードの変更を行います。Github Pro を入れておくと、Copilot のアイコンが使えるので、いつものチャット(Ask)からエージェント(Agent)に切り替えます。

  • Ask の場合は、チャット上でコードの提案をしてくれます
  • Agent の場合は、要望や質問を Copilot 自身がファイルを修正してくれます

Agent のほうがファイルを直接操作してくれるので手間は少ないです。が、あらぬところを変えることがあるので、そういうときは Ask モードに切り替えるか、git でブランチを作っておいて元のファイルに戻します。元のファイルに戻したときは

元のファイルに戻しました

と Copilot に知らせてあげると話がスムーズになります。Claude Sonnet があれこれと調べ始めるので。

コード生成のスピードは直接 Claude Code に課金したほうが早いのですが、Claude Code だと結構なお金が掛かる(Maxだと特に)のと、API を使うときの制限が厳しいです。仕事的に 2,3 時間使ってしまうと制限に達してしまいます。なぜかわかりませんが、Copilot + Claude Sonnet の組み合わせだと、この API 呼び出しが無制限になっています。なので、あまり生成スピードを気にせずにだらだらやる場合は Sonnet のほうがお得です。多分、Claude Code のほうが色々と便利なコマンドがあるんでしょうが、いまのところ私は Claude Sonnet で十分な感じです。

新しい Compose ページを追加する

Android の Jetpack Compose は、React の JSX 形式のようにコード内で UI を記述することができます。まあ、機能的にはそれだけではないのですが、この「コードに記述する」というスタイルが、AI エージェントと相性がいいです。

従来の Android UI の場合は Android Stdio を立ち上げてデザイナを使って画面を構成することが多く、その設定は *.xml ファイルに記述されます。直接 *.xml ファイルを編集することもあるのですが、結構面倒で、さらに言えば AI エージェントのこれをやって貰うと大変なことになります。どういう大変なことになるかというと、それは各自に試してみてください。端的に言えば、完成しません、ってだけなんですが。

バージョンを示すページを作ってみましょう。

バージョンを表示する AboutActivity を作成して。

いろいろ項目を入れる必要があると感じますが、いいえ、そんなことはありません。最初のテンプレートだけを作りたいので、一行だけプロンプトで指示すれば ok です。

完全に余計なお世話的な感じで作ってくれますが、これを使います。

画面を表示するに AndroidManifest.xml に追加しておきます。これは最初に指示をすると追加してくれたり、追加してくれなかったりします。

AboutActivity を AndroidManifest.xml に追加して。

設定した後に Android Studio でビルドをして確認しておきます。

Compose UI で作成したときに、プレビューが出ないのが残念なところなのですが、これは放置します。手書きで書くとプレビューが表示されるので、おそらく Claude Sonnet の生成するコードのせいです。

package jp.mamori_i.app.screen.ui

import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import jp.mamori_i.app.ui.theme.AppTheme
import java.text.SimpleDateFormat
import java.util.*

class AboutActivity : ComponentActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        enableEdgeToEdge()
        setContent {
            AppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    AboutScreen(
                        onNavigateBack = {
                            finish()
                        },
                        context = this@AboutActivity
                    )
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
    onNavigateBack: () -> Unit = {},
    context: ComponentActivity? = null
) {
    // バージョン情報を取得
    val versionInfo = remember {
        context?.let { ctx ->
            try {
                val packageInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
                mapOf(
                    "appName" to (ctx.applicationInfo.loadLabel(ctx.packageManager).toString()),
                    "versionName" to packageInfo.versionName,
                    "versionCode" to packageInfo.longVersionCode.toString(),
                    "packageName" to packageInfo.packageName,
                    "buildTime" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(packageInfo.lastUpdateTime))
                )
            } catch (e: PackageManager.NameNotFoundException) {
                mapOf(
                    "appName" to "FolkBears",
                    "versionName" to "1.0.0",
                    "versionCode" to "1",
                    "packageName" to "jp.mamori_i.app",
                    "buildTime" to "Unknown"
                )
            }
        } ?: mapOf(
            "appName" to "FolkBears",
            "versionName" to "1.0.0",
            "versionCode" to "1",
            "packageName" to "jp.mamori_i.app",
            "buildTime" to "Unknown"
        )
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("アプリについて") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = "戻る"
                        )
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp)
                .verticalScroll(rememberScrollState()),
            verticalArrangement = Arrangement.spacedBy(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Spacer(modifier = Modifier.height(32.dp))
            
            // アプリアイコン(代替として情報アイコンを使用)
            Card(
                modifier = Modifier.size(80.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.primary
                )
            ) {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Icon(
                        imageVector = Icons.Default.Info,
                        contentDescription = null,
                        modifier = Modifier.size(40.dp),
                        tint = MaterialTheme.colorScheme.onPrimary
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            // アプリ名
            Text(
                text = versionInfo["appName"] ?: "FolkBears",
                style = MaterialTheme.typography.headlineMedium,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.Center
            )
            
            // バージョン情報
            Text(
                text = "バージョン ${versionInfo["versionName"]} (${versionInfo["versionCode"]})",
                style = MaterialTheme.typography.titleMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                textAlign = TextAlign.Center
            )
            
            Spacer(modifier = Modifier.height(24.dp))
            
            // 詳細情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "アプリケーション情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    InfoRow("アプリ名", versionInfo["appName"] ?: "Unknown")
                    InfoRow("バージョン", versionInfo["versionName"] ?: "Unknown")
                    InfoRow("ビルド番号", versionInfo["versionCode"] ?: "Unknown")
                    InfoRow("パッケージ名", versionInfo["packageName"] ?: "Unknown")
                    InfoRow("ビルド日時", versionInfo["buildTime"] ?: "Unknown")
                }
            }
            
            // システム情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "システム情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    InfoRow("Android バージョン", android.os.Build.VERSION.RELEASE)
                    InfoRow("API レベル", android.os.Build.VERSION.SDK_INT.toString())
                    InfoRow("デバイス", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
                    InfoRow("CPU アーキテクチャ", System.getProperty("os.arch") ?: "Unknown")
                }
            }
            
            // 著作権情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "著作権情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    Text(
                        text = "© 2024 FolkBears Group\nAll rights reserved.",
                        style = MaterialTheme.typography.bodyMedium,
                        textAlign = TextAlign.Center,
                        modifier = Modifier.fillMaxWidth()
                    )
                    
                    Text(
                        text = "このアプリケーションは接触確認システムとして開発されました。",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        textAlign = TextAlign.Center,
                        modifier = Modifier.fillMaxWidth()
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(32.dp))
        }
    }
}

@Composable
fun InfoRow(label: String, value: String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(
            text = "$label:",
            style = MaterialTheme.typography.bodyMedium,
            fontWeight = FontWeight.Medium,
            modifier = Modifier.weight(1f)
        )
        Text(
            text = value,
            style = MaterialTheme.typography.bodyMedium,
            fontFamily = FontFamily.Monospace,
            color = MaterialTheme.colorScheme.primary,
            modifier = Modifier.weight(1f),
            textAlign = TextAlign.End
        )
    }
}

@Preview(showBackground = true)
@Composable
fun AboutScreenPreview() {
    AppTheme {
        AboutScreen()
    }
}

まあ、それでも AI エージェントを使ってバイブコード(Vibe Coding)する分には十分な品質を保っています。About ページならばセキュリティがどうという話もないし特殊なロジックを使っているわけではありません。単にアプリの製品情報とかを並べたいだけです。

従来の方式で言えば Android Studio や Visual Studio が出してくれるテンプレートを使えばいいのですが、 そこすら面倒くさいし、こうやって AI エージェントが作りやすいように画面設計や変数設定をしてしまったほうが後々楽でしょう。うまくいかなければ、もう一度いちから AI エージェントに作って貰えばよいです。

エミュレータで実行するとこんな感じです。

既存の Activity を編集してもらう

About 画面ができたので、いくつか編集をしてみましょう。

UI を作成している AboutScreen の中身を手作業で変更してもよいのですが、AI エージェントを使っても追加や削除ができます。手作業だとレイアウトが崩れたりするので、情報を与えて AI エージェントに修正して貰ったほうが作業が早く済みます。複雑なロジックの場合は面倒なのですが、ちょっとした文言を追加するのはプロンプトだけで結構いけます。

バージョン情報の前に

- アプリの目的:これは接触確認アプリを更に進化させて、実験的に接触記録をトレースできるものです。
- アプリの外報 : 複数の端末で GATT/iBeacon 方式で送受信することにより、相互の距離を記録していきます。接触回数や時刻などを研究データとして役立てるために、指定のサーバーに送信します。

の文言を追加して。

コードをざっと眺めて、うまくいかないようであれば git で元に戻せば ok です。あるいは、コードの変更がしめされたときに「取り消し」します。

このあたりの修正サイクルを Claude Code の設計書(claude.md)に最初に練り込んでいくのか、それとも後からプロンプトを使って修正していくのかと議論の分かれるところでしょうが、私としては後者である「ペアプロ」方式のほうが絶対に楽です。最初の設計書の場合は、まあ、環境づくりとかひな形の作成に使うとよいでしょう。夜間バッチ的に動かせば人手がいれなくなるので結構いいと思います。が、その場合は、Claude Code にバッチファイルを作って貰ったほうがいいんじゃないでしょうか…とは思いますが、開発プロジェクトのスタイルによってそれぞれ。要は、開発者(=自分)が疲弊しない方法を選んでください。あと、やってみて楽しいほうがいいです。

カテゴリー: 開発 | Claude Sonnet で Android アプリに Compose UI のページを追加する。 はコメントを受け付けていません

Claude Sonnet で簡単な Android/iOS アプリを作る

AI エージェントである Claude Code を本格的な業務システムに使うのもいいのですが、治具的なツールを作るのも便利です、という話。

モバイルアプリの場合 React Native Expo を使うことが多いでしょう。コードが TypeScript で書けるし、アップロード等の手間も必要ありません。Web API を呼び出す程度の社内ツールならばこれで十分です。審査が面倒なので、社内配布や自分だけが使うのでれば、これで十分です。

Expo の場合、Android/iOS 共通のコードを使えるので2つの環境を別々に作らなくてよいというメリットが大きいです。一方で、スマホネイティブのコードを弄るときにはちょっと手間がかかります。手としては、それぞれのライブラリを作っておいて、Expo や Flutter から呼び出せ場イイのですが、少し複雑なことをやろうとすると、やっぱりネイティブ環境での動作確認が必要になります。そこで、初手の実験コードとしては、Kotlin, Swift で書かざるを得ません。

ネイティブコードとして Kotlin/Swift で書くときに結構面倒になるのが UI です。Android の場合は、*.xml を Android Studio で開いてデザインしないといけないし、Swift の場合は古来の *.storyboard を編集しないといけません。それぞれコツがあって結構面倒。なので、最近は Compose UI か Swift UI が流行りだし、そっちのほうがテストツールのような標準的な画面は作りやすいのです。

Compose UI / Swift UI を使う

AI エージェントツールである Claude Sonnet がどの程度まで UI を書けるかという、この程度までいけます。

最近は、仕様書駆動(spec駆動)が流行っているし、それらの指示を Claude Sonnet も読み取ってくれるので、プロンプトに直接書くのではなく、ちょっとした readme.md を書いて AI エージェントに渡します。

readme.md

# Folkbers.mini の作成

GATT/iBeacon の接続テストを行う

## iBeacon サービス
- BeaconScan : iBeacon のスキャン
- BeaconTransmitter : iBeacon の発信
- BeaconTraceService : iBeacon の発信とスキャンを制御する

## GATT サービス
- GattAdvertise : GATT アドバタイズ
- GattClient : GATT クライアント
- GattServer : GATT サーバー
- GattTraceService : GATT サービスの発信とスキャンを制御する

## ContentView.swift

- Trace の開始と停止ボタン
- GATT/iBeacon の切り替えスイッチ

この位の readme.md を書いておいて、プロンプトで

readme.md に従って画面作成して。

と指示するだけで ok です。iOS の場合は BLE 通信の中身まで作ってくれました。まあ、この中身は標準的なものでしかないので、私の場合は修正が必須なのですが。

Android の設定画面っぽいものも、readme.md とか別の *.md ファイルに書いておいてプロンプトで渡せば ok です。

治具ツールを作るときは、いちから Claude Sonnet に任せるのは難しいので、新規プロジェクトだけ Android Studio や Xcode で作成しておきます。

/save コマンド

プロンプトで /save と打つと今までのプロンプトと AI エージェントの回答が表示されます。

---
tools: ['listDirectory', 'readFile', 'createFile', 'replaceString', 'runInTerminal', 'textSearch', 'fileSearch']
---

readme.md を参考にして

- BeaconScan : iBeacon のスキャン
- BeaconTransmitter : iBeacon の発信
- BeaconTraceService : iBeacon の発信とスキャンを制御する

を作成して。

> Copilot: readme.mdを参考にして、iBeacon関連のサービスクラスを作成いたします。まず、現在のプロジェクト構造を確認してから、必要なファイルを作成していきます。 それでは、iBeacon関連のサービスクラスを作成します。まず、必要なインポートとCore Locationフレームワークを使用して、以下の3つのクラスを作成します:
>
> 1. **BeaconScan** - iBeaconのスキャン機能
> 2. **BeaconTransmitter** - iBeaconの発信機能
> 3. **BeaconTraceService** - 発信とスキャンを制御するサービス 次に、これらのサービスを使用するためにContentView.swiftを更新します:
> ```
>
> ```
> 最後に、プロジェクトでCore LocationとCore Bluetoothフレームワークを使用するため、Info.plistにプライバシー設定を追加する必要があります。Info.plistファイルを作成します: ## 作成完了
>
> readme.mdの仕様に基づいて、以下の3つのiBeacon関連サービスクラスを作成しました:
>
> ### 📡 BeaconScan.swift
> - **機能**: iBeaconのスキャン機能を提供
> - **主要メソッド**:
> - `startScanning()` - スキャン開始
> - `stopScanning()` - スキャン停止
> - **プロパティ**: 検出されたビーコン一覧、スキャン状態
>
> ### 📤 BeaconTransmitter.swift
> - **機能**: iBeaconの発信機能を提供
> - **主要メソッド**:
> - `startTransmitting()` - 発信開始
> - `stopTransmitting()` - 発信停止
> - `updateBeaconParameters()` - MajorとMinor値の更新
> - **プロパティ**: 発信状態、Bluetooth状態
>
> ### 🎛️ BeaconTraceService.swift
> - **機能**: 発信とスキャンを統合制御するサービス
> - **動作モード**:
> - スキャンのみ
> - 発信のみ
> - スキャン&発信の同時実行
> - **主要メソッド**:
> - `startTrace()` - トレース開始
> - `stopTrace()` - トレース停止
> - `changeTraceMode()` - モード切り替え
>
> ### 🖥️ ContentView.swift
> readme.mdの要件に従って以下の機能を実装:
> - ✅ Traceの開始と停止ボタン
> - ✅ iBeaconのモード切り替え(スキャン/発信/両方)
> - ✅ リアルタイムステータス表示
> - ✅ 検出されたビーコンの一覧表示
>
> ### 📋 Info.plist
> Core LocationとCore Bluetooth使用のためのプライバシー権限設定を追加しました。
>
> これで、iBeaconのスキャンと発信機能を持つアプリケーションが完成しました。アプリを実行すると、モードを選択してiBeaconの動作をテストできます。

readme.md を参照して、

- GattAdvertise : GATT アドバタイズ
- GattClient : GATT クライアント
- GattServer : GATT サーバー
- GattTraceService : GATT サービスの発信とスキャンを制御する

を作って。


> Copilot: readme.mdを参考にして、GATT関連のサービスクラスを作成いたします。GATT(Generic Attribute Profile)のアドバタイズ、クライアント、サーバー、そしてそれらを制御するサービスを作成します。 次に、ContentView.swiftを更新して、GATTとiBeaconの両方の機能を切り替えできるようにします:
> ```
>
> ```
> ## 作成完了 ✅
>
> readme.mdの仕様に基づいて、GATT関連の4つのサービスクラスを作成し、ContentView.swiftも更新しました:
>
> ### 📡 GattAdvertise.swift
> - **機能**: GATTアドバタイズ機能を提供
> - **主要メソッド**:
> - `startAdvertising()` - アドバタイズ開始
> - `stopAdvertising()` - アドバタイズ停止
> - `updateCharacteristicValue()` - キャラクタリスティック値の更新
> - **特徴**: カスタムサービスとキャラクタリスティックを設定し、クライアントからの読み書き要求に対応
>
...

カテゴリー: 開発 | Claude Sonnet で簡単な Android/iOS アプリを作る はコメントを受け付けていません

要件定義と概要設計を作成して、3マッチゲームを作る

3度目の正直ということで、要件定義(requiremnet.md)と概要設計(desing.md)を作成して、Claude Sonnet で3マッチゲームを作ってみます。

完全にオートメーションという訳にはいかないでしょうが、出だしぐらいはいけるんじゃないでしょうか? Claude Code のように git へのコミットとかはできませんが、この手のプログラムを AI エージェントに一括に任せることは少なく(アイデアを間に差し込むので)、初期のプロトタイプの底上げが目的です。これまでの2回は、自動生成させたコードがバグバグで到底直せない感じになってしまったので、ちょっと慎重に概要設計を書いています。

要件定義 requiremnet.md

# 要件定義

マッチ3ゲームの要件定義を以下に示す

## ゲームの目的

- 同じ色のジェルを3つ以上揃えて消す
- ブロックを消しときに、爆弾、中爆弾などのアイテムが出現する。アイテムを消すと、ジェルの消え方が様々に変わる
- ジェルを動かしたり、アイテムをクリックすることで、残り動作のカウントが減る。ゲームスタート時のカウントが0になるとゲームオーバーになる。
- すべてのブロックの裏にパネルが貼り付けてあり、すべてのパネルを消すことがゲームの目的となる

## ゲームのルール

- プレイヤーは隣接するブロックを入れ替えて、3つ以上の同じ色のブロックを揃える
- ブロックが消えると、上のブロックが下に落ちてくる
- 新しいブロックが上から追加される
- 消せない岩ブロックがある
- パネルをすべてのブロックの裏に配置させて、すべてのパネルを消すようにジェルや爆弾を操作する

## ユーザーインターフェース

- ゲームボードは8x8のグリッド
- 各ブロックは異なる色と形を持つ
- スコア、残り動作のカウント、レベルを表示するUI要素

## 機能要件

- Webブラウザ上で動作する
- モバイルデバイスに対応したレスポンシブデザイン
- 高速な描画とアニメーションでユーザー体験を向上させる。SVG形式を採用すること。
- 爆弾が消えるときには派手なアニメーションをつける


## 非機能要件

- ゲームのパフォーマンスは、低スペックのデバイスでも快適に動作すること
- アクセシビリティを考慮し、キーボード操作ができること
- ジェルが消えるロジックは関数化されて、テスト可能となっていること
- ジェルの色は3色から5色まで変更可能とすること

概要設計 desing.md

# 概要設計

このドキュメントは、システムの概要設計を記述します。以下のセクションでは、システムのアーキテクチャ、主要なコンポーネント、および相互作用について説明します。

## システムアーキテクチャ

- マッチ3ゲームは、ブラウザ上で動作する
- 画面をカラフルにするための SVG 形式を使用する
- ゲームのロジックは TypeScript で実装される
- ゲームのロジックは、単体テストが可能にする。ロジックは UI の ファイルを別にする

## 主要コンポーネント

### ゲームボード

- 8x8のグリッドで構成される
- 各セルには異なる色と形のブロックが配置される
- ブロックは、ジェルと呼ばれる

### ユーザーインターフェース

- スコア、残り動作のカウント、レベル、パネルの数を表示するためのUI要素が含まれる
- ゲームの開始、リセット、終了などの操作を行うためのボタンが含まれる
- ゲームの進行状況を表示するためのアニメーションが含まれる

### ゲームロジック

- ブロックの入れ替え、消去、スコア計算などのゲームロジックを処理する
- ジェルの消去ロジックは関数化され、テスト可能な形で実装される
- 爆弾や中爆弾の動作を制御するロジックが含まれる
- ゲームの進行状況を管理し、ゲームオーバーやレベルアップの判定を行う

### アニメーション

- ブロックの消去や爆弾の動作に対するアニメーションを制御する
- ジェルが上から落下するときになめらかになるようにアニメーションする
- ジェルが消えるときのアニメーションは派手にする
- ゲームの進行に合わせて、アニメーションがスムーズに動作するように最適化される
- アニメーションは、SVG形式で実装される

要件定義と概要設計の違い

ひとりプロジェクトであったり、顧客=開発者であればとくに要件定義と概要設計を分離しなくてもよいのですが、実際の仕事としては発注側=顧客が要件定義を行い、受注側=IT屋が概要設計を書きます。実際のところは発注側はITに詳しくないのでコンサルタントや場合によっては受注側が要件定義を代筆することが多いのですが、これらの二つは契約と言う点で発注/受注という大きな違いがあります。

簡単にいえば、要件定義が目的であり、概要設計が手段です。目的を達成するためには手段を変えても構いません。しかし目的を変える場合には顧客の合意が必要です。そういう意味では、

  • AI エージェントは要件定義の内容を変更できない。
  • AI エージェントは、場合によっては概要設計の内容を変更する提案ができる。

という違いがあります。

実は、AI エージェントに対して要件定義や概要設計のレビューを求めることができます。このレビューは要件定義内にある矛盾を叩き出すのに有効です。同時に設計の齟齬も発見してくれます。逆に顧客=自分としてはこの要件定義は外せないと思うならば、要件定義内に「これは契約上重要である」ことを強調すればよいでしょう。そのあたりの整合性を AI が判断しているかどうかは判別できないのですが。

Claude Sonnet の進捗状況

なんとなくできたようです。

ロジックができていて、テストコードをも一応作られています。

まあ、動きは変なんですけどね。。。

どうも落下アニメーションがブラウザ上でうまくいかないのは定番っぽいです。

Expoの開発サーバーが既に起動されているので、ブラウザまたは Expo Go アプリでゲームをプレイできます。ゲームは要件定義と設計仕様に忠実に実装されており、マッチ3ゲームの基本的な遊び方から爆弾システムまで完全に機能します。

と豪語していますが、到底完璧とは言えません。が、以前よりも随分マシになりました。

npm test 

テストコードを動かすと失敗しますがw

ところどこ動かないのは仕方がないのところなので、このあたりは人間が手を入れるかというところでしょう。いちおう、何回かプロンプトで指定をすると、直りました。えらい!

開発はスピードアップするのか?

私の場合、問題なくスピードアップしています。と言いますか、スピードアップするところだけ使っています。私自身の使い方としては、

  • React Native のような自分の未知なプロジェクトの雛形を作成する
  • Kotlin/Swift の最新版のコードをコメント込みで入れ込む
  • Kotlin/Swift からデッドコードとなったクラスを指定して削除する
  • 組み込みC言語のコンパイルエラーから、コードを修正する目途をつける
  • Laravel の不明な実行エラーから、コードを修正する目途を付ける

とにかく最初のひな型の作成と不明なエラーメッセージを代わりに読んでくれるのは圧倒的に楽です。変なロジックを踏んで、どうにもならなくなったら早々に AI エージェントの動作を取りやめて、方針を変換します。

  • 特定の関数やクラスを切り出すように指示する

おおむね、ペアプロのナビゲータ役か、結合テストのテスター役に徹するとうまくいきます。コードを書くときは、変数名などを AI エージェントが書いているほうに合わせます。このほうが、AI にとって続きが書きやすく、変数名の揺れが少なくなります。

要件定義や概要設計を AI が読んで返して来た用語をそのまま使うようにします。ラショナル統一プロセスの「用語集」やドメイン駆動設計の「ユビキタス言語」を意識するとよいでしょう。どうも、下手に人間側の用語を持ち出すと AI が誤解をし始めるので、いまのところは人間が合わせたほうが無難です。用語については、別途 *.md に書き出してもよいかもしれません(トークンを消費しそうですが)。

テストを繰り返す構造

爆弾セルの動きがおかしかったので指摘をすると、テストコードを追加してテストしてくれます。

ゲームロジックと UI の表示をどうやってテストするのかが難しいところですが、AI エージェントのテストも私のテストも同じことをやってくれます。UI のテストは結構手間なのですが、今回の3マッチゲームの場合はボードに配置する(2次元配列に配置する)ことになるので、爆弾やジェルのマッチの前後を配列そのもので表すことができます。これをちまちま手作業で書いてテストの成功例を書いていきます。実に面倒臭い作業なのですが、これが後々効いてきます。そのあたりの単純作業を AI エージェントが肩代わりしてくれるのでかなり助かります。

落下アニメーションは、ジェルが消えた場所だけにして。
全体のジェルが落ちてくるアニメーションになっています。

なんというか、お茶目な感じにバグを頻発させますが、まあ以前よりはましな気がします。

続く…

カテゴリー: 開発 | 要件定義と概要設計を作成して、3マッチゲームを作る はコメントを受け付けていません

Claude Sonnet で 3マッチゲームが作れるのか?

Amazon の Kiro で spec 駆動(仕様駆動)が流行りですが、AI エージェントを使うと Kiro とかじゃなくても結構いけます、というお話です…と同時に、それ以上いけません、というのも記録しておきます。

手元で試している Claude Sonnet は Claude Code の廉価版みたいなもので、GitHub Copilot Pro(月10ドル) + Claude Sonnet(無料)の組み合わせ開発が可能です。業務や精度をアップしたいときは Claude Code 等を使うといいんでしょうがお金が結構かかるのと同時にリクエスト制限が結構きついです。X を見る限り2,3時間でリミットに達する模様。その点では、GitHub Copilot Pro + Claude Sonnet の組み合わせだとリクエストが無制限になっていて(本家、Claude Sonnet だけだと時間内制限があります、何故w)、それなりに開発ができます。ちなみに、皆さんが Opus 4 を使っているらしく、Sonnet 4 のほうは空いていてレスポンスがよいです。

ちなみに、モデルを GPT40 に変えることができるのですが、コード生成の質が悪くて、最初の課題がクリアできません。素直に Claude Sonnet を使う方がいいです。

React Native Expo で3マッチゲームを作る

3マッチゲームは結構古くからあって、ルール自体は隣のブロックを入れ替えて3つに並んだら消えるという簡単なものです。samegame もそうですよね。ルールが簡単なので、プログラムで作るのも比較的簡単なのですが、最初の頃からバリエーションが増えてきて、最近ではいろいろなブロックのアイテムがあります。

Fishdom
https://play.google.com/store/apps/details?id=com.playrix.fishdomdd.gplay&hl=ja

単純に上から下というわけでもなく、課金うんぬんはさておき、ディズニーキャラクター版とかもあったります。単にぽちぽち消すのではなく、ぷよぷよみたいに連鎖しないと意味がありません。

というわけで expo でプロジェクトを作ります

npx create-expo-app swap-game-expo0 --template expo-template-blank-typescript

実は Kiro 風に readme.md を作って、プロジェクトを作成するところからスタートしても構いません。が、プロジェクト構造を最初に決めておいて、どんなフレームワークを使うのかを AI エージェントに伝えたほうが完成の角度が高いです。

Expo プロジェクトができたら、プロンプトに入力します。

3マッチゲームを作って。

設計上いろいろ決めてもいいのですが、ひな形を先に作って貰った方が早いです。

おそらく、この手のゲームのテンプレートがモデル内にある感じがしています。Transformer だけではちょっと無理かなと思うのですが、ここでは問いません。多分、どの AI エージェントを使っても3マッチゲームとかオセロとか定番なものは揃っているでしょう。

スマホで動かす前に PC の web ブラウザ上で動かしてみましょう。

npm run web

で起動できるのですが、web のライブラリが入っていないのでこれを入れます。

npx expo install react-dom react-native-web @expo/metro-runtime

この手の問題は「web ブラウザでも動くように」とプロンプトで指示するといけるような気もするのですが、ペアプロ的に作りながら指示したほうが楽です。

ちなみにプロンプトで「3マッチゲームを作って」と指示をしても同じコードが生成されるとは限りません。AI エージェントは生成 AI と同じように内部ではランダムな回答を出すようになっている(ことが多い)ので、生成されるコードもある程度ランダムになります。つまり、再現性が低いのです。

spec 駆動の場合は、これを要件/設計(claude.md)で詰めることになっていますが、夜間バッチで動かすためのものなので、正確に狙った形の出力を得るのは難しいでしょうね。一案としては、設計を書いたあとに、5~10個ぐらい同時に動かしてコード生成をした後に、よいものをピックアップするよいでしょう。いわゆる A/B テストか、遺伝子プログラミングのように目標を絞っている形になるのですが、まあ、課金は結構かかりそうです。そのあたりは、タイムパフォーマンスをどのあたりで手を打つかというところでしょう。

私の場合は、ペアプロ風に readme.md を書いていくほうが開発スタイルに合っています。

結論から言えば「3マッチゲームを作って」というプロンプトだけで、ここまでいけます。ブロックをマウスで選択して交換する、ブロックが消える、というルールは満たしてあります。UI としてはしょぼいですが、最初のプロトタイプとしてはこれで十分ですよね。去年あたりに所謂「AI 驚き屋」さんがやっていたのもこんな感じです。ブロック崩しとかオセロとかちょっとしゅたシューティングゲームとかの定番は、モデル自身が持っているテンプレート(たぶん)から引っ張ってくるだけで ok です。これは TypeScript で書かれていますが、他の言語に直すこともできます。こういう使い方は便利ですね。

ジェルを SVG で描画する

遊べるの少し動かしてみるのですが、かなりしょぼいです。

小学生の夏休みの宿題ならばなんとかななりそうだけど、仕事?では無理そうです。コードを見ると単純なHTMLを使っているだけなので、消えるときのアニメーションとから落下のアニメーションがありません。このあたり「落下のアニメーションを付けて」とか試行錯誤することもできるのですが、そもそも SVG 形式で描画しないとカラフルにはなりませんよね。

SVG を使ってジェルなどをカラフルにして。

プロンプトで「SVG」を指定するのですから、spec駆動の場合には設計時に「SVG形式を使うどうか」というのが問題になりますよね。そのあたりspec駆動の場合は狙うことができるのかは不明です。ただし、SwiftUI とか Compose UI とかの標準的なコンポーネントだけ使う場合はうまくいくかもしれません。そのあたり、デザイナー視点をどう含めるかが問題ですが。私の場合は、ロジックを先に作って UI で装飾、という手順のほうがよいと思っています。

SVG 形式を使ったのでかなりカラフルになっています。「カラフル」という用語を使ったのですが、これもモデルに内で最適化されたものでしょう。これあこれで遊べるのですが、落下アニメーションとか爆弾のアイテムとかがないので、かなりしょぼいです。

落下アニメーションを付ける

落下アニメーションを付けて。
ジェルが3つ消えるときに爆弾アイテムを出現させて。

SVG のときもそうでしたが、AI エージェントは App.tsx をがんがん書き変えます。これが人の開発者であれば、構造化したり共通化したりするところですが AI の場合は検索できるパターンがあれば機械的に書き変えるの手間がかからないので、全て書き変えてしまう勢いです。

将来的に AI エージェントで出力させたコードの Git 履歴とかが難しいでしょうね。ある程度コード量が多くなってくると、トークン数の制限のためにコードを部分的に読み出すように最適化されてきます。このときに、設計スタイルとしてオブジェクト指向の各種パターンを使うかどうかは定かではないのですが、試した感じでは「1回のトークン数を超えていくところからバグを大量発生」させていきます。おそらく全体の整合性がとれなくなってしまうのでしょう。人の開発者ならば、構造化して眺めるコード量を抑えるのですが、現在の AI エージェントではその視点がありません(上位バージョンの Opus 4 にはあるかもしれません)。なので、AI エージェントが読み込みやすいようにある程度コードを構造化するようにプロンプトで誘導するのがベターかもしれません。

落下アニメーションが、ジェルが消える場所を埋めるように落下させて。
爆弾をクリックしたら、上下左右の4個のジェルを消して。爆発アニメーションを付けて。

あと、ブラウザでアニメーションがうまく表示されていないので、書き変えて貰います。

落下アニメーションがブラウザででていません。

このあたり、一発で思った通りのものが出力されません。それどころか、何かの指示を出すたびにどこかのバグを含めてしまいます。それをいちいち UI を動かして動作を確認していきます。このあたりは、ちょうどテスターの役割を人間が担います。

ゲームを動かして、ちょっとずつ完成させていくイメージですね。自分でコーディングしたほうが早いのではないか?って場合もあるでしょうが。実際、ある程度の複雑なコードの場合は人間がやったほうがよさそうです。ただし、AI エージェントのコードは可読性がよくないので、人が読むのは一苦労です。可読性が良くなるようにリファクタリングした貰うといいかもしれません。

落下するときのアニメーションをなめらかにしたり、爆弾アイテムをつけたりした状態がこの状態になります。動きがおかしいですよね。一見すると複雑な条件で設計ができそうなのですが、3マッチゲームはなかなか完成しません。

おそらく、条件の組み合わせ爆発のときに駄目っぽい感じがします。業務アプリケーションのように管理画面で CRUD 機能をつけるとかカテゴリから商品をひらくとかいうステートがあまり変化しないものは大丈夫なのですが、3マッチゲームのようにまともにやると遷移表が爆発しそうなものは AI エージェント頼りでは難しそうです。

テスト可能なように構造化する

まあ、ゲームロジックの場合は、いろいろな条件が重なるので View と Logic を分離させておくのがよいでしょう。

ジェルの消去や爆弾の出現条件をテストできるようにコードを構造化して。

果たして、テスト可能なように構造化してくれるでしょうか?

デバッグ画面っぽいものは出るのですが、なんか違うんですよね…これは、Claude の中の人が悪いのか、Sonnet が悪いのか。

続きは後日。

ちなみに、現在のルールとかは次のプロンプトを使うとよいです。

設計書を design.md に書き出して。

ファイル名とか文言とかは変更しても大丈夫です。まあ、致命的なのは、この design.md を最初に渡して3マッチゲームが完成されないことなんですよね。これも再現性がないので、何回か繰り返すと猿がシェイクスピアを生み出す確率でうまく出力されるかもしれないですが。

# 3マッチゲーム 設計書

## 概要
React Native/Expoを使用したモバイル向け3マッチパズルゲーム。SVGベースのカラフルなジェルと爆弾アイテム、落下アニメーション、爆発エフェクトを特徴とする。

## 技術仕様

### フレームワーク・ライブラリ
- **React Native**: 18.x
- **Expo**: SDK対応
- **react-native-svg**: SVGグラフィックス
- **TypeScript**: 型安全性

### 対応プラットフォーム
- iOS
- Android
- Web (Expo Web)

## ゲーム仕様

### 基本ルール
1. 8x8のグリッドボード
2. 5種類のジェル(赤、緑、青、黄、紫)
3. 隣接するタイル同士の交換
4. 3個以上の同色マッチで消去
5. 重力による落下システム
6. 空いたスペースに新しいタイルが補充

### 特殊アイテム
- **爆弾**: 4個以上のマッチで生成
- **爆弾効果**: 上下左右4方向のタイルを消去
- **爆発アニメーション**: スケール・透明度変化

### 勝利条件・ゲーム終了
- エンドレス仕様(現在)
- 将来的にスコアシステム、レベル制を追加予定

## アーキテクチャ設計

### コンポーネント構成

```
App
├── ExplosionEffect (爆発エフェクト)
├── BombTile (爆弾タイル)
├── AnimatedGelTile (ジェルタイル)
└── DebugPanel (デバッグUI)
```

### 状態管理

```typescript
// メインゲーム状態
const [board, setBoard] = useState<TileType[][]>() // ボード状態
const [selected, setSelected] = useState<Position>() // 選択中タイル
const [isAnimating, setIsAnimating] = useState<boolean>() // アニメーション中フラグ
const [explosions, setExplosions] = useState<Position[]>() // 爆発エフェクト
const [debugMode, setDebugMode] = useState<boolean>() // デバッグモード

// アニメーション管理
const animationValues = useRef<Animated.Value[][]>() // 落下アニメーション値
```

### データ型定義

```typescript
// 基本型
const TILE_TYPES = ['red', 'green', 'blue', 'yellow', 'purple'] as const;
const SPECIAL_ITEMS = ['bomb'] as const;
type TileType = typeof TILE_TYPES[number] | typeof SPECIAL_ITEMS[number] | null;

// 位置情報
interface Position {
y: number;
x: number;
}

// アニメーション情報
interface AnimationInfo {
fromY: number;
toY: number;
tile: TileType;
}
```

## ゲームロジック設計

### GameLogicオブジェクト

```typescript
const GameLogic = {
// ボード操作
createBoard(): TileType[][] // ランダムボード生成
createTestBoard(pattern): TileType[][] // テスト用ボード生成
cloneBoard(board): TileType[][] // ボードクローン

// マッチング処理
findMatches(board): boolean[][] // マッチ検出
removeMatches(board, matched): result // マッチ消去

// 爆弾処理
handleBombExplosion(board, y, x): count // 爆弾爆発処理
shouldGenerateBomb(count): boolean // 爆弾生成判定

// タイル操作
dropTiles(board): void // 落下・補充処理

// テスト・デバッグ用
countTotalMatches(board): number // マッチ数カウント
countTilesByColor(board, color): number // 特定色カウント
boardToString(board): string // ボード文字列化
}
```

### 処理フロー

#### 1. タイル交換処理
```
handleTileClick(y, x)
├── 爆弾チェック
│ ├── 爆発アニメーション開始
│ ├── handleBombExplosion()
│ ├── dropTiles()
│ ├── 落下アニメーション
│ └── processMatches()
└── 通常タイル
├── 選択状態更新
├── 隣接チェック
├── タイル交換
└── processMatches()
```

#### 2. マッチ処理
```
processMatches(board, shouldGenerateBombs)
├── findMatches()
├── removeMatches()
├── 爆弾生成判定 (4個以上)
├── dropTiles()
├── 落下アニメーション
└── 再帰処理(連鎖)
```

#### 3. 落下アニメーション
```
calculateDropAnimations(oldBoard, newBoard)
├── 各列の移動追跡
├── animationMapの生成
└── 距離計算

animateDrop(animationData)
├── アニメーション設定
├── 並列実行
└── 完了コールバック
```

## UI/UX設計

### ビジュアルデザイン
- **テーマ**: ダークモード(背景: #1a1a2e)
- **ジェル**: SVGグラデーション、立体感
- **爆弾**: メタリック質感、導火線付き
- **エフェクト**: 爆発は放射グラデーション + 火花

### アニメーション仕様
```typescript
// 落下アニメーション
duration: Math.abs(fromY - toY) * 80 // 距離比例
easing: Linear // 等速度

// 爆発エフェクト
scale: 0 → 3 // 拡大
opacity: 1 → 0 // 透明化
duration: 400ms // 固定時間
```

### レスポンシブ対応
- タイルサイズ: 38px固定
- ボードサイズ: 8 * 42px (パディング含む)
- 画面サイズに応じた中央配置

## テスト設計

### テスト機能
```typescript
// デバッグモード機能
testThreeMatch() // 3マッチテスト
testBombGeneration() // 4+マッチ→爆弾生成テスト
testBombExplosion() // 爆弾爆発テスト
logBoardState() // ボード状態ログ出力
forceMatchAt() // 強制マッチ作成
```

### テストパターン
1. **3マッチテスト**: 爆弾生成されない
2. **4+マッチテスト**: 爆弾生成される
3. **爆弾テスト**: 十字方向消去確認
4. **連鎖テスト**: 連続マッチ処理
5. **落下テスト**: アニメーション確認

## パフォーマンス設計

### 最適化手法
- `useNativeDriver: false`: Web互換性重視
- アニメーション並列実行: `Animated.parallel()`
- 状態更新最小化: 必要時のみsetBoard()
- メモリ管理: アニメーション完了時リセット

### レンダリング最適化
- `key`属性適切設定: `tile-${y}-${x}`
- SVGグラデーションID重複回避: `${color}-${position}`
- 条件付きレンダリング: デバッグパネル、エフェクト

## セキュリティ・品質

### エラーハンドリング
- 境界値チェック: 配列インデックス
- null/undefined対策: オプショナルチェーン
- アニメーション中操作制限: isAnimatingフラグ

### コード品質
- TypeScript型安全性
- ESLintルール準拠
- コメント・ドキュメント完備
- 関数型プログラミング重視

## 今後の拡張予定

### 機能追加
- [ ] スコアシステム
- [ ] レベル制・難易度調整
- [ ] 音効・BGM
- [ ] 特殊アイテム追加(レインボー、ラインクリア等)
- [ ] アチーブメントシステム

### 技術改善
- [ ] Redux/Zustand状態管理
- [ ] React.memo最適化
- [ ] PWA対応
- [ ] オフライン機能
- [ ] マルチプレイヤー

### テスト強化
- [ ] Jest単体テスト
- [ ] E2Eテスト (Detox)
- [ ] パフォーマンステスト
- [ ] アクセシビリティテスト

## 開発環境

### 必要ツール
```bash
npm install -g expo-cli
npm install react-native-svg
```

### 実行方法
```bash
npx expo start # 開発サーバー起動
npx expo start --web # Web版実行
```

### ビルド
```bash
npx expo build:ios # iOS向けビルド
npx expo build:android # Android向けビルド
```

---

**作成日**: 2025年7月22日
**バージョン**: 1.0.0
**更新者**: AI Development Assistant
カテゴリー: 開発 | Claude Sonnet で 3マッチゲームが作れるのか? はコメントを受け付けていません

asp.net minimal を Docker 対応して、azure に up するまで…いかず

asp.net minimal をローカル環境でなく Azure で動作できるようにします。ひとまず、Docker 化しておきましょう。

Dockerfile を作る

aspnet-minimal-sample の Dockerfile を書いて。

毎度、Docker ファイルを作るのが面倒なので、作ってくれるのはありがたい。

mysql も使うので docker-compose.yml も次いでに作ってくれます。

Dockerfile

# ASP.NET Minimal API Dockerfile

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# Copy csproj and restore dependencies
COPY *.csproj ./
RUN dotnet restore

# Copy everything else and build
COPY . ./
RUN dotnet publish -c Release -o /app/publish

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app

# Copy published files
COPY --from=build /app/publish .

# Copy static files (wwwroot)
COPY --from=build /src/wwwroot ./wwwroot

# Create a non-root user
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser /app
USER appuser

# Expose port
EXPOSE 8000

# Set environment variables
ENV ASPNETCORE_URLS=http://+:8000
ENV ASPNETCORE_ENVIRONMENT=Production

# Entry point
ENTRYPOINT ["dotnet", "aspnet-minimal-sample.dll"]

docker-compose.yml

version: '3.8'

services:
  # MySQL Database
  mysql:
    image: mysql:8.0
    container_name: mos-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mos
      MYSQL_USER: mos
      MYSQL_PASSWORD: mos
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./database/init:/docker-entrypoint-initdb.d
    networks:
      - mos-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  # ASP.NET Web API
  webapi:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: mos-webapi
    ports:
      - "8000:8000"
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__DefaultConnection=Server=mysql;Database=mos;User=mos;Password=mos;
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - mos-network
    volumes:
      - ./wwwroot/images:/app/wwwroot/images

volumes:
  mysql_data:

networks:
  mos-network:
    driver: bridge

いまどき、Dockerfile や docker-compose.yml を一から作ることはなくて、大体が似たようなプロジェクトからコピーして少し修正するぐらいなので、その元ネタが Claude 由来ってところでしょうか。

マイグレーションファイルを作る

mysql に categories テーブル等を作成するマイグレーション用のコードを作成して。

データベースを Docker 内に構築するので、テーブル定義からマイグレーション用のコードを生成します。以前ならば、何らかのツールか手作業で作成するところですが、これも一気に作ってくれます。

Seeder のサンプルも作ってくれて、丁寧にも readme.md にツールのドキュメントが生成されます。

README.md

# ASP.NET Core Minimal API with MySQL - マイグレーション管理

このプロジェクトでは、Entity Framework Coreを使用してMySQLデータベースとの連携を行います。

## 前提条件

- .NET 9.0
- MySQL 8.0以降
- Entity Framework Core Tools

## データベース設定

### 1. 接続文字列の設定

`appsettings.json`または`appsettings.Development.json`で接続文字列を設定してください:

```json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=mos;User=root;Password=your_password;Port=3306;"
  }
}
```

### 2. マイグレーションの管理

#### PowerShell(Windows)

```powershell
# 初期マイグレーションを作成
.\migrate.ps1 init

# 新しいマイグレーションを作成
.\migrate.ps1 add "AddNewTable"

# データベースを最新のマイグレーションに更新
.\migrate.ps1 update

# マイグレーションの状態を確認
.\migrate.ps1 status

# 初期データを投入
.\migrate.ps1 seed

# データベースをリセット(危険)
.\migrate.ps1 reset
```

#### Bash(Linux/Mac)

```bash
# 初期マイグレーションを作成
./migrate.sh init

# 新しいマイグレーションを作成
./migrate.sh add "AddNewTable"

# データベースを最新のマイグレーションに更新
./migrate.sh update

# マイグレーションの状態を確認
./migrate.sh status

# 初期データを投入
./migrate.sh seed

# データベースをリセット(危険)
./migrate.sh reset
```

#### 手動でのEntity Framework Coreコマンド

```bash
# 初期マイグレーションを作成
dotnet ef migrations add InitialCreate

# データベースを更新
dotnet ef database update

# 初期データを投入
dotnet run -- --seed

# マイグレーションの状態を確認
dotnet ef migrations list

# 特定のマイグレーションに移行
dotnet ef database update TargetMigration

# データベースを削除
dotnet ef database drop
```

## マイグレーションファイルの構成

### 自動生成されるマイグレーション

Entity Framework Coreは、モデルの変更を検出して自動的にマイグレーションを作成します:

- `Migrations/[timestamp]_InitialCreate.cs`: マイグレーションの実行内容
- `Migrations/[timestamp]_InitialCreate.Designer.cs`: EF Coreのメタデータ
- `Migrations/AppDbContextModelSnapshot.cs`: 現在のモデルのスナップショット

### 手動でのSQL実行

手動でSQLを実行する場合は、`database/init/01_create_tables.sql`を使用できます:

```sql
-- MySQLに直接接続して実行
mysql -u root -p mos < database/init/01_create_tables.sql
```

## 初期データについて

### 自動投入される初期データ

`Services/DatabaseSeeder.cs`で以下のデータが自動投入されます:

- **カテゴリ**: 4件(コーヒー、紅茶・ティー、フード、季節限定)
- **商品**: 12件(各カテゴリの代表的な商品)
- **注文**: 2件(サンプル注文データ)
- **注文商品**: 関連する注文商品データ

### 初期データの投入

```bash
# 初期データを投入
dotnet run -- --seed

# または PowerShell
.\migrate.ps1 seed

# または Bash
./migrate.sh seed
```

## Docker環境での使用

Docker環境では、データベースの初期化が自動で行われます:

```bash
# 開発環境の起動
docker-compose -f docker-compose.dev.yml up

# 本番環境の起動
docker-compose up
```

## トラブルシューティング

### マイグレーションエラー

```bash
# マイグレーションをリセット
dotnet ef migrations remove

# データベースを削除して再作成
dotnet ef database drop
dotnet ef database update
```

### 接続エラー

1. MySQL サーバーが起動していることを確認
2. 接続文字列が正しいことを確認
3. データベース `mos` が存在することを確認

### パッケージエラー

```bash
# パッケージを復元
dotnet restore

# キャッシュをクリア
dotnet nuget locals all --clear
```

## API エンドポイント

マイグレーション後、以下のAPIエンドポイントが利用可能になります:

- `GET /mos/api/categories` - カテゴリ一覧
- `GET /mos/api/products` - 商品一覧
- `GET /mos/api/orders` - 注文一覧
- `POST /mos/api/orders` - 注文作成

詳細な仕様については、`webapi/mos-api.yaml`を参照してください。

DatabaseSeeder.cs を書き変える

データベースに投入する初期値は DatabaseSeeder.cs に書かれています。

using aspnet_minimal_sample.Data;
using aspnet_minimal_sample.Models;
using Microsoft.EntityFrameworkCore;

namespace aspnet_minimal_sample.Services;

public class DatabaseSeeder
{
    private readonly AppDbContext _context;

    public DatabaseSeeder(AppDbContext context)
    {
        _context = context;
    }

    public async Task SeedAsync()
    {
        // 既存のデータがある場合はスキップ
        if (await _context.Categories.AnyAsync())
        {
            Console.WriteLine("データベースには既にデータが存在します。初期データの投入をスキップします。");
            return;
        }

        Console.WriteLine("初期データを投入中...");

        // カテゴリの初期データ
        var categories = new List<Category>
        {
            new Category
            {
                Slug = "coffee",
                Title = "コーヒー",
                Description = "厳選されたコーヒー豆を使用したドリンク",
                Image = "/images/categories/coffee.jpg",
                SortId = 1,
                Display = 1
            },
            new Category
            {
                Slug = "tea",
                Title = "紅茶・ティー",
                Description = "世界各地から厳選された紅茶とハーブティー",
                Image = "/images/categories/tea.jpg",
                SortId = 2,
                Display = 1
            },
            new Category
            {
                Slug = "food",
                Title = "フード",
                Description = "軽食やデザートなど、お飲み物と一緒にお楽しみください",
                Image = "/images/categories/food.jpg",
                SortId = 3,
                Display = 1
            },

ちまちま書き変えてもいいのですが、既に laravel-webapi-sample で CategorySeeder.php と ProductSeeder.php が書かれているので、これを流用しましょう。

laravel-webapi-sample の ProductSeeder と CategorySeeder があるので、
これを使って aspnet-minimal-sample の DatabaseSeeder のデータを書き変えて。

PHP のコードから C# のコードに書き変えるだけなので、Copilot + Claude Sonnet の得意とするところでしょう。

コンバートが出来て一見できているように見えますが、CategoryIdの値が固定になっています。

        // 商品の初期データ
        var products = new List<Product>
        {
            // ハンバーガー
            new Product
            {
                CategoryId = categories[4].Id, // ハンバーガー
                Slug = "burger1",
                Name = "モスバーガー",
                Description = "",
                Image = "burger1.jpg",
                Price = 440,
                SortId = 1,
                Display = 1
            },
            new Product
            {
                CategoryId = categories[4].Id, // ハンバーガー
                Slug = "burger2",
                Name = "モスチーズバーガー",
                Description = "",
                Image = "burger2.jpg",
                Price = 480,
                SortId = 2,
                Display = 1
            },

実は、カテゴリはあちこちに割り振られるようにランダム値にしています。これを C# のコードにもいれておきます。

        // カテゴリの最大値からランダムidを取得
        function fake_category_id() {
            $maxId = Category::max('id');
            return rand(1, $maxId);
        }

この部分は手作業と Copiot で変更

        // カテゴリの最大値からランダムidを取得
        int fake_category_id()
        {
            var maxId = _context.Categories.Max(c => c.Id);
            return Random.Shared.Next(maxId) + 1; // 1からmaxIdまでのランダムな値を生成
        }


        // 商品の初期データ
        var products = new List<Product>
        {
            // ハンバーガー
            new Product
            {
                CategoryId = fake_category_id(),
                Slug = "burger1",
                Name = "モスバーガー",
                Description = "",
                Image = "burger1.jpg",
                Price = 440,
                SortId = 1,
                Display = 1
            },

.devcontainer コンテナを作成する

vscode では開発環境をコンテナ化できるのでこれを作成します。

.devcontainer を作成して。

できあがったっぽいので、いったん vscode を閉じて開き直します。

右下に「コンテナ―で再度開く」ボタンが出てきたら ok です。これをクリックすると Docker コンテナを作り始めます。

コマンドパレットで「開発コンテナ:コンテナ―で再度開く」を選択しても ok です。

で、一発でうまくいけばいいのですが、大抵はうまくいきません。素直に AI に聞いてみましょう。AI が書いたんだし。

リモートコンテナを開いたときにエラーがでます。

ubuntu の dotnet 9.0 のイメージが無いようです。

豪快に 8.0 に以降しようとしていますが、9.0 で使いたいんですよね。。。

.NET(Core)で利用可能なDockerイメージとタグ #Docker – Qiita https://qiita.com/karuakun/items/8d98f0430bf2dbc6af59

これを見る限り、Ubuntu 22.04 ならば 9.0-jammy、Ubuntu 24.04 ならば 9.0-noble となるはずです。

FROM mcr.microsoft.com/dotnet/sdk:8.0
の代わりに
FROM mcr.microsoft.com/dotnet/sdk:9.0-noble
を使ってみて。

執拗に Pomelo.EntityFrameworkCore.MySql を薦めてくるのですが、現時点では Oracle 提供の MySql.EntityFrameworkCore を使った方がいいです。

MySql.EntityFrameworkCore を使って。

この手の話は、実際に開発経験がないとわからないところでもあるし、Claude Sonnet にしても内部での学習済みモデルを作成したときの収集データ時期の問題もあり、そもそも、MySql.EntityFrameworkCore を使った例があまりブログなどでないという現状もあり。このあたり、「正解」なのか「多数決」なのかが問われるところです。

Docker コンテナを作る

さて、ビルドが通ったところで Docker の開発コンテナを作ります。今度はうまくイメージファイルをダウンロードできそうです。

ちなみに、先の 9.0 から 8.0 のダウングレード騒ぎのときに、Claude Sonnet が既存のコンテナを全削除してしまいました。この手のやらかしは結構あるかもしれません。

一見うまくいきそうだったのですが、何故か https://download.docker.com/linux/debian のところで失敗しています。

------
> [dev_container_auto_added_stage_label 4/9] RUN curl -fsSL https://download.do
cker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-
keyring.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/s
hare/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debi
an $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/n
ull && apt-get update && apt-get install -y docker-ce-cli && apt-get
clean && rm -rf /var/lib/apt/lists/*:
0.917 Hit:1 https://deb.nodesource.com/node_20.x nodistro InRelease
1.171 Hit:2 http://archive.ubuntu.com/ubuntu noble InRelease
1.221 Hit:3 http://security.ubuntu.com/ubuntu noble-security InRelease
1.324 Ign:4 https://download.docker.com/linux/debian noble InRelease
1.391 Hit:5 http://archive.ubuntu.com/ubuntu noble-updates InRelease
1.614 Hit:6 http://archive.ubuntu.com/ubuntu noble-backports InRelease
1.656 Err:7 https://download.docker.com/linux/debian noble Release
1.656 404 Not Found [IP: 18.172.31.22 443]
1.728 Reading package lists...
3.740 E: The repository 'https://download.docker.com/linux/debian noble Release'
does not have a Release file.
------
failed to solve: process "/bin/sh -c curl -fsSL https://download.docker.com/linu
x/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
&& echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyring
s/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_rel
ease -cs) stable\" | tee /etc/apt/sources.list.d/docker.list > /dev/null &&
apt-get update && apt-get install -y docker-ce-cli && apt-get clean
&& rm -rf /var/lib/apt/lists/*" did not complete successfully: exit code: 100

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux
/jadc0jrt185aqxmaw15etxq1i
[2025-07-06T12:53:13.110Z] Stop (136147 ms): Run: docker compose --project-name aspnet-minimal-sample_devcontainer -f h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\docker-compose.yml -f c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data\docker-compose\docker-compose.devcontainer.build-1751806256960.yml build
[2025-07-06T12:53:13.129Z] Error: Command failed: docker compose --project-name aspnet-minimal-sample_devcontainer -f h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\docker-compose.yml -f c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data\docker-compose\docker-compose.devcontainer.build-1751806256960.yml build
[2025-07-06T12:53:13.129Z] at lw (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:432:525)
[2025-07-06T12:53:13.130Z] at async c6 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:432:2475)
[2025-07-06T12:53:13.130Z] at async u6 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:412:3489)
[2025-07-06T12:53:13.130Z] at async H6 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:484:4015)
[2025-07-06T12:53:13.130Z] at async BC (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:484:4957)
[2025-07-06T12:53:13.131Z] at async d7 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:665:202)
[2025-07-06T12:53:13.131Z] at async f7 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:664:14804)
[2025-07-06T12:53:13.131Z] at async c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:484:1188
[2025-07-06T12:53:13.219Z] Stop (143625 ms): Run: d:\tools\Microsoft VS Code\Code.exe c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js up --user-data-folder c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data --container-session-data-folder /tmp/devcontainers-434ce8f4-209d-49f9-b58e-f43a1a52828f1751806242331 --workspace-folder h:\ai-sample\src\webapi\aspnet-minimal-sample --workspace-mount-consistency cached --gpu-availability detect --id-label devcontainer.local_folder=h:\ai-sample\src\webapi\aspnet-minimal-sample --id-label devcontainer.config_file=h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --log-level debug --log-format json --config h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --default-user-env-probe loginInteractiveShell --mount type=volume,source=vscode,target=/vscode,external=true --mount type=bind,source=\\wsl.localhost\Ubuntu\mnt\wslg\runtime-dir\wayland-0,target=/tmp/vscode-wayland-97456619-a854-41e6-aa3d-2cca4ba71657.sock --skip-post-create --update-remote-user-uid-default on --mount-workspace-git-root --include-configuration --include-merged-configuration
[2025-07-06T12:53:13.220Z] Exit code 1
[2025-07-06T12:53:13.238Z] Command failed: d:\tools\Microsoft VS Code\Code.exe c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js up --user-data-folder c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data --container-session-data-folder /tmp/devcontainers-434ce8f4-209d-49f9-b58e-f43a1a52828f1751806242331 --workspace-folder h:\ai-sample\src\webapi\aspnet-minimal-sample --workspace-mount-consistency cached --gpu-availability detect --id-label devcontainer.local_folder=h:\ai-sample\src\webapi\aspnet-minimal-sample --id-label devcontainer.config_file=h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --log-level debug --log-format json --config h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --default-user-env-probe loginInteractiveShell --mount type=volume,source=vscode,target=/vscode,external=true --mount type=bind,source=\\wsl.localhost\Ubuntu\mnt\wslg\runtime-dir\wayland-0,target=/tmp/vscode-wayland-97456619-a854-41e6-aa3d-2cca4ba71657.sock --skip-post-create --update-remote-user-uid-default on --mount-workspace-git-root --include-configuration --include-merged-configuration
[2025-07-06T12:53:13.238Z] Exit code 1

で、

本当かどうかわからないのですが、Docker は 24.04 はサポートしていないらしく、22.04 を使うそうです。ほんとかな?

で、うまくいかないのでやり直し

2日間苦戦したのですが、何をやってもポートエラー等が取れなくなってしまったので、別途 minimal のみな asp.net web api を作って、Dockerfile を整理しました。

Dockerfile.dev

# Development Dockerfile for devcontainer
FROM mcr.microsoft.com/dotnet/sdk:9.0

# Set environment variables
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Tokyo

# Install additional tools
RUN apt-get update && apt-get install -y \
    git \
    curl \
    wget \
    unzip \
    vim \
    nano \
    zsh \
    sudo \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/* \
    && apt-get clean

# Create vscode user with sudo access
RUN groupadd --gid 1000 vscode \
    && useradd --uid 1000 --gid vscode --shell /bin/bash --create-home vscode \
    && echo vscode ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/vscode \
    && chmod 0440 /etc/sudoers.d/vscode

# Set up workspace directory with proper permissions
WORKDIR /workspace
RUN chown vscode:vscode /workspace

# Switch to vscode user for the rest of the setup
USER vscode

# Set umask to ensure proper permissions for created files
RUN echo "umask 022" >> /home/vscode/.bashrc \
    && echo "umask 022" >> /home/vscode/.zshrc || true

# Install Oh My Zsh with retry logic and error handling
RUN curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o install-ohmyzsh.sh \
    && chmod +x install-ohmyzsh.sh \
    && bash install-ohmyzsh.sh --unattended \
    && rm -f install-ohmyzsh.sh \
    || echo "Oh My Zsh installation failed, continuing..."

# Set the default shell to zsh (switch back to root temporarily)
USER root
RUN chsh -s /bin/zsh vscode || echo "Failed to change shell, continuing..."

# Switch back to vscode user
USER vscode

# Install dotnet tools with error handling
RUN dotnet tool install --global dotnet-ef || echo "dotnet-ef installation failed" \
    && dotnet tool install --global dotnet-aspnet-codegenerator || echo "dotnet-aspnet-codegenerator installation failed" \
    && dotnet tool install --global dotnet-watch || echo "dotnet-watch installation failed"

# Add dotnet tools to PATH
ENV PATH="${PATH}:/home/vscode/.dotnet/tools"

# Set up git configuration
RUN git config --global --add safe.directory /workspace \
    && git config --global init.defaultBranch main

# Create necessary directories
RUN mkdir -p /home/vscode/.vscode-server/extensions \
    && mkdir -p /home/vscode/.nuget/packages

# Default command
CMD ["sleep", "infinity"]

docker-compose.yml

services:
  aspnet-sample:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "8000:8000"
      - "8001:8001"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=http://+:8000
    networks:
      - aspnet-network
  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mos
      MYSQL_USER: mos
      MYSQL_PASSWORD: mos
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
      - ./database/init:/docker-entrypoint-initdb.d
    networks:
      - aspnet-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10
      interval: 10s

networks:
  aspnet-network:
    driver: bridge

volumes:
    mysql-data:
        driver: local

.devcontainer/devcontainer.json

{
  "name": "ASP.NET Core 9.0 Development Container",
  "dockerFile": "../Dockerfile.dev",
  "context": "..",
  "workspaceFolder": "/workspace",
  "shutdownAction": "stopContainer",
  
  // Mount the workspace folder
  "mounts": [
    "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
  ],
  
  // Use vscode user for development
  "remoteUser": "vscode",
  
  // Configure tool-specific properties.
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-dotnettools.csharp",
        "ms-dotnettools.csdevkit",
        "ms-vscode.vscode-json",
        "ms-azuretools.vscode-docker"
      ],
      "settings": {
        "dotnet.defaultSolution": "aspnet-minimal-sample.sln",
        "files.exclude": {
          "**/bin": true,
          "**/obj": true
        },
        "terminal.integrated.defaultProfile.linux": "bash"
      }
    }
  },

  // Use 'forwardPorts' to make a list of ports inside the container available locally.
  "forwardPorts": [
    8000,
    8081
  ],

  // Use 'postCreateCommand' to run commands after the container is created.
  "postCreateCommand": "chmod +x .devcontainer/init.sh && .devcontainer/init.sh",

  // Configure container features (simplified)
  "features": {
    "ghcr.io/devcontainers/features/common-utils:2": {
      "installZsh": false,
      "username": "vscode",
      "userUid": "1000",
      "userGid": "1000"
    }
  }
}

init.sh

#!/bin/bash

# Fix permissions for .NET build artifacts
echo "🔧 Fixing permissions for .NET development..."

# Remove existing build artifacts that might have incorrect permissions
echo "🧹 Cleaning build artifacts..."
rm -rf obj bin || true

# Ensure vscode user owns the workspace
echo "👤 Setting ownership..."
sudo chown -R vscode:vscode /workspace

# Set appropriate permissions
echo "🔐 Setting permissions..."
sudo chmod -R 755 /workspace

# Create directories with correct permissions
echo "📁 Creating build directories..."
mkdir -p obj bin

# Clean and restore
echo "🏗️  Cleaning and restoring project..."
dotnet clean
dotnet restore aspnet-minimal-sample.csproj

# Test build
echo "🧪 Testing build..."
dotnet build aspnet-minimal-sample.csproj

echo "✅ Setup complete! You can now run 'dotnet run'"

setup.sh

#!/bin/bash

# DevContainer initialization script
echo "🚀 Starting DevContainer setup..."

# Check if we're in the correct directory
echo "📁 Current directory: $(pwd)"
echo "📄 Files in current directory:"
ls -la

# Check if project file exists
if [ -f "aspnet-minimal-sample.csproj" ]; then
    echo "✅ Found aspnet-minimal-sample.csproj"
    echo "📦 Restoring NuGet packages..."
    dotnet restore aspnet-minimal-sample.csproj
    if [ $? -eq 0 ]; then
        echo "✅ NuGet packages restored successfully"
    else
        echo "❌ Failed to restore NuGet packages"
        exit 1
    fi
else
    echo "❌ aspnet-minimal-sample.csproj not found in current directory"
    exit 1
fi

# Check if solution file exists
if [ -f "aspnet-minimal-sample.sln" ]; then
    echo "✅ Found aspnet-minimal-sample.sln"
else
    echo "⚠️  aspnet-minimal-sample.sln not found"
fi

echo "🎉 DevContainer setup completed successfully!"

Dockerfile と docker-compose.yml なんて生成AIのモデルの中では十分に知見がありそうなものですが、何度やっても失敗します。仕方がないので手作業で修正をした後に Claude Sonnet にレビューして貰います。

おそらく

  • ホストのポート番号とダブることを想定していない。
  • dotnet build で書き込みされるフォルダーのパーミッション変更が必須
  • ホストが windows まわりなのでが問題か?

なところで引っ掛かります。laravel や next.js のほうはビルドする必要がないのでコード変更をした後にブラウザ等でリロードすればいいのですが、dotnet の場合はいちいちビルドしないといけないのが面倒なところです。hot reload もできたような気がするのですが、これは後で調べるとして。

そんな訳で、無事「開発コンテナ」の中での実行まで完了

私の場合、xamp での mysql も動いているので mysql-1 の 3306:3306 も変えないと駄目なんですが、ひとまず、これは終了。あとで、azure にデプロイを試していきます。

参照

webapi/aspnet-minimal-sample : ASP.NET Core Minimal による web api サンプル & Docker 動作

https://github.com/moonmile/mos-ai-sample/tree/master/src/webapi/aspnet-minimal-sample

カテゴリー: 開発 | asp.net minimal を Docker 対応して、azure に up するまで…いかず はコメントを受け付けていません

GitHub Copilot + GPT-4o/Claude Sonnet で ASP.NET Core minimal な web api を作成する

Copilot の Agent を “GPT-4o” に変えて、minmal web api を作成していきます。vscode 上で使っている間はどちらもで変わらないし、いま使っている限りではどちらが上という感じはしません。課金的に安いほうを選べばいいんじゃないでしょうか?

- データベース定義 : `ddl.md`
- API 定義 : `mos-api.yaml`
- 開発環境 : `asp.net minimal`
- データベース : `MySQL`

だけ決めておいて、プロンプトをスタートさせます。

dotnet new webapi 

でプロジェクトを作成した後に、次のプロンプトでスタートです。

ddl.md と mos-api.yaml を参照して asp.net minimal で web api を作成して。
データベースは MySQL を使用して。

mos-api.yaml には products や orders の CRUD が含まれているのですが、それは追えていないようです。これが gpt-4o だからなのかは不明ですが、ビルドが終わったら少しずつ追加していきます。

最初の修正

DefaultConnection を追加して。
ポート番号を 8000 にして。
エンドポイントを localhost:8000/api/categories でアクセスできるようにして。
Models のクラスを ddl.md のカラム名にあわせて。

なんと!Models/Category.cs のプロパティ名に合わせてデータベースのほうを修正しようとします。しかも MySQL なのに pgsql を使おうとするので、ここでストップ。

どうやら、GPT-4o では無理そうなので、Claude Sonnet 4 に切り替えます。

まあ、普通はそうですね。C# の場合は Column 属性で名前を合わせます。データベースが MySQL の場合は大文字小文字が区別されるので、テーブルを参照するときのカラム名はすべて設定しておいたほうが無難です。

using System;
using System.ComponentModel.DataAnnotations.Schema;

namespace aspnet_minimal_sample.Models
{
    public class Category
    {
        public int Id { get; set; }
        public string Slug { get; set; } = string.Empty;
        public string Title { get; set; } = string.Empty;
        public string? Description { get; set; }
        public string? Image { get; set; }
        
        [Column("sortid")]
        public int SortId { get; set; } = 0;
        
        public int Display { get; set; } = 1;
        
        [Column("created_at")]
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
        
        [Column("updated_at")]
        public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
        
        [Column("deleted_at")]
        public DateTime? DeletedAt { get; set; }
    }
}

GPT-4o の尻ぬぐいは Claude Sonnet にやってもらいましょう。

mos-api.yaml に従って、products や orders のエンドポイントも作って。
categories も mos-api.yaml のように修正して。
レスポンスの JSON を categoryId ではなく category_id のように mos-api.yaml に揃えて。
localhost:8000/mos/api/products/1 のレスポンスを mos-api.yaml にあわせて

あちこち抜けているので、mos-api.yaml の仕様にあっているかチェックしてもらいます。

JSON のレスポンスが mos-api.yaml の仕様にあっているか全体をチェックして。

指示した視点でコードのレビューができるので、git へのプルリクエストのコードのチェックとかできるもしれません。この場合 PR のマスター役がやるというよりも、PR を出す人があらかじめ Claude Code などでコードレビューをしておいてね、という感じでしょうか。

nuxt-sample と接続してみる

さて asp.net minimal がほどよく作成できたので、実際にクライアントと接続して試してみましょう。既にできている(筈)の nuxt-sample を実行します。

npm run dev

カテゴリ一覧からカテゴリを選択すると「読み込み中…」で止まってしまいます。

商品一覧で画像が表示されません。「品切れ」はよくわからない。こんな機能をいれたっけ?

商品をクリックすると「読み込み中…」になってしまいます。

このあたり、nuxt-sample のバグなのか asp.net minimal のバグかわからないので、vscode でひとつのワークスペースで扱って調査していきます。

バグ取り開始

next-sample でカテゴリを選択したとき「読み込み中...」のまま止まります。

vscode で aspnet-minimal-sample と next-sample をひとつのワークスペースにいれておきます。これで、両方のプロジェクトを Claude Sonnet が参照できるようになります。

ひとまず aspnet 側の CORS 問題だったらしいので、カテゴリ内の商品表示ができるようになりました。

商品詳細のページも表示できるようになっています。

画像に関しては products.image カラムのデータに拡張子を含むようにしたので、next-sample のページで “.jpeg” しているところを外します。

画像ファイルは next-sample のほうではなく、aspnet-minimal-sample のサーバーのほうに置きたいので尋ねてみます。

aspnet-minimal-sample のほうに public を置けますか?

next-sample のほうの img タグのほうも書き変えて、画像が表示されるようになります。

レイアウトにつかうときの画像ファイルは next-sample のほうの public に置けばよいのですが、商品データの画像はデータベースや public の適当なところに置くことになるので aspnet-minimal-sample のほうに置きます。

あとは、カートに入れて注文番号が表示されれば ok です。

ひとまず、これで web api のほうも含めて作成完了です。

ASP.NET Core minimal なのでワンコードで確認ができる。

Laravel の場合と比較すると、aspnet minimal の場合は Program.cs に全て詰め込まれています。

// カテゴリ API エンドポイント
app.MapGet("/mos/api/categories", async (AppDbContext db, int? display) =>
{
    var query = db.Categories.AsQueryable();
    
    if (display.HasValue)
        query = query.Where(c => c.Display == display.Value);
    
    var categories = await query.ToListAsync();
    return Results.Ok(new { items = categories, total = categories.Count });
});

app.MapPost("/mos/api/categories", async (AppDbContext db, Category category) =>
{
    db.Categories.Add(category);
    await db.SaveChangesAsync();
    return Results.Created($"/mos/api/categories/{category.Id}", category);
});

app.MapGet("/mos/api/categories/{id}", async (AppDbContext db, int id) =>
{
    var category = await db.Categories.FindAsync(id);
    return category is not null ? Results.Ok(category) : Results.NotFound();
});

app.MapPut("/mos/api/categories/{id}", async (AppDbContext db, int id, Category category) =>
{
    var existingCategory = await db.Categories.FindAsync(id);
    if (existingCategory is null) return Results.NotFound();
    
    existingCategory.Slug = category.Slug;
    existingCategory.Title = category.Title;
    existingCategory.Description = category.Description;
    existingCategory.Image = category.Image;
    existingCategory.SortId = category.SortId;
    existingCategory.Display = category.Display;
    existingCategory.UpdatedAt = DateTime.UtcNow;
    
    await db.SaveChangesAsync();
    return Results.Ok(existingCategory);
});
...

本格的な仕事コードだとエラー処理とかの不安があるので、MVC パターンの Controller ベースにしたいところですが、このぐらいの実験コードとか管理モードで社内ツールとして使う場合にはルーティングやデータベースアクセス(特にCRUDのみ)はひとつにまとまっておいたほうが修正がしやすいです。Claude Code のような AI を使う場合にはファイルを横断してくれますが、人が手動で修正する場合は「置換」を多用するのでひとつのファイルにまとまっていたほうが便利です。

実際に、web api の機能としては Program.cs のコード量は 300行弱でしかないので、あれこれファイルを分割するよりもひとつのファイルにまとまった方が編集もしやすいでしょう。

参照先

mos-ai-sample/src/webapi/aspnet-minimal-sample at master · moonmile/mos-ai-sample https://github.com/moonmile/mos-ai-sample/tree/master/src/webapi/aspnet-minimal-sample

カテゴリー: 開発 | GitHub Copilot + GPT-4o/Claude Sonnet で ASP.NET Core minimal な web api を作成する はコメントを受け付けていません

Copilot + Claude で Laravel の Web API を作る

一番手慣れた手段として、Laravel で作ってみます。

OpenAPI 仕様 mos-api.yaml に従って Controller を作って。

これまで OpenAPI の yaml/json から各プログラム言語のコードを作るときはジェネレーターを動かす必要があったのですが、Claude Sonnet を使うと一切必要ありません。

  • routes/api.php にルーティングの追加
  • app/Models/* にモデルクラスの追加
  • app/Http/Controllers/* にコントローラークラスの追加
  • database/migrations/* にマイグレーションコードの追加

この手のコンバートツールは個人で作りがちだし、開発プロジェクト内でも標準ツールにしがちなのですが、フレームワークのバージョンが上がったりすると乖離してしまうので自作はお薦めしません。できることならば OSS にある標準のものを使うか、以後は Claude Sonnet のような生成AIものにしておくか、というところです。

あと、windows 上の mysql を動かすと毎回出てくるエラーにも対処しておきます。

SQLSTATE[HY000]: General error: 1709 Index column size too large. The maximum column size is 767 bytes. (Connection: mysql, SQL: alter table `users` add unique `users_email_unique`(`email`)) 

の対処は?
class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        // Set default string length to 191 for MySQL compatibility
        Schema::defaultStringLength(191);
    }
}

これで php artisan migrate:fresh が正常に終了します。

標準のままだと、/api のプレフィックスが付くので、これを修正します。

api/mos/api/categories



mos/api/categories

にするには?

以下のコマンドを動かせばよいそうです。

php artisan route:list --path=mos

postman で動作確認

postman を使って web api にアクセスしてみます。これは成功

localhost:8000/mos/api/categories

OpenAPI SwaggerUI でアクセス

vscode の swaggerUI でアクセスしてみます。

どうやら CORS でエラーになっているっぽいです。

swaggerUI でアクセスをすると CORS のエラーがでます。

実は、この対処だけではエラーになるので、Claude にログも確認してもらいます。

どうやら、Laravel に最初に入っている CORS の機能と、Sanctumミドルウェア、CORS ミドルウェアの兼ね合いが良くなかったみたいですね。

このあたりの laravel の middleware の設定が良く分かっていないので、なんとも言えないのですが、ひとまずエラーが無くなります。

app.php

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        apiPrefix: '',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        // Add CORS middleware alias
        $middleware->alias([
            'cors' => \Illuminate\Http\Middleware\HandleCors::class,
        ]);

        // Apply CORS middleware globally to API routes
        $middleware->api(append: [
            \Illuminate\Http\Middleware\HandleCors::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();

cors.php

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Cross-Origin Resource Sharing (CORS) Configuration
    |--------------------------------------------------------------------------
    |
    | Here you may configure your settings for cross-origin resource sharing
    | or "CORS". This determines what cross-origin operations may execute
    | in web browsers. You are free to adjust these settings as needed.
    |
    | To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    |
    */

    'paths' => ['mos/api/*', 'api/*'],

    'allowed_methods' => ['*'],

    'allowed_origins' => ['*'],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 0,

    'supports_credentials' => false,

];

Seeder を入れる

カテゴリ(categories)と商品(products)に初期値を入れたいので、これを Claude に作って貰います。

categories と puroducts の seed のサンプルを作成して。

ひな形ができたので、これをハンバーガー注文サイトのカテゴリと商品名に手作業で修正。

CategorySeeder.php

class CategorySeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $categories = [
            [
                'id' => 1,
                'slug' => 'special1',
                'title' => '今月のお薦め',
                'description' => '今月のお薦め商品を紹介します。',
                'image' => 'special1.jpg',
                'sortid' => 1,
                'display' => 1,
            ],
            [
                'id' => 2,
                'slug' => 'special2',
                'title' => 'ネット注文特別価格メニュー',
                'description' => '',
                'image' => 'special2.jpg',
                'sortid' => 2,
                'display' => true,
            ],

ProductSeeder.php

class ProductSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */


    public function run(): void
    {
        // カテゴリの最大値からランダムidを取得
        function fake_category_id() {
            $maxId = Category::max('id');
            return rand(1, $maxId);
        }

        $products = [
            [
                'id' => 1,
                'slug' => 'burger1',
                'name' => 'モスバーガー',
                'description' => '',
                'image' => 'burger1.jpg',
                'price' => 440.00,
                'sortid' => 1,
                'display' => true,
                'category_id' => fake_category_id(),
            ],
            [
                'id' => 2,
                'slug' => 'burger2',
                'name' => 'モスチーズバーガー',
                'description' => '',
                'image' => 'burger2.jpg',
                'price' => 480.00,
                'sortid' => 2,
                'display' => true,
                'category_id' => fake_category_id(),
            ],

たぶん、テスト用に大量に作成する手段はあるのですが、ひとまず新人研修で作成したものをコピーします。このあたり、Copilot にテストデータを作って貰ってもいいと思います。

OpenAPI の戻り値を修正する

web api で  GET /products/{id} を呼び出したときに、category_id がそのまま返ってきているので、これを categories を検索するよう変更します。

mos-api.yaml の GET /products/{id} を呼び出した時に、
category_id と一緒に categories の内容も戻して。

ProductController.php と mos-api.yaml の両方を修正してくれます。

GET /categories の戻りが配列だけになっているので、以前のクライアントで受けられるように items の中に配列を作ります。正確には mos-api.yaml を直すのですが、直し方がわからないのでレスポンスの JSON 例を示して Copilot に修正してもらいましょう。

web api で GET /categories のレスポンスを次のように変更して。

JSON 形式の例

{
"items": [
{
"id": 1,
"slug": "special1",
"title": "今月のお薦め",
"description": "今月のお薦め商品を紹介します。",
"image": "",
"sortid": 1,
"display": true,
"created_at": "2025-06-05T11:57:01+09:00",
"updated_at": "2025-06-05T11:57:01+09:00",
"deleted_at": null
},
...
{
"id": 10,
"slug": "sidemenu3",
"title": "デザート",
"description": "",
"image": "",
"sortid": 22,
"display": true,
"created_at": "2024-06-19T02:49:19+09:00",
"updated_at": "2024-06-19T02:49:19+09:00",
"deleted_at": null
}
],
"total": 10
}

この部分はプロトタイプや社内ツールならば Claude の提案したテンプレートでもよいのですが、Web API の場合は既存の形式に合わせることも多いので妥協せずに少し突っ込みます。

指定したカテゴリ内の商品を取得する場合、/products?category_id=1 で実装済みではあるのですが、これも /products/slug/{category_slug} 形式に修正します。

カテゴリ内の商品一覧を取得する web api を
/products/slug/{category_slug}
のようにカテゴリの slug を使って。
POST /orders では、
次の JSON 形式の例に変更して。

{
total_price: 0,
total_quantity: 0,
items: [
{
id: 1,
price: 1000,
quantity: 2,
},
{
id: 2,
price: 2000,
quantity: 3,
},
],
}

これでほぼ完成です。後は

  • ログイン機能の追加
  • 管理画面で使うためのルーティングをログイン状態へ移動
  • CRFS, CROS 機能の復活

あたりでしょうか。

ファイル数が多くなると、指示によってあちこちのファイルの手を入れることになるので時間が掛かります。この現象自体はAIであっても人間であっても同じなのですが、MVC パターンの Web API の場合は、Model/Controller そしてルーティングの3か所に手をいれないといけないのが難点です。この現象はもともとの MVC パターンのアプリケーションからあるもので今に限ったものではないのですが、人が修正するときは3か所同時に手をいれないといけないので大変でした。

そういう意味では、Claude Sonnet の場合は多少時間がかかるものの複数のファイルに手を入れることに躊躇はしません。たまにコンパイルが出来ないコードを吐き出しますが、Claude 自らコンパイルエラーを読み解いたり、実行時のエラーを読み解くことでコードを修正していきます。その部分では開発者はナビゲーターとしての役割やレビュアやテスターの視点を持てるのが便利なところです。自分がやると「面倒くさい」が先に立ってしまって、オブジェクト指向的にもう少し依存関係が少なくならないか?と考えてしまいますからね。

参照

mos-ai-sample/src/webapi/laravel-webapi-sample at master · moonmile/mos-ai-sample https://github.com/moonmile/mos-ai-sample/tree/master/src/webapi/laravel-webapi-sample

カテゴリー: 開発 | Copilot + Claude で Laravel の Web API を作る はコメントを受け付けていません

Copilot + Claude Sonnet を活用して web api を作る下準備

ここまで、3つのクライアント(React, Blazor, Kotlin)を作ってきましたが、実際のプロジェクトでは Web API も同時に作ることが多いです。既存のシステムの場合は、既に web api があるかもしれませんが。

mock のダミー画像データを作る

新人研修ではモスバーガーの画像をそのまま借用してしまっているのですが、版権的に良くないのでダミー画像を作ります。注文サイト&管理サイトの研修方式は数年間からやっていて、このダミー画像が結構面倒なのですが、いまだと OpenAI の DALL-E でできます。

当然のことながら OpenAI の API KEY が必要になりますが、そこは必要経費ということで。

# ハンバーガー注文サイト用の画像ファイルを作成する

## カテゴリ用の画像作成


```
slug title filename
-------- ------------------------------------- --------------------
"special1" "今月のお薦め" "special1.jpg"
"special3" "限定メニュー" "special3.jpg"
"special2" "ネット注文特別価格メニュー" "special2.jpg"
"main1" "メインメニュー" "main1.jpg"
"main2" "ハンバーガー" "main2.jpg"
"main3" "ホットドック" "main3.jpg"
"main4" "ソイパティ" "main4.jpg"
"sidemenu1" "サイドメニュー" "sidemenu1.jpg"
"sidemenu2" "ドリンク・スープ" "sidemenu2.jpg"
"sidemenu3" "デザート" "sidemenu3.jpg"
```

カテゴリの title に適した画像ファイルを 690x560 px で作成してください。

こんな形でドキュメントを書いて、Copilot に渡しますが、あえなく挫折。

Claude Sonnet だと画像ができないそうなので。これを ChatGPT でちまちま作ってもいいのですが、カテゴリも10種類、商品となるハンバーガー等の画像は100個ぐらいが必要になります。

なので、量産するコードをを書いて貰いましょう。

openai を使って、プロンプトを指示して、指定サイズの画像を作るコードを書いて。

ツールで使う言語は何でもいいのですが、C# のコンソールアプリにしています。

using OpenAI;
using OpenAI.Images;
using System.ClientModel;

class Program
{
    // カテゴリ情報の定義
    private static readonly Dictionary<string, string> CategoryInfo = new()
    {
        { "special1", "今月のお薦め" },
        { "special3", "限定メニュー" },
        { "special2", "ネット注文特別価格メニュー" },
        { "main1", "メインメニュー" },
        { "main2", "ハンバーガー" },
        { "main3", "ホットドック" },
        { "main4", "ソイパティ" },
        { "sidemenu1", "サイドメニュー" },
        { "sidemenu2", "ドリンク・スープ" },
        { "sidemenu3", "デザート" }
    };

    // 各カテゴリに対応する英語プロンプト
    private static readonly Dictionary<string, string> CategoryPrompts = new()
    {
        { "special1", "A beautiful and appetizing seasonal hamburger set with fresh ingredients, warm orange and yellow color scheme, professional food photography, featuring 'Monthly Recommendation' text overlay" },
        { "special3", "A premium limited edition hamburger with luxury presentation, elegant black and gold color scheme, high-end food photography, featuring 'Limited Menu' text overlay" },
        { "special2", "A special price hamburger with discount elements, eye-catching red and white color scheme, online ordering theme, featuring 'Special Online Price' text overlay" },
        { "main1", "A variety of delicious hamburgers arranged together, appetizing brown and red color scheme, main menu showcase, featuring 'Main Menu' text overlay" },
        { "main2", "A classic hamburger cross-section showing layers of bun, patty, and fresh vegetables, vibrant green, red, and brown colors, featuring 'Hamburger' text overlay" },
        { "main3", "A delicious hot dog with sausage, bun, mustard and ketchup, warm brown and red color scheme, featuring 'Hot Dog' text overlay" },
        { "main4", "A healthy soy patty burger with fresh vegetables, healthy green and brown color scheme, emphasizing healthiness, featuring 'Soy Patty' text overlay" },
        { "sidemenu1", "A variety of side dishes including french fries and onion rings, golden yellow color scheme, featuring 'Side Menu' text overlay" },
        { "sidemenu2", "Various drinks and soups with glasses, cups, ice, and steam, refreshing blue and clear color scheme, featuring 'Drinks & Soup' text overlay" },
        { "sidemenu3", "Delicious desserts including ice cream and pies, sweet pastel color scheme, happy atmosphere, featuring 'Dessert' text overlay" }
    };

    static async Task Main(string[] args)
    {
        // OpenAI API キーを環境変数から取得
        var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");
        if (string.IsNullOrEmpty(apiKey))
        {
            Console.WriteLine("エラー: OPENAI_API_KEY 環境変数が設定されていません。");
            Console.WriteLine("使用方法: set OPENAI_API_KEY=your_api_key_here");
            return;
        }

        var client = new OpenAIClient(apiKey);

        // 出力ディレクトリを作成
        var outputDir = Path.Combine(Directory.GetCurrentDirectory(), "generated_images");
        Directory.CreateDirectory(outputDir);

        Console.WriteLine("ハンバーガー注文サイト用画像生成ツール");
        Console.WriteLine("========================================");

        if (args.Length == 0)
        {
            Console.WriteLine("使用方法:");
            Console.WriteLine("  すべてのカテゴリ画像を生成: dotnet run all");
            Console.WriteLine("  特定のカテゴリ画像を生成: dotnet run <slug>");
            Console.WriteLine("  カスタムプロンプトで生成: dotnet run custom \"<prompt>\" <filename>");
            Console.WriteLine();
            Console.WriteLine("利用可能なカテゴリ:");
            foreach (var category in CategoryInfo)
            {
                Console.WriteLine($"  {category.Key} - {category.Value}");
            }
            return;
        }

この手のやつは、ちまちま本に書いた覚えがあるのですが、AI に書いて貰うほうが断然楽です。どうせ使い捨てツールなので、中身はどうということはありません。

実行すると、こんな感じで画像を作ってくれます。

商品画像のほうは量が多いのでデータベースから読み込むようにしてもいいのですが、これも使い捨てツールなので商品画像の作成機能を追加してしまいます。

同じパターンで、商品画像の作成も追加して。

互換性もなんのその、豪快にメニューを書き変えて「カテゴリ画像」と「商品画像」の両方が生成できるようにしてくれます。

        Console.WriteLine("ハンバーガー注文サイト用画像生成ツール");
        Console.WriteLine("========================================");

        if (args.Length == 0)
        {
            Console.WriteLine("使用方法:");
            Console.WriteLine("  すべてのカテゴリ画像を生成: dotnet run category-all");
            Console.WriteLine("  特定のカテゴリ画像を生成: dotnet run category <slug>");
            Console.WriteLine("  すべての商品画像を生成: dotnet run product-all");
            Console.WriteLine("  特定の商品画像を生成: dotnet run product <slug>");
            Console.WriteLine("  カスタムプロンプトで生成: dotnet run custom \"<prompt>\" <filename>");
            Console.WriteLine();
            Console.WriteLine("利用可能なカテゴリ:");
            foreach (var category in CategoryInfo)
            {
                Console.WriteLine($"  {category.Key} - {category.Value}");
            }
            Console.WriteLine();
            Console.WriteLine("利用可能な商品:");
            foreach (var product in ProductInfo)
            {
                Console.WriteLine($"  {product.Key} - {product.Value}");
            }
            return;
        }

生成中です。

ひとまず、これで版権をクリアした画像の作成が完了。

テーブル定義

DDL定義(テーブル定義)を作成しておきます。

会社でデータベース設計書などを作るときは Excel を使うことが多いと思うのですが、おそらく ddl.md というドキュメントファイルを作って、create table の定義を並べておいたほうが AI に理解しやすいです。いったん、MySQL Workbeanch とか、SQL Server Management Studio などを使ってテーブル作成した後にスクリプトに落とし込んでもいいでしょう。

# DDL 定義

## categories

カテゴリのテーブル定義です。

```sql
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug varchar(255) NOT NULL UNIQUE,
title varchar(255) NOT NULL,
description TEXT,
image varchar(255),
sortid INTEGER NOT NULL DEFAULT 0,
display INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
);
```
## products

商品のテーブル定義です。

```sql
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER,
slug varchar(255) NOT NULL UNIQUE,
name varchar(255) NOT NULL,
description TEXT,
image varchar(255),
price REAL NOT NULL,
sortid INTEGER NOT NULL DEFAULT 0,
display INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (category_id) REFERENCES categories(id)
);
```

## orders

注文のテーブル定義です。

```sql
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number varchar(10) NOT NULL,
total_price REAL NOT NULL,
total_quantity INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
);
```

## order_products

注文と商品を関連付ける中間テーブルの定義です。

```sql
CREATE TABLE order_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
price REAL NOT NULL,
quantity INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (product_id) REFERENCES products(id)
);
```

コードファースト形式の場合、マイグレーションのコードをプログラムに書くことになるのですが、これは言語やフレームワークに依存してしまいます。Ruby, Laravel, C# などそれぞれの言語でしか使えない形式になってしまうので、CREATE TABLE のように SQL のままのほうがよいと思います。たぶん、各言語で書いたとしても別の言語に AI がコンバートしてくれるだろうから、大丈夫だとは思うのですが。

OpenAPI定義を書く

web api の定義として、RESTful で書いていくか、OpenAPI で書いていくか、ルーティングをどう記述していくかが問題になると思いますが、ここでは OpenAPI定義を直接書いていきます。

この部分も諸々仕様書に書き起こすこともできるのですが、現状だと

  • テーブル定義から OpenAPI 定義を書き出す
  • OpenAPI 定義から設計書等へ書き出す

のように、リバース型式でドキュメントを書いたほうが楽です。

db/ddl.md のテーブル定義を参考にして、
mos-api.yaml に CURD を呼び出せる openapi を書き出して。

このように DDL の定義から CRUD 形式を一発で作成してくれます。

openapi: 3.1.1
info:
title: Mos API
description: Mos API
version: 1.0.0
contact:
name: moonmile solutions
servers:
- url: "http://localhost:8000/mos/api"
paths:
# Categories CRUD
/categories:
get:
summary: Get all categories
operationId: getCategories
parameters:
- name: display
in: query
description: Filter by display status
required: false
schema:
type: integer
enum: [0, 1]
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
post:
summary: Create a new category
operationId: createCategory
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryCreate'
responses:
'201':
description: Category created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Category'
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
...

ハンバーガー注文サイトの場合は、注文 /orders など、いくつかの web api を用意することがあるのですが、ひとまず Copilot で mos-api.yaml を書いて貰います。

vscode でプレビューもできるし、実際にここから web api を呼び出すこともできます。

参照

moonmile/mos-ai-sample: opilot + Claude Sonnet 4 でハンバーガー注文サイト作成のサンプル https://github.com/moonmile/mos-ai-sample

  • src/tools/make-image ダミー画像の作成ツール
  • src/db/ddl.md テーブル定義
  • src/webapi/mos-api.yaml OpenAPI 定義

カテゴリー: 開発 | Copilot + Claude Sonnet を活用して web api を作る下準備 はコメントを受け付けていません

Github Copilot + Claude Sonnet で Kotlin の Android アプリを作ってみる

同じパターンで Android もいけるだろう、ということで Android Studio + Kotlin の組み合わせで作ってみます。React Native とか Flutter ならいけそうなので、ここはあえて Kotlin で。

カテゴリ一覧を表示するページを追加して。

豪快に MainActivity を書き変えてくれますが、まあ、初手はそれでもいいです。

カテゴリ一覧の取得には web api で 10.0.2.2:8000/mos/api/categories を使って。

戻り値 JSON の例

{
"items": [
{
"id": 1,
"slug": "special1",
"title": "今月のお薦め",
"description": "今月のお薦め商品を紹介します。",
"image": "",
"sortid": 1,
"display": true,
"created_at": "2025-06-05T11:57:01+09:00",
"updated_at": "2025-06-05T11:57:01+09:00",
"deleted_at": null
},
...

React や Blazor のときと同じようにカテゴリ一覧を web api 呼び出しに変更します。

Android の場合はインターネット接続のためのパーミッションを入れる必要があり、きちんと AndroidManifest.xml に追加してくれます。あと、build.gradle にも必要なライブラリを追加してくれます。

ひとまずカテゴリ一覧の作成が完了

コードは MainActivity から CategoryListScreen を呼び出して、データは CategoryViewModel から拾うようになっていますね。ベタで書くよりは ViewModel を使ったほうが良さそうです。

次はカテゴリ内の商品を表示させます。

カテゴリ一覧のカテゴリをクリックしたときに、カテゴリ内の商品の一覧を表示して。
web api は /products/slug/{category_slug} を使って。

レスポンスの例

{
"items": [
{
"id": 13,
"category_id": 1,
"slug": "hotdog3",
"name": "スパイシーチリドッグ",
"description": "",
"image": "m011",
"price": 470,
"sortid": 13,
"display": true,
"created_at": "2024-06-19T02:49:19+09:00",
"updated_at": "2024-06-19T02:49:19+09:00",
"deleted_at": null
},
...

既に web api ができているので、このサンプルを貼り付ければ ok 。ちなみにエミュレータからはホストのPCに対して 10.0.2.2 で接続が可能です。

MainActivity を書き変えて MosNavigation を呼び出すようにしています。この手のナビゲーション機能は、設計時に使うか使わないかの判断をするのですが(そうしないとコーディングの変更が多くて大変)、Claude Sonnet を使うと後からの大幅な変更もやってくれます。

このあたり、アジャイル的にUIを変更するにしても、React や Flutter であったとしても UI 構造の大幅な変更は避けたいものです。なので、共通レイアウトを作ってみたり、共通のベースクラス(BaseActivityを継承するとか)をやりがちになるのですが、生成AIを使ったときにはそのあたりの手間は惜しみません。オブジェクト指向設計の「共通化」の部分がごっそり抜けてあっても大丈夫です。むしろ継承はしなくていい感じですね。このあたりは、将来的なコード技術の進化もあるでしょうが、あれこれと継承しなくてよいのは人間にも楽です。

商品一覧で、商品を選択したときに商品詳細を表示して。

web api は /products/{id}

戻り値の例

{
"id": 1,
"category_id": 6,
"slug": "burger1",
"name": "モスバーガー",
"description": "",
"image": "m001",
"price": 440,
"sortid": 1,
"display": true,
"created_at": "2024-06-19T02:49:19+09:00",
"updated_at": "2024-06-19T02:49:19+09:00",
"deleted_at": null
}

プロンプトはだんだん手慣れてきて、こんな感じに書くようになります。特にプロンプト技術っぽいのもは入っていません。あまり複雑なこともしないし、step by step で進んでいくので、AI 側にも誤解が少ないのでしょう。というか、曖昧さを排除していけば ok です。

そういう意味では、ペアプロしていてドライバー役で後ろから指示を出している感じですよね。キーボード担当の Claude Sonnet が一生懸命コーディングしている感じ。

商品詳細に表示される画像を切り替えます。

商品詳細の画像データは、

10.0.2.2:8000/images/{画像コード}.jpeg

で取得ができるので、これを切り替えて。

画像が表示されるようになりました、が、間違った画像が表示されています。

というか、絶妙にサンプルデータを突っ込んでいますw

これを修正させます。

カテゴリで「今月のお薦め」を選択したときの商品の数が多すぎます。

実際は

localhost:8000/mos/api/products/slug/special1

{
"items": [
{
"id": 13,
"category_id": 1,
"slug": "hotdog3",
"name": "スパイシーチリドッグ",
"description": "",
"image": "m011",
"price": 470,
"sortid": 13,
"display": true,
"created_at": "2024-06-19T02:49:19+09:00",
"updated_at": "2024-06-19T02:49:19+09:00",
"deleted_at": null
},
{
"id": 15,
"category_id": 1,
"slug": "soiburger2",
"name": "ソイバーガー",
"description": "",
"image": "m013",
"price": 260,
"sortid": 15,
"display": true,
"created_at": "2024-06-19T02:49:19+09:00",
"updated_at": "2024-06-19T02:49:19+09:00",
"deleted_at": null
}
],
"total": 2
}

の2つの商品だけです。

サンプルデータを表示していませんか?
カテゴリスラッグ別の商品データは

/mos/api/products/slug/{slug}

を使って。

endpoint のアドレスが間違っていたので、サンプルデータが表示されてしまったようですね。サーバーに接続できない場合にはエラーが出るのが普通だと思うのですが、ここの Claude Sonnet ではサンプルデータを表示するようになっています。後で、これは修正しましょう。

次はカートの機能を実装してもらいましょう。

続きは昼の後で…

商品詳細で「カートに追加」で、カートに追加する機能を実装して。

「カートに追加」ボタンの機能はあるらしいのですが、カートの中身が見れませんw。仕方がないので、カートの中身を見るページを作って貰いましょう。

チキンナゲットの画像があるはずなのですが、絵文字になっているのでうまく拾えていないようです。これは後でチェック。

右上に「カート」のメニューを作って。

CategoryListScreen.kt や ProductDetailScreen.kt のページにがんがんとトップメニューを追加していきます。最初からいれておけよとか、共通化したらどうか?というのは無しです。これは React で作った時も同じですが、人の場合は共通化しないと手間が掛かったり間違ったりするのですが、AIの場合は間違わないのでがんがん手をいれいきます。いや、実際は間違えるのですが、間違えない振りをして多くのファイルに手を入れていく方法に躊躇がありません。

修正方法を間違えるというのは、たまにビルドが通らなかったり文法エラーを起こしているためです。文法エラーになる頻度はそう多くはないのですが、それなりにあります。そのたびに、ビルドができなかった原因を Claude Sonnet が自らチェックをして直していきます。そのあたり、一発で正しいコードが出て来ないので反復という点でうまくいっているような気がします。そのあたり、反復して少しずつ修正していくという手法が実に人間っぽいです。AIですが。

右上にカートのアイコン、そしてカートのページが実装されました。

「注文する」ボタンを実装して。

web api は POST /mos/api/orders

送信する JSON 形式の例

{
total_price: 0,
total_quantity: 0,
items: [
{
id: 1,
price: 1000,
quantity: 2,
},
{
id: 2,
price: 2000,
quantity: 3,
},
],
}
注文した結果が

{ "order_number": "00000000" }

となるので、この注文番号を表示して。

というわけで、やっとこさ注文画面の作成が完了です。これらを一発で書けるようなプロンプトを作ってもよいのですが、ソフトウェアは同じものを作ることはまずありえないので、ペアプロっぽく少しずつ組み立てるのがコツです。

Next.js や Nuxt.js とは違い、ビルドをして Android エミュレータに転送しないと動作確認ができない分だけ、ちょっと時間がかかります。それでも午前中に始めて、多少間があいて現時点(22時頃)には終わります。間に他の仕事をしていたので、おそらく半日でここまでいけます。

おそらく、手慣れた Android アプリの開発者であれば、似た感じのものを半日で作れると思います。ですが、Copilot + Claude Sonnet の組み合わせだと、まあそれなりのものがこのペースで作れるわけです。果たして新人が作れるかどうかは分かりませんが(おそらく新人だとコードの内容が理解できないので、ハマると大変そうです)、何回か Android アプリを作っている人であれば、同じ形でできるでしょう。コードやファイル構造を眺めると、こんな感じです。

  • UI に Jetpack Compose を使う
  • ViewModel を使う
  • Retrofit を使う
  • NavHostController を使う
  • データクラスをちまちま作る

という構造になっています。これがベストな方法かよくわからないのですが、現状の Claude Sonnet のベストプラクティスなのだと思います。

サンプルコード

https://github.com/moonmile/mos-ai-sample/tree/master/src/client/mos-kotlin

カテゴリー: 開発 | Github Copilot + Claude Sonnet で Kotlin の Android アプリを作ってみる はコメントを受け付けていません