ASP.NET Core MVC×Web APIで画像付きCRUDを完全実装する手順|IFormFileとmultipartの正攻法

ASP.NET CoreでMVCとWeb APIを分離した構成にすると、一覧表示はREST、登録・更新はフォーム投稿という“ハイブリッド”な実装が求められます。特に画像ファイルを含むCRUDでは、パスの扱い・Content‑Type・静的ファイル公開・セキュリティのいずれかが欠けただけで動かなくなりがちです。本稿は「なぜ表示できないのか」「どう送ればいいのか」を、設計からコードまで一気通貫で解説します。

目次

前提と全体像(アーキテクチャ)

本記事では次のような分離構成を前提にします。

  • APIサーバー:製品情報(tbl_product)と画像ファイルを受け取り、DBとストレージ(wwwroot/images)に保存。JSONで一覧・詳細を返す。
  • MVCアプリ:ユーザー向け画面。APIからJSONを取得して表示。登録・更新フォームからは画像を含む multipart/form-data をAPIへ送信。

ポイントは「画像はAPIサーバーの静的ファイルとして公開し、DBにはローカル絶対パスではなく相対パスまたはファイル名を保持する」ことです。表示用URLは /images/abc.jpg のようにし、必要に応じてMVC側でAPIのホスト名を前置します。

よくある失敗と正しいアプローチ(要約表)

課題主な原因解決策(要点)
画像が表示されない<img src="C:\..."> のようにローカル絶対パスを返しているAPIで静的ファイルを公開(app.UseStaticFiles())。 画像は wwwroot/images 配下に保存(DBは相対パス /images/xxx.jpg)。 表示時は <img src="/images/xxx.jpg">。別ドメインなら https://api.example.com/images/xxx.jpg
Insertで画像が届かないapplication/json で画像を送っているフォームに enctype="multipart/form-data" を必ず付与。 MVC→APIは MultipartFormDataContent で文字列+画像を同時送信。 APIの受け側は [FromForm]IFormFile を受け取る。
Updateで画像だけ更新できないUpdateでもJSON固定で送信しているInsertと同じく multipart/form-data を使用。新規画像がなければ既存パスを維持。
旧画像が残りディスクが肥大新規保存後に旧ファイルを消していない置換時に File.Delete(oldFullPath) を実施。削除APIでも物理削除を行う。

API側の実装:静的ファイル公開と保存ディレクトリ

APIプロジェクトに wwwroot/images を作成し、静的ファイルを有効化します。配置は UseRouting より前 が定石です。

// Program.cs(API)
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
// 画像アップロードサイズ上限(例:10MB)
builder.Services.Configure(o =>
{
o.MultipartBodyLengthLimit = 10 * 1024 * 1024;
});

var app = builder.Build();

app.UseHttpsRedirection();

// 画像を wwwroot から配信
app.UseStaticFiles(); // <-- これがポイント(/images/*.jpg などがそのまま公開される)

app.UseRouting();
app.UseAuthorization();
app.MapControllers();

app.Run(); 

コントローラから保存先を扱うために IWebHostEnvironment を注入し、WebRootPath で物理パスを取得します。

API側のDTOとエンティティ設計

// Entity(DBに保存)
public class Product
{
    public int Id { get; set; }
    public string ProductName { get; set; } = "";
    // DBには相対パスまたはファイル名のみを保存(例:"/images/xxxx.jpg")
    public string ImagePath { get; set; } = "";
}

// クライアントへ返すDTO(公開URLを含めても良い)
public class ProductDto
{
public int Id { get; set; }
public string ProductName { get; set; } = "";
public string ImagePath { get; set; } = ""; // "/images/xxxx.jpg"
public string? ImageUrl { get; set; }       // "[https://api-host/images/xxxx.jpg](https://api-host/images/xxxx.jpg)"
}

// 受信DTO(Insert/Update用)— [FromForm] で受ける
public class ProductCreateRequest
{
public string ProductName { get; set; } = "";
public IFormFile? ImagePath { get; set; } // name="imagepath"
}

public class ProductUpdateRequest
{
public string ProductName { get; set; } = "";
public IFormFile? ImagePath { get; set; } // 任意:未指定なら画像は据え置き
} 

フィールド名はMVC側フォームの name 属性と一致させます(例:name="imagepath")。既存プロジェクトでAPIのパラメータ名が異なる場合は、[FromForm(Name = "imagepath")] のように調整してください。

API側のコントローラ:Insert / Update / Delete

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IWebHostEnvironment _env;
    private readonly AppDbContext _db;
public ProductsController(IWebHostEnvironment env, AppDbContext db)
{
    _env = env;
    _db = db;
}

private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".webp" };

private (bool ok, string relPath, string? error) SaveImage(IFormFile file, string prefix)
{
    if (file == null || file.Length == 0) return (false, "", "ファイルが空です。");

    var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
    if (!AllowedExtensions.Contains(ext)) return (false, "", "許可されていない拡張子です。");

    // 画像サイズ制限(例:10MB)
    if (file.Length &gt; 10 * 1024 * 1024) return (false, "", "ファイルサイズが大きすぎます。");

    var webroot = _env.WebRootPath; // wwwroot
    var images = Path.Combine(webroot, "images");
    Directory.CreateDirectory(images);

    var safePrefix = string.Join("_", prefix.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries));
    var unique = $"{safePrefix}_{Guid.NewGuid():N}{ext}";
    var fullPath = Path.Combine(images, unique);

    using var fs = System.IO.File.Create(fullPath);
    file.CopyTo(fs);

    // DBには相対パスを保存
    var rel = "/images/" + unique;
    return (true, rel, null);
}

[HttpGet]
public IEnumerable&lt;ProductDto&gt; GetAll()
{
    var baseUrl = $"{Request.Scheme}://{Request.Host}";
    return _db.Products.OrderByDescending(p =&gt; p.Id).Select(p =&gt; new ProductDto
    {
        Id = p.Id,
        ProductName = p.ProductName,
        ImagePath = p.ImagePath,
        ImageUrl = string.IsNullOrEmpty(p.ImagePath) ? null : baseUrl + p.ImagePath
    }).ToList();
}

[HttpPost]
public async Task&lt;IActionResult&gt; Create([FromForm] ProductCreateRequest req)
{
    if (!ModelState.IsValid) return ValidationProblem(ModelState);
    var entity = new Product { ProductName = req.ProductName };

    if (req.ImagePath != null)
    {
        var (ok, rel, err) = SaveImage(req.ImagePath, req.ProductName);
        if (!ok) return BadRequest(new { message = err });
        entity.ImagePath = rel;
    }

    _db.Products.Add(entity);
    await _db.SaveChangesAsync();
    return CreatedAtAction(nameof(GetById), new { id = entity.Id }, new { entity.Id });
}

[HttpGet("{id:int}")]
public ActionResult&lt;ProductDto&gt; GetById(int id)
{
    var p = _db.Products.Find(id);
    if (p == null) return NotFound();
    var baseUrl = $"{Request.Scheme}://{Request.Host}";
    return new ProductDto
    {
        Id = p.Id,
        ProductName = p.ProductName,
        ImagePath = p.ImagePath,
        ImageUrl = string.IsNullOrEmpty(p.ImagePath) ? null : baseUrl + p.ImagePath
    };
}

[HttpPut("{id:int}")]
public async Task&lt;IActionResult&gt; Update(int id, [FromForm] ProductUpdateRequest req)
{
    var p = _db.Products.Find(id);
    if (p == null) return NotFound();

    p.ProductName = req.ProductName;

    if (req.ImagePath != null &amp;&amp; req.ImagePath.Length &gt; 0)
    {
        // 旧ファイル物理削除
        if (!string.IsNullOrEmpty(p.ImagePath))
        {
            var oldFull = Path.Combine(_env.WebRootPath, p.ImagePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
            if (System.IO.File.Exists(oldFull)) System.IO.File.Delete(oldFull);
        }

        var (ok, rel, err) = SaveImage(req.ImagePath, req.ProductName);
        if (!ok) return BadRequest(new { message = err });
        p.ImagePath = rel;
    }

    await _db.SaveChangesAsync();
    return NoContent();
}

[HttpDelete("{id:int}")]
public async Task&lt;IActionResult&gt; Delete(int id)
{
    var p = _db.Products.Find(id);
    if (p == null) return NotFound();

    if (!string.IsNullOrEmpty(p.ImagePath))
    {
        var full = Path.Combine(_env.WebRootPath, p.ImagePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
        if (System.IO.File.Exists(full)) System.IO.File.Delete(full);
    }

    _db.Products.Remove(p);
    await _db.SaveChangesAsync();
    return NoContent();
}

} 

アップロード時のファイル名は Guid でユニーク化し、AllowedExtensions で拡張子をホワイトリスト化。これにより衝突と不正拡張子の両方を防げます。

MVC側:モデルとフォーム(Insert/Update共通)

フォームには enctype="multipart/form-data" を必ず指定し、モデルには IFormFile を持たせます。

// ViewModel(MVC側)
public class ProductViewModel
{
    public int? Id { get; set; }
    public string ProductName { get; set; } = "";
    public IFormFile? ImagePath { get; set; } // name="ImagePath"(API側の[FromForm]名に合わせる)
    public string? CurrentImageUrl { get; set; } // プレビュー用
}
&lt;form asp-action="Save" method="post" enctype="multipart/form-data"&gt;
  &lt;input type="hidden" asp-for="Id" /&gt;
  &lt;div&gt;
    &lt;label&gt;商品名&lt;/label&gt;
    &lt;input asp-for="ProductName" required /&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;label&gt;画像&lt;/label&gt;
    &lt;input type="file" asp-for="ImagePath" accept=".jpg,.jpeg,.png,.gif,.webp" /&gt;
    &lt;div&gt;
      &lt;img src="@Model.CurrentImageUrl" alt="" style="max-width:160px;height:auto" /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;button type="submit"&gt;保存&lt;/button&gt;
&lt;/form&gt;

InsertとUpdateを同じアクションでハンドリングする場合は、IDの有無で分岐します。

MVC側:API呼び出し(MultipartFormDataContent)

// Program.cs(MVC)— HttpClient をDI登録
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();

// APIベースURLを appsettings などで管理
builder.Services.AddHttpClient("ApiClient", c =>
{
c.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]); // 例: [https://api.example.com/](https://api.example.com/)
});

var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.MapDefaultControllerRoute();
app.Run(); 
// Controller(MVC)
public class ProductsController : Controller
{
    private readonly IHttpClientFactory _factory;

    public ProductsController(IHttpClientFactory factory) =&gt; _factory = factory;

    [HttpGet]
    public async Task&lt;IActionResult&gt; Index()
    {
        var client = _factory.CreateClient("ApiClient");
        var list = await client.GetFromJsonAsync&lt;List&lt;ProductDto&gt;&gt;("api/products");
        return View(list);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task&lt;IActionResult&gt; Save(ProductViewModel vm)
    {
        var client = _factory.CreateClient("ApiClient");

        using var form = new MultipartFormDataContent();
        form.Add(new StringContent(vm.ProductName ?? ""), "ProductName");

        if (vm.ImagePath != null &amp;&amp; vm.ImagePath.Length &gt; 0)
        {
            var stream = vm.ImagePath.OpenReadStream();
            form.Add(new StreamContent(stream), "ImagePath", vm.ImagePath.FileName);
        }

        HttpResponseMessage res;
        if (vm.Id.HasValue)
        {
            res = await client.PutAsync($"api/products/{vm.Id}", form);
        }
        else
        {
            res = await client.PostAsync("api/products", form);
        }

        if (!res.IsSuccessStatusCode)
        {
            var msg = await res.Content.ReadAsStringAsync();
            ModelState.AddModelError(string.Empty, $"APIエラー: {msg}");
            return View("Edit", vm);
        }

        return RedirectToAction(nameof(Index));
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task&lt;IActionResult&gt; Delete(int id)
    {
        var client = _factory.CreateClient("ApiClient");
        var res = await client.DeleteAsync($"api/products/{id}");
        if (!res.IsSuccessStatusCode) ModelState.AddModelError("", "削除に失敗しました。");
        return RedirectToAction(nameof(Index));
    }
}

API側で ImageUrl を返していれば、MVCの一覧はそのまま src に指定できます。返却が相対パス(/images/xxx.jpg)のみの場合は、MVC側でAPIのホストを前置します。

// Razorの一例(相対パスを絶対URLへ補完)
@inject Microsoft.Extensions.Configuration.IConfiguration Config
@{
    string apiBase = Config["ApiBaseUrl"]?.TrimEnd('/') ?? "";
}
@foreach (var item in Model)
{
    var url = string.IsNullOrEmpty(item.ImageUrl)
        ? (string.IsNullOrEmpty(item.ImagePath) ? "" : apiBase + item.ImagePath)
        : item.ImageUrl;

    &lt;img src="@url" alt="" style="max-width:120px;height:auto" /&gt;
}

キャッシュ最適化とキャッシュバスター

同じファイル名で上書きするとブラウザキャッシュにより更新が見えないことがあります。推奨は新しいファイル名で保存(本記事の実装)ですが、どうしても同名運用にしたいならクエリ文字列のバージョンを付与します。

// 例:&lt;img src="/images/xxx.jpg?v=638000000000000000"&gt; の形式で出力する
var version = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var url = $"/images/xxx.jpg?v={version}";

静的ファイルのHTTPキャッシュを明示したい場合は、UseStaticFiles にオプションを与えて Cache-Control を付与します。

app.UseStaticFiles(new StaticFileOptions
{
    OnPrepareResponse = ctx =&gt;
    {
        // 例:1日キャッシュ
        ctx.Context.Response.Headers["Cache-Control"] = "public,max-age=86400";
    }
});

セキュリティ実装チェックリスト

  • 拡張子とMIMEタイプのダブルチェック:拡張子ホワイトリスト+ImageSharp等でデコード可否を検証。
  • ファイル名の正規化:Path.GetFileName を通し、無効文字を除去してディレクトリトラバーサルを防止。
  • サイズ上限:フォームの MultipartBodyLengthLimit とアプリ側バリデーションで二重に制御。
  • ウイルス対策:必要に応じてスキャンを挟む(外部サービスやコマンド連携)。
  • CSRF対策:MVCフォームは [ValidateAntiForgeryToken]。APIを外部クライアントが叩く場合はトークン方式(Bearer等)を検討。
  • CSP(Content-Security-Policy):表示側で img-src をAPIドメインに限定。
  • 公開範囲の最小化:画像は wwwroot/images のみに集約し、アップロード先を公開領域外にしない。

クロスドメイン時のポイント(APIとMVCが別ホスト)

  • 表示:<img> での単純表示は通常CORS不要ですが、Canvas などへ描画してピクセルを読むならCORSヘッダが必要。
  • API呼び出し:POST/PUT/DELETE等はCORS設定が必須。サーバーで許可オリジン・メソッド・ヘッダを適切に設定。
  • URLの一貫性:DBには相対パス、クライアントでは ApiBaseUrl + ImagePath で絶対化すると運用が楽。
// API(CORS例)
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
    p.WithOrigins("https://mvc.example.com")
     .AllowAnyHeader()
     .AllowAnyMethod()));

app.UseCors(); 

サムネイル生成・リサイズ(任意の品質向上)

原寸画像をそのまま配信すると帯域や表示速度に影響します。保存時に小さめのサムネイルを並行生成しておくとUXが向上します。

// ImageSharp を使った簡易サムネイル例(パッケージ導入が必要)
private string SaveThumbnail(string fullPath, int width)
{
    var thumbPath = Path.Combine(Path.GetDirectoryName(fullPath)!, 
        Path.GetFileNameWithoutExtension(fullPath) + "_thumb" + Path.GetExtension(fullPath));
using var image = SixLabors.ImageSharp.Image.Load(fullPath);
var ratio = (double)width / image.Width;
var height = (int)(image.Height * ratio);
image.Mutate(x =&gt; x.Resize(width, height));
image.Save(thumbPath);
return "/images/" + Path.GetFileName(thumbPath);

} 

DTOに ThumbnailPath を追加しておけば、一覧ではサムネ、詳細では原寸という出し分けが容易です。

表示側(一覧テーブル)の具体例

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;&lt;th&gt;ID&lt;/th&gt;&lt;th&gt;商品名&lt;/th&gt;&lt;th&gt;画像&lt;/th&gt;&lt;th&gt;操作&lt;/th&gt;&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
  @foreach (var item in Model)
  {
      &lt;tr&gt;
        &lt;td&gt;@item.Id&lt;/td&gt;
        &lt;td&gt;@item.ProductName&lt;/td&gt;
        &lt;td&gt;
          &lt;img src="@(string.IsNullOrEmpty(item.ImageUrl) ? item.ImagePath : item.ImageUrl)" alt="" style="max-width:120px;height:auto" /&gt;
        &lt;/td&gt;
        &lt;td&gt;
          &lt;a asp-action="Edit" asp-route-id="@item.Id"&gt;編集&lt;/a&gt; |
          &lt;form asp-action="Delete" method="post" style="display:inline"&gt;
            &lt;input type="hidden" name="id" value="@item.Id" /&gt;
            @Html.AntiForgeryToken()
            &lt;button type="submit"&gt;削除&lt;/button&gt;
          &lt;/form&gt;
        &lt;/td&gt;
      &lt;/tr&gt;
  }
  &lt;/tbody&gt;
&lt;/table&gt;

アップロードのエラーと対処(トラブルシューティング表)

症状原因対処
画像が表示されない(404)UseStaticFiles 未設定/保存先が wwwrootAPIの Program.csUseStaticFiles を追加、保存先を wwwroot/images に統一
Unsupported Media Type(415)クライアントが application/json で送信フォームの enctypemultipart/form-data にして MultipartFormDataContent を使用
Request body too large(413)アップロード上限に到達FormOptions.MultipartBodyLengthLimit を拡張し、同時にUI側でもサイズ制限を明記
ファイル名が壊れる/多言語で不具合ファイル名に全角・記号を含む保存時は Guid ベースの英数字名に変換し、元名はDBの別列で保持
更新しても画像が切り替わらないブラウザキャッシュ新規ファイル名で保存する/クエリにバージョン(?v=timestamp)を付与
旧画像が残る置換後のクリーンアップ未実施Update時に旧物理ファイルを File.Delete

実装のベストプラクティス(まとめ)

  • DBには絶対パスではなく相対パス(例:/images/xxx.jpg)を保存。
  • APIの wwwroot/images に保存し、app.UseStaticFiles() で公開。
  • Insert/Updateは multipart/form-data[FromForm] IFormFile でやり取り。
  • 更新時は旧ファイルの物理削除とキャッシュ対策を忘れない。
  • 拡張子/MIME/サイズの検証、ファイル名のユニーク化でセキュアに。
  • 別ドメイン構成ではURLの絶対化とCORS設定を整える。

上記の方針に従えば、「APIでは相対パスを返す」「MVCはそれを画像の src にそのまま使う(必要ならベースURLを前置)」というシンプルな責務分担が実現できます。結果として、Web API経由の画像付きCRUDが堅牢かつ拡張可能な形で完成します。

参考:要点のみの最小コード(実運用は上の完全版を推奨)

// API 側:保存の最小要点
app.UseStaticFiles();
string images = Path.Combine(app.Environment.WebRootPath, "images");
Directory.CreateDirectory(images);

// MVC 側:画像付きInsert
using var form = new MultipartFormDataContent();
form.Add(new StringContent(model.ProductName), "ProductName");
if (model.ImagePath != null && model.ImagePath.Length > 0)
form.Add(new StreamContent(model.ImagePath.OpenReadStream()), "ImagePath", model.ImagePath.FileName);
await _client.PostAsync("api/products", form);

// 表示(相対パス or 絶対パス)
 

チェックリスト(公開前の最終確認)

  • API:wwwroot/images が存在し、書き込み権限がある。
  • API:UseStaticFiles を設定済み。
  • API:[FromForm] IFormFile を受け取れている(Actionのパラメータ名とフォームの name が一致)。
  • MVC:フォームに enctype="multipart/form-data" を指定。
  • MVC:MultipartFormDataContent でファイル+文字列を同梱。
  • DB:ImagePath は相対パス(/images/..)。
  • Update:旧ファイルの物理削除ロジックがある。
  • 表示:別ドメインならAPIベースURLを前置、またはAPIが ImageUrl を返す。
  • セキュリティ:拡張子・MIME・サイズ・ファイル名正規化を実装。
  • キャッシュ:新規ファイル名運用、またはバージョンクエリで更新が可視化される。

付録:あなたの断片コードをこの方針に合わせて修正するには

質問で挙げられていた要素を、この完成形に合わせると次のようになります。

  • 静的ファイル公開:APIの Program.csapp.UseStaticFiles();
  • 保存パス:Path.Combine(app.Environment.WebRootPath, "images") を使い、DBには "/images/" + uniqueFileName を保存。
  • ビュー表示:<img src='@item.path'>(同一ドメイン)または <img src='https://[email protected]'>(別ドメイン)。
  • Insert/Update:どちらも MultipartFormDataContent で送信し、APIは [FromForm]IFormFile を受信。
  • 旧画像削除:System.IO.File.Delete(oldPath) をUpdate/Deleteに実装。

コード断片(復習・要点のみ)

// Program.cs(API)
app.UseStaticFiles(); // ルーティング前に置くのが定石

// 画像アップロード(APIの保存関数の核心)
string uploadFolder = Path.Combine(app.Environment.WebRootPath, "images");
Directory.CreateDirectory(uploadFolder);
string uniqueFileName = $"{productName}_{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
using var fs = new FileStream(Path.Combine(uploadFolder, uniqueFileName), FileMode.Create);
file.CopyTo(fs);
return "/images/" + uniqueFileName; // DBにはこれを保存 
&lt;!-- MVC テーブル描画(例) --&gt;
&lt;td data-label="画像"&gt;
  &lt;img src="@item.ImageUrl ?? (ApiBaseUrl + item.ImagePath)" alt="" class="responsive-image"&gt;
&lt;/td&gt;
// MVC Controller: 画像付き Insert(要点)
[HttpPost]
public async Task&lt;IActionResult&gt; InsertRecord(ProductViewModel model)
{
    using var form = new MultipartFormDataContent();
    form.Add(new StringContent(model.ProductName), "ProductName");
    if (model.ImagePath != null &amp;&amp; model.ImagePath.Length &gt; 0)
        form.Add(new StreamContent(model.ImagePath.OpenReadStream()), "ImagePath", model.ImagePath.FileName);

    HttpResponseMessage res = await _client.PostAsync("api/products", form);
    return RedirectToAction("Index");
}

最後に

画像は「バイナリをJSONに入れない」「絶対パスをDBに入れない」「公開は静的ファイル」の3原則を守れば迷いません。APIとMVCの責務分担をクリアにし、IFormFile+multipartで確実に受け渡すことで、画像付きのCRUDは堅実に動作します。ここまでの実装を土台に、サムネイル生成やCDN配信などの拡張も容易になります。

この記事を書いた人

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

コメント

コメントする

目次