.NET Core Web APIで配列を一括挿入する最適解|DTOリストとEF Coreの高速バルク登録ガイド

「MVCでは int[] IDS, int[] qty, decimal[] totlvau を渡して複数レコードを登録できたのに、ASP.NET Core Web APIでは1件しか受け取れない」。この悩みはバインディングの前提([FromBody] は1つ)と、データの持ち方が原因です。この記事では、DTOリストをJSONで送る方式(推奨)と、既存jQuery実装を活かす妥協案、EF Coreでの効率的な一括挿入まで、実運用を想定したコードで解説します。

目次

課題の整理

  • MVCでは3配列(商品ID、数量、合計金額)をPOSTしていた。
  • Web APIにも同じ形で渡したいが、現在は単一レコードしか受け取れない。
  • 可能ならフロント(jQuery)の改修を最小にしたい。

ASP.NET Coreのモデルバインディングは、アクション引数に複数の [FromBody] を置けないため、3配列のような「バラバラのスカラー配列」をそのまま受けるのは非現実的です。1つのオブジェクト(DTO)の配列で送るのが最も自然で保守性も高い設計になります。

結論(先に要点)

  • 推奨:「PurchaseDto の配列(List<PurchaseDto>)」をJSONで送信し、APIは [FromBody] で一括受信して AddRange→SaveChanges。
  • フロントは purchaseList.push({ productId, qty, purchaseValue }) を詰めて contentType: 'application/json' で送る。
  • 既存3配列を崩したくない場合は、API入口で一旦3配列→DTOリストに組み替える(妥協案)。
  • 大量データは バッチ分割、AutoDetectChangesの抑制、または SqlBulkCopy/サードパーティのBulk拡張で高速化。

実装:DTOリスト方式(推奨)

DTO(Data Transfer Object)

public class PurchaseDto
{
    public int ProductId { get; set; }       // 例: 商品ID
    public int Qty { get; set; }             // 例: 購入数量
    public decimal PurchaseValue { get; set; } // 例: 合計金額(decimal を推奨)
}

永続化エンティティと DbContext

public class Purchase
{
    public long Id { get; set; }
    public int ProductId { get; set; }
    public int Qty { get; set; }
    public decimal PurchaseValue { get; set; }
    public DateTime PurchasedAt { get; set; } = DateTime.UtcNow;
}

public class AppDbContext : DbContext
{
public DbSet Purchases => Set();


public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Purchase>(e =>
    {
        e.ToTable("tbl_purchase");
        e.HasKey(x => x.Id);
        // 金額は通貨に合わせて精度を指定(SQL Server の例)
        e.Property(x => x.PurchaseValue).HasColumnType("decimal(18,2)");
    });
}


} 

APIコントローラ(バルク受付)

[ApiController]
[Route("api/[controller]")]
public class PurchasesController : ControllerBase
{
    private readonly AppDbContext _db;
    public PurchasesController(AppDbContext db) => _db = db;


// POST /api/purchases/bulk
[HttpPost("bulk")]
public async Task<IActionResult> BulkInsert([FromBody] List<PurchaseDto> items, CancellationToken ct)
{
    if (items is null || items.Count == 0)
        return BadRequest(new { message = "空のリクエストです。" });

    // 入力検証(例:サーバー側で必須・範囲の再確認)
    var errors = new List<string>();
    for (int i = 0; i < items.Count; i++)
    {
        var it = items[i];
        if (it.ProductId <= 0) errors.Add($"[{i}] ProductId が不正です。");
        if (it.Qty <= 0) errors.Add($"[{i}] Qty は1以上である必要があります。");
        if (it.PurchaseValue < 0) errors.Add($"[{i}] PurchaseValue は0以上である必要があります。");
    }
    if (errors.Count > 0)
        return UnprocessableEntity(new { errors });

    var entities = items.Select(x => new Purchase
    {
        ProductId = x.ProductId,
        Qty = x.Qty,
        PurchaseValue = x.PurchaseValue,
        PurchasedAt = DateTime.UtcNow
    }).ToList();

    // まとめて登録(必要に応じてバッチ分割は後述)
    await using var tx = await _db.Database.BeginTransactionAsync(ct);
    _db.Purchases.AddRange(entities);
    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);

    return Created(string.Empty, new { inserted = entities.Count });
}


} 

Minimal API 版(.NET 6+)

app.MapPost("/api/purchases/bulk", async (
    List<PurchaseDto> items,
    AppDbContext db,
    CancellationToken ct) =>
{
    if (items is null || items.Count == 0)
        return Results.BadRequest(new { message = "空のリクエストです。" });


var entities = items.Select(x => new Purchase
{
    ProductId = x.ProductId,
    Qty = x.Qty,
    PurchaseValue = x.PurchaseValue,
    PurchasedAt = DateTime.UtcNow
}).ToList();

db.Purchases.AddRange(entities);
await db.SaveChangesAsync(ct);
return Results.Created("/api/purchases/bulk", new { inserted = entities.Count });


}); 

フロント(jQuery)送信例

// rows テーブルから値を集め、DTO配列を作って送信
var purchaseList = [];
$("#rows tr.data").each(function () {
  const productId = parseInt($(this).find(".prod-id").val(), 10);
  const qty = parseInt($(this).find(".qty").val(), 10);
  const total = parseFloat($(this).find(".total").val()); // decimal相当
  purchaseList.push({ productId, qty, purchaseValue: total });
});

$.ajax({
url: "/api/purchases/bulk",
method: "POST",
data: JSON.stringify(purchaseList),
contentType: "application/json; charset=utf-8",
success: function (res) {
console.log("inserted:", res.inserted);
},
error: function (xhr) {
console.error(xhr.responseText);
}
}); 

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

[
  { "productId": 101, "qty": 2, "purchaseValue": 1200.00 },
  { "productId": 205, "qty": 1, "purchaseValue": 5980.00 }
]
{
  "inserted": 2
}

既存フロント実装を温存する妥協案(3配列→サーバーで再構成)

フロント側で IDS[] / qty[] / totlvau[] を既に生成している場合、API入口で一旦まとめてDTO配列に組み替えることで、フロントの変更を最小化できます。

POSTボディの形(例)

{
  "ids":    [101, 205, 333],
  "qty":    [2,   1,   5],
  "totlvau":[1200.00, 5980.00, 300.00]
}

API側の受け口と組み替え

public class PurchaseArraysDto
{
    public int[]? ids { get; set; }
    public int[]? qty { get; set; }
    public decimal[]? totlvau { get; set; }
}

[HttpPost("bulk-arrays")]
public async Task BulkInsertArrays([FromBody] PurchaseArraysDto data, CancellationToken ct)
{
if (data.ids is null || data.qty is null || data.totlvau is null)
return BadRequest(new { message = "ids/qty/totlvau のいずれかが null です。" });


if (data.ids.Length != data.qty.Length || data.qty.Length != data.totlvau.Length)
    return BadRequest(new { message = "配列の長さが一致しません。" });

var items = Enumerable.Range(0, data.ids.Length)
    .Select(i => new PurchaseDto
    {
        ProductId = data.ids[i],
        Qty = data.qty[i],
        PurchaseValue = data.totlvau[i]
    })
    .ToList();

// 以降は推奨方式と同じ
var entities = items.Select(x => new Purchase
{
    ProductId = x.ProductId,
    Qty = x.Qty,
    PurchaseValue = x.PurchaseValue,
    PurchasedAt = DateTime.UtcNow
}).ToList();

_db.Purchases.AddRange(entities);
await _db.SaveChangesAsync(ct);

return Created(string.Empty, new { inserted = entities.Count });


} 

jQueryからの送信(既存3配列をそのまま)

$.ajax({
  url: "/api/purchases/bulk-arrays",
  method: "POST",
  data: JSON.stringify({ ids: IDS, qty: qty, totlvau: totlvau }),
  contentType: "application/json; charset=utf-8"
});

注意:この妥協案は入口が複雑になりがちで、配列長のズレを見落とすとデータ崩壊の温床になります。長期的にはDTOリスト方式への移行を推奨します。

方式比較(要点早見表)

方式送信形式API受け口長所注意点
DTOリスト(推奨)[{ productId, qty, purchaseValue }][FromBody] List<PurchaseDto>読みやすい・拡張しやすい・バグが少ないフロントの送信コードを少し修正
妥協案:3配列→サーバーで組み替え{ ids:[], qty:[], totlvau:[] }[FromBody] PurchaseArraysDto既存jQueryの変更最小配列長チェック・保守性低下
URLクエリで配列?ids=1&ids=2...[FromQuery]簡易件数制限・URL長上限・セキュリティ面で非推奨

大量データを高速に登録するテクニック

1) バッチに分けて SaveChanges

const int batchSize = 500;
for (int i = 0; i &lt; entities.Count; i += batchSize)
{
    var chunk = entities.Skip(i).Take(batchSize).ToList();
    _db.Purchases.AddRange(chunk);
    await _db.SaveChangesAsync(ct);
}

2) AutoDetectChanges を一時的に抑制

var prev = _db.ChangeTracker.AutoDetectChangesEnabled;
_db.ChangeTracker.AutoDetectChangesEnabled = false;
try
{
    _db.Purchases.AddRange(entities);
    await _db.SaveChangesAsync(ct);
}
finally
{
    _db.ChangeTracker.AutoDetectChangesEnabled = prev;
}

3) SqlBulkCopy(SQL Server向け・最速クラス)

トランザクション性や制約の扱いに注意しつつ、極大量データでは SqlBulkCopy を使うと桁違いに高速です。

using System.Data;
using Microsoft.Data.SqlClient;

var dt = new DataTable();
dt.Columns.Add("ProductId", typeof(int));
dt.Columns.Add("Qty", typeof(int));
dt.Columns.Add("PurchaseValue", typeof(decimal));
foreach (var e in entities)
dt.Rows.Add(e.ProductId, e.Qty, e.PurchaseValue);

var conn = (SqlConnection)_db.Database.GetDbConnection();
await conn.OpenAsync(ct);
using var bulk = new SqlBulkCopy(conn)
{
DestinationTableName = "dbo.tbl_purchase",
BatchSize = 1000
};
bulk.ColumnMappings.Add("ProductId", "ProductId");
bulk.ColumnMappings.Add("Qty", "Qty");
bulk.ColumnMappings.Add("PurchaseValue", "PurchaseValue");
await bulk.WriteToServerAsync(dt, ct); 

制約(外部キー・重複キー)やトリガーがある場合の挙動は事前に検証してください。

入力検証・エラー設計のポイント

  • 数量・金額などの業務ルールはサーバー側で最終確認(クライアントの値は信用しない)。
  • 金額は decimal(18,2) など精度固定。浮動小数(double/float)は避ける。
  • [ApiController] 属性を付けるとモデル検証の自動400が働く。複雑な検証は独自に UnprocessableEntity(422) を返すと分かりやすい。

エラーレスポンス例

{
  "errors": [
    "[0] Qty は1以上である必要があります。",
    "[3] ProductId が不正です。"
  ]
}

堅牢性を高める設計(実運用TIPS)

  • トランザクション:複数明細は1ユニット。途中失敗時はロールバック。
  • 冪等性:同じカートを誤って再送しないよう、OrderNo 等の一意キーで重複挿入を防止。
  • 監査情報:IP、ユーザー、送信元(フロント/バッチ)を記録。
  • 最大サイズ:巨大ペイロードでは 413 (Payload Too Large)。Kestrelの最大受信サイズ・リバースプロキシ設定を見直す。
  • CORSとContent-Type:contentType: application/json/charset=utf-8 を明示。

よくある落とし穴と回避策

症状原因対処
APIが1件しか受け取らない[FromBody] を複数引数に付けている/3配列を別々に渡しているDTO配列に統一し、[FromBody] List<PurchaseDto> のみを受ける
415 Unsupported Media TypeContent-Type未指定application/json を指定
配列長の不一致でデータ崩壊3配列方式のズレサーバーで長さチェック/DTO配列方式へ移行
金額の丸め誤差float/doubleで計算decimal を使用/DB列を decimal(18,2)
大量データで遅い毎回 SaveChanges、AutoDetectChanges過多バッチ分割・AutoDetectChanges抑制・SqlBulkCopy

API契約を明確にする(サンプル仕様)

エンドポイント

POST /api/purchases/bulk
Content-Type: application/json

リクエストボディ(DTOリスト)

[
  { "productId": 101, "qty": 2, "purchaseValue": 1200.00 },
  { "productId": 205, "qty": 1, "purchaseValue": 5980.00 }
]

ステータスコード

  • 201 Created:挿入成功({ inserted: n })
  • 400 Bad Request:空ペイロード・形式不正
  • 422 Unprocessable Entity:ビジネスルール違反(数量0など)
  • 500 Internal Server Error:予期せぬ失敗

発展:ビュー/エンティティ分離と責務設計

クライアントからの受け口はDTO専用、永続化はエンティティで行います。コントローラは「受けて検証してユースケース呼び出し」までに留め、複雑な集計・在庫引当はアプリケーションサービスやドメイン層に移すとテストが容易になります。AutoMapperなどの導入も検討できますが、少量のフィールドであれば手書きマッピングが読みやすくバグも少なくなります。

テストと可観測性

  • ユニットテスト:DTO検証/マッピングのテスト。境界値(qty=0、金額負)を網羅。
  • 統合テスト:インメモリ/テスト用DBで POST /bulk → DB件数を確認。
  • ログ:1リクエストあたりの明細数、挿入所要時間、失敗率をメトリクス化。
  • アラート:422急増や500発生時に通知。

まとめ

ASP.NET Core Web APIで複数レコードを一括挿入する最短・堅牢な道は、「DTOリストをJSONで送る」ことです。これによりバインディングが安定し、検証・ログ・スキーマ拡張が容易になります。既存のjQuery実装を活かすなら、API入口で3配列からDTOへ組み替える妥協案も可能です。いずれにしても、検証・トランザクション・バッチ処理を組み合わせれば、読みやすくて速く、事故に強い一括登録を実現できます。

付録:マイグレーション例(SQL Server)

public partial class CreatePurchase : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "tbl_purchase",
            columns: table => new
            {
                Id = table.Column<long>(nullable: false)
                    .Annotation("SqlServer:Identity", "1, 1"),
                ProductId = table.Column<int>(nullable: false),
                Qty = table.Column<int>(nullable: false),
                PurchaseValue = table.Column<decimal>("decimal(18,2)", nullable: false),
                PurchasedAt = table.Column<DateTime>(nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_tbl_purchase", x => x.Id);
            });
    }


protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropTable("tbl_purchase");
}


} 

チェックリスト(導入時に確認)

観点確認ポイント
API契約DTOリスト/JSON/application/json/ルート・認可ルール
検証必須・範囲・業務ルール(負・ゼロ・桁数)
DB精度decimal(18,2) 等の定義・インデックス
性能バッチサイズ・AutoDetectChanges・Bulk手段
障害対応ロールバック・エラーメッセージ・アラート
運用監査ログ・レート制限・CORS

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次