ASP.NET Core APIでリストを一括更新したいときはAutoMapperが便利だった

APIで扱うデータの中にリストが含まれる場合に更新(PUT)でリストを一括で対応してほしいとき、EntityFrameworkの追跡機能とAutoMapperを使えば楽に実装できた、という話です。

例えば GET した時が以下のようなデータとします。

GET /api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        },
    ]
}

このような場合PUT の時は TodoSubItems に追加、変更、削除があってもそのまま上書きしてもらいたいということです。

PUT /api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)"
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 0,
            "name": "New task"
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}

この例では TodoSubItem の1を削除、2を編集、一つを新たに追加しています。

補足: サブタスクでAPIのエンドポイント作ればいいんじゃない?

確かに TodoItems と TodoSubItems でエンドポイントを分ける設計もあります。ですが今回サブタスクはメインタスクに従属するもので、サブタスクの変更もメインタスクの更新時に行いたい、というユースケースを想定しています。例えば、伝票と伝票明細の関係、記事と記事タグなど、1対多の関係で1の属性の一つとして多(リスト)を持っている場合には実用的なパターンかと思っています。

準備

今回はデータベースにSQL Serverを利用します。今はDockerで簡単にSQL Serverを起動できます。

docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=<your_strong_password>" -p 1433:1433 --name todosubapi -d mcr.microsoft.com/mssql/server:2019-latest

たったこれだけ。いい時代になりました。

実装

ここから実装していきます。サンプルとして公式サイトのチュートリアルにあるToDoアプリを使います。このアプリにToDoタスクが複数のサブタスクを持てる機能を追加します。基本的にチュートリアル通りに進めて、サブタスク機能を実現するのに必要な最低限の実装を付け足す方針です。

Webプロジェクトの作成

公式チュートリアル通りです。詳細な作成方法は割愛します。プロジェクト名は「 TodoSubApi 」としました。

テスト

初期状態で正常に起動するか確認します。

  • Ctrl + F5 を押して実行する
  • Webブラウザが起動しSwaggerの画面が表示されることを確認する

モデルクラスの追加

基本公式チュートリアル通りですが、サブタスクを表す TodoSubItem を追加します。

  • ソリューションエクスプローラーを右クリックして [追加] -> [新しいフォルダー] を選択する
  • 「 Models 」という名前をつける
  • Models フォルダーを右クリックして [追加] -> [クラス] を選択する
  • クラスに「 TodoItem 」という名前をつける
  • 同様に Models 配下に「 TodoSubItem 」というクラスをつくる
  • TodoItem.cs と TodoSubItem.cs を以下のように作成する
namespace TodoSubApi.Models
{
    public class TodoItem
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool IsComplete { get; set; }
        public List<TodoSubItem> TodoSubItems { get; set; }
    }
}
namespace TodoSubApi.Models
{
    public class TodoSubItem
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool IsComplete { get; set; }
        public long TodoItemId { get; set; }
    }
}
ちょっと説明

TodoItem クラスの TodoSubItems プロパティはコレクションナビゲーションプロパティと呼ばれるものです。TodoSubItem クラスの TodoItemId は外部キーです。(参照

データベースコンテキストの追加

公式チュートリアルではインメモリデータベースを採用していますが、今回はSQL Serverで試すので、NuGetパッケージ「 Microsoft.EntityFrameworkCore.SqlServer 」を追加します。その後以下のようにデータベースコンテキストを追加します。

  • Models フォルダーを右クリックして [追加] -> [クラス] を選択する
  • クラスに「 TodoContext 」という名前をつける
  • TodoContext.cs を以下のように作成する
using Microsoft.EntityFrameworkCore;

namespace TodoSubApi.Models
{
    public class TodoContext : DbContext
    {
        public TodoContext(DbContextOptions<TodoContext> options)
            :base(options)
        {
        }

        public DbSet<TodoItem> TodoItems { get; set; }
        public DbSet<TodoSubItem> TodoSubItems { get; set; }
    }
}

データベースコンテキストの登録

公式チュートリアルとは異なり、SQL Serverを使う形に Startup.cs を書き換えます。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<TodoContext>(opt =>
        opt.UseSqlServer("Server=tcp:127.0.0.1,1433;Initial Catalog=todoapi;User ID=sa;Password=<your_strong_password>;Persist Security Info=False;"));

    ...
}

マイグレーションとテーブル作成

この作業は公式チュートリアルにはありません。今回はSQL Serverを使うので事前にSQL Serverにテーブルを作成しておきます。

  • Visual Studioの [パッケージマネージャーコンソール] を開く
  • 以下のコマンドを実行し、マイグレーションを作成する
PM> Add-Migration InitialCreate
Build started...
Build succeeded.
To undo this action, use Remove-Migration.
  • プロジェクト配下に Migrations フォルダーがあることを確認する
  • 以下のコマンドを実行し、データベースを更新する
PM> Update-Database
Build started...
Build succeeded.
Done.

コントローラーのスキャフォールディング

公式チュートリアル通りです。詳細な方法は割愛しますがスクリーンショットだけ載せておきます。

PostTodoItem 作成メソッドの更新

公式チュートリアル通りです。

GetTodoItems メソッドとGetTodoItem メソッドの更新

これは公式チュートリアルにはありません。GET してきたときに TodoSubItems も取得できるように処理を追加します。

  • TodoItemsController.cs を以下のように編集する
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
    return await _context.TodoItems.Include(x => x.TodoSubItems).ToListAsync();
}

...
[HttpGet("{id}")]
public async Task<ActionResult<TodoItem>> GetTodoItem(long id)
{
    var todoItem = await _context.TodoItems.Include(x => x.TodoSubItems).FirstOrDefaultAsync(x => x.Id == id);

    if (todoItem == null)
    {
        return NotFound();
    }

    return todoItem;
}
ちょっと説明

データコンテキストからデータを取得する処理に Include メソッドを追加して TodoSubItems を読み込んでいます。

PutTodoItem メソッドの更新

この記事のメインです。データ更新時に TodoSubItems の中身を自由に変えても適切に更新してもらうためAutoMapperを使う方法に変えます。

  • NuGetパッケージ「 AutoMapper 」を追加する
  • まず TodoItemsController.cs のコンストラクタにAutomapperの定義と設定を書く
private readonly IMapper _mapper;

...

public TodoItemsController(TodoContext context)
{
    _context = context;

    var config = new MapperConfiguration(cfg =>
    {
        cfg.CreateMap<TodoItem, TodoItem>();
        cfg.CreateMap<TodoSubItem, TodoSubItem>();
    });
    _mapper = config.CreateMapper();
}
  • PutTodoItem メソッドを書き換える
[HttpPut("{id}")]
public async Task<IActionResult> PutTodoItem(long id, TodoItem todoItem)
{
    if (id != todoItem.Id)
    {
        return BadRequest();
    }

    // コメントアウトする
    //_context.Entry(todoItem).State = EntityState.Modified;

    try
    {
        var found = _context.TodoItems.Include(t => t.TodoSubItems).FirstOrDefault(x => x.Id == id);

        _mapper.Map(todoItem, found);

        _context.TodoItems.Update(found);

        await _context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        if (!TodoItemExists(id))
        {
            return NotFound();
        }
        else
        {
            throw;
        }
    }

    return NoContent();
}
ちょっと説明

UPDATE処理のメインは try 句の中です。一度データベースから該当するIDのデータを持ってきて、更新したいデータをAutoMapperで上書きします。Entity Frameworkの追跡機能が変更箇所を判別してくれるのでいい感じにデータ更新してくれます。正直この辺の仕組みは完全には理解できていないですが、とりあえず想定していた動きをしてくれます。

Postman を使ってテスト

最後にAPIが想定通り動作するか Postmanを使って確かめます。

  • Ctrl + F5 で起動する
  • 作成 (POST)。Postmanで以下のクエリを送信する
    • ※:ポート番号は環境によって異なります。
POST: https://localhost:44366/api/TodoItems
{
    "id": 0,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 0,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 0
        },
        {
            "id": 0,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 0
        }
    ]
}
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 取得 (GET)。Postmanで以下のクエリを送信する
GET: https://localhost:44366/api/TodoItems/1
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 更新 (PUT)。Postmanで以下のクエリを送信する
    • ※:サブタスク1を削除、サブタスク2を変更、新しいサブタスクの一つ追加した、という想定です。
PUT: https://localhost:44366/api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 0,
            "name": "New task",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 再取得 (GET)。Postmanで以下のクエリを送信する
GET: https://localhost:44366/api/TodoItems/1
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 3,
            "name": "New task",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}

サブタスクの削除、変更、追加が全て反映されているのが分かります。

まとめと感想

APIのデータの中にリスト(配列)を設けた場合の実装方法をまとめました。リスト自体をAPIのエンドポイントにして一つずつCRUDさせる設計もありますが、リストが完全に親の要素に従属している場合は今回のような設計のほうが簡単に利用できるので、ここで書いた内容が役立つ場面は意外とあると思います。この記事では書きませんでしたが、実際のAPIのモデルクラスとDBに記録するデータモデルクラスに同一のものを使う必要はありません。今回のサンプルだとサブタスクの外部キー TodoItemId はAPIに含める必要がない項目ですし、サブタスクのIDも本質的には不要です。そのため実際にはモデルクラスとデータモデルクラスを分けて定義します。その場合はクラス間のデータ変換方法が課題になりますが、やはりAutoMapperが便利なのでこの記事の方法と同じようなAutoMapperを使った実装で課題に対応できます。