「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 < 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 Type | Content-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 |

コメント