画像ファイルをタイトルと説明の文字列と共にアップロードし、サーバ側でサムネイル画像を作成してタイトル・説明・サムネイル画像・オリジナル画像を一式 SQL Server データベースに保存するサンプルを書きます。
上の画像はアップロードして SQL Server に保存されたタイトル、説明、サムネイル画像を取得して一覧にして表示したものです。(オリジナル画像を表示してないのは一覧表に表示するのは大きすぎるからという理由だけです)
以下に、Entity Framework Code First の機能を使っての SQL Server データベースの作り方、一覧の表示、アップロード、編集、削除機能を実装した ASP.NET Core MVC アプリの作り方を述べます。
(保存先をデータベースではなく Web サーバーの特定のフォルダにファイルとして保存する場合は別の記事「ASP.NET Core MVC でファイルアップロード」に書きましたので、興味がありましたらそちらを見てください)
ベースに使ったプロジェクトは、先の記事「Visual Studio 2022 の ASP.NET MVC アプリ」に書いた Visual Studio 2022 で作成した .NET 6.0 の ASP.NET Core MVC アプリです。それに以下のように機能を追加します。
(1) Model
アップロードの際に Controller と View の間でデータのやり取りをするための View Model と、Entity Framework を使って SQL Server データベースとのデータのやり取りをするエンティティクラスを作ります。この記事では以下のようにしました (ファイル名とか MIME タイプ情報なども保持したいかもしれませんが、それはまた別の機会に)。
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace MvcCore6App.Models
{
// View Model
// アップロードの際 Controller/View 間でデータのやり取りをする
public class FileUploadViewModel
{
[Display(Name = "タイトル")]
[Required(ErrorMessage = "{0} は必須")]
[StringLength(25, ErrorMessage = "{0} は {1} 文字以内")]
public string Title { get; set; }
[Display(Name = "説明")]
[StringLength(250, ErrorMessage = "{0} は {1} 文字以内")]
public string Description { get; set; }
[Display(Name = "ファイル")]
[Required(ErrorMessage = "{0} は必須")]
public IFormFile PostedFile { get; set; }
}
// エンティティクラス
// SQL Server データベースとのデータのやり取りをする。また、
// これをベースに EF Code First でデータベースを生成する
public class FileEntity
{
public int Id { get; set; }
[Display(Name = "タイトル")]
[Required]
[StringLength(25)]
public string FileName { get; set; }
[Display(Name = "説明")]
[StringLength(250)]
public string Description { get; set; }
[Display(Name = "サムネイル画像")]
[Required]
public byte[] ThumbImage { get; set; }
[Required]
public byte[] OriginalImage { get; set; }
}
}
Visual Studio 2022 で作成した .NET 6.0 の ASP.NET Core MVC アプリでは NULL 許容参照型がプロジェクト全体で有効化されていますので、警告を抑制するため #nullable disable を付与しています。下のコードでも必要に応じてそのようにしています。
(2) コンテキストクラス
Entity Framework を使って Controller と SQL Server データベースとの間でデータをやり取りするためのコンテキストクラスを定義します。この記事では以下のようにしました。
#nullable disable
using MvcCore6App.Models;
using Microsoft.EntityFrameworkCore;
namespace MvcCore6App.Data
{
public class FileContext : DbContext
{
public FileContext(DbContextOptions<FileContext> options) : base(options)
{
}
public DbSet<FileEntity> Files { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<FileEntity>().ToTable("File");
}
}
(3) Program.cs にサービスの追加
上記 (2) で定義したコンテクストクラスのインスタンスを Controller のコンストラクタ経由で DI できるようにするため、Program.cs に AddDbContext メソッドを使ってサービスの追加を行います。以下のコードで「// これを追加」とコメントした部分です。
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MvcCore6App.Data;
using MvcCore6App.Areas.Identity.Data;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration
.GetConnectionString("ApplicationDbContextConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
// これを追加
builder.Services.AddDbContext<FileContext>(options =>
options.UseSqlServer(connectionString));
// Add services to the container.
builder.Services.AddControllersWithViews();
// ・・・後略・・・
(4) SQL Server データベース作成
上に定義したエンティティクラスとコンテクストクラスをベースに Migration 操作を行って SQL Server データベースを生成します。
具体的には、Visual Studio のパッケージマネージャーコンソールで Add-Migration, Update-Database コマンドを実行すれば、Entity Framework Code First の機能によって、上の画像のようなデータベースが生成されます。
なお、上の (3) で接続文字列を appsettings.json に既存の ASP.NET Identity 用の "ApplicationDbContextConnection" にしていますので、ASP.NET Identity のユーザー情報のストア用のデータベースに File テーブルが追加されます。別のデータベースとして作成したい場合は接続文字列を変更してください。
(5) サムネイル作成用ユーティリティ
オリジナル画像を指定したサイズに縮小したサムネイル画像を作成するユーティリティクラスを定義します。
今回は以前に作成した .NET Framework の Windows アプリ用のコードを流用したのですが、それは Windows OS の GDI+ に依存する System.Drawing 名前空間のグラフィックス機能を利用しています。
.NET Core では特定の OS に依存する機能は Visual Studio のテンプレートで作るプロジェクトには含まれてないようで、利用するには NuGet パッケージ System.Drawing.Common をインストールする必要があります。
Windows OS の GDI+ に依存するということで Linux 上で動かすと例外が出て動かないとのことですが、それを解決するために libgdiplus というライブラリがあるそうです。(未検証・未確認です)
NuGet パッケージ System.Drawing.Common をインストールしても CA1416 警告が出ますが、#pragma warning disable CA1416 を追記して警告を抑制しました。コードは以下の通りです。
#pragma warning disable CA1416
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
namespace MvcCore6App.Utils
{
public class ImageUtils
{
const int sizeThumb = 69; // thumbimage のサイズ(縦横同じ)
const int sizeLarge = 400; // largerimage のサイズ(横幅)
// sizeThumb で指定されたサイズのサムネイルを作る。
// オリジナルの縦横比は保たれる(高さ or 幅の大きい方が sizeThumb になる)
public static byte[] MakeThumb(byte[] fullsize)
{
// ・・・省略・・・
}
// 引数 newWidth, newHeight で指定されたサイズのサムネイルを作る。
// 縦横で縮小率が異なる場合変形されないよう大きい方をトリミングして縮小
public static byte[] MakeThumb(byte[] fullsize, int newWidth, int newHeight)
{
using (MemoryStream ms1 = new MemoryStream(fullsize))
using (Image iOriginal = Image.FromStream(ms1))
{
// オリジナル/サムネイルの縦横のサイズ比
double scaleW = (double)iOriginal.Width / (double)newWidth;
double scaleH = (double)iOriginal.Height / (double)newHeight;
// オリジナル画像をトリミングするための Rectangle 作成
Rectangle srcRect = new Rectangle();
if (scaleH == scaleW) // 縦横同じ⇒トリミングなし
{
srcRect.Width = iOriginal.Width;
srcRect.Height = iOriginal.Height;
srcRect.X = 0;
srcRect.Y = 0;
}
else if (scaleH > scaleW) // 縦 > 横 ⇒ 縦のみトリミング
{
srcRect.Width = iOriginal.Width;
srcRect.Height = Convert.ToInt32((double)newHeight * scaleW);
srcRect.X = 0;
srcRect.Y = (iOriginal.Height - srcRect.Height) / 2;
}
else // 縦 < 横 ⇒ 横のみトリミング
{
srcRect.Width = Convert.ToInt32((double)newWidth * scaleH);
srcRect.Height = iOriginal.Height;
srcRect.X = (iOriginal.Width - srcRect.Width) / 2;
srcRect.Y = 0;
}
using (Image iThumb = new Bitmap(newWidth, newHeight))
using (Graphics g = Graphics.FromImage(iThumb))
{
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
Rectangle destRect = new Rectangle(0, 0, newWidth, newHeight);
g.DrawImage(iOriginal, destRect, srcRect, GraphicsUnit.Pixel);
using (MemoryStream ms2 = new MemoryStream())
{
iThumb.Save(ms2, ImageFormat.Jpeg);
return ms2.GetBuffer();
}
}
}
}
// 幅のみ指定してサムネイルを作る。高さは幅と同じ縮小率で縮小。
public static byte[] MakeThumb(byte[] fullsize, int maxWidth)
{
// ・・・省略・・・
}
}
}
引数 fullsize のバイト列が有効なイメージ形式でないと Image.FromStream で例外がスローされます。一応 png と jpeg 形式は問題ないのは確認しましたが、その他はチェックはしてないので注意してください。戻り値のサムネイル画像のバイト列は jpeg 形式になります。
(6) Controller / Action Method
SQL Server のレコード一覧の表示、アップロード、編集、削除を行うための Controller のコードは以下のようにしました。
using Microsoft.AspNetCore.Mvc;
using MvcCore6App.Data;
using MvcCore6App.Models;
using MvcCore6App.Utils;
using Microsoft.EntityFrameworkCore;
namespace MvcCore6App.Controllers
{
public class FileController : Controller
{
private readonly FileContext _context;
public FileController(FileContext context)
{
_context = context;
}
public async Task<IActionResult> Index()
{
var fileContext = _context.Files;
return View(await fileContext.ToListAsync());
}
public async Task<IActionResult> GetThumb(int? id)
{
if (id == null)
{
return NotFound();
}
var file = await _context.Files
.FirstOrDefaultAsync(m => m.Id == id);
if (file == null)
{
return NotFound();
}
return File(file.ThumbImage, "image/jpeg");
}
public IActionResult Upload()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Upload(FileUploadViewModel model)
{
if (ModelState.IsValid)
{
using (var memoryStream = new MemoryStream())
{
await model.PostedFile.CopyToAsync(memoryStream);
// Upload the file if less than 2 MB
if (memoryStream.Length < 2097152)
{
var byteArray = memoryStream.ToArray();
var file = new FileEntity()
{
FileName = model.Title,
Description = model.Description,
ThumbImage = ImageUtils.MakeThumb(byteArray, 70, 70),
OriginalImage = byteArray
};
_context.Files.Add(file);
await _context.SaveChangesAsync();
}
else
{
ModelState.AddModelError("PostedFile", "サイズは 2MB 以下");
return View(model);
}
}
return RedirectToAction("Index");
}
return View(model);
}
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var file = await _context.Files.FindAsync(id);
if (file == null)
{
return NotFound();
}
return View(file);
}
[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditPost(int? id)
{
if (id == null)
{
return NotFound();
}
var fileToUpdate = await _context
.Files
.FirstOrDefaultAsync(f => f.Id == id);
if (fileToUpdate != null)
{
if (await TryUpdateModelAsync<FileEntity>(
fileToUpdate,
"",
f => f.FileName, f => f.Description))
{
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}
}
return View(fileToUpdate);
}
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
var file = await _context.Files
.AsNoTracking()
.FirstOrDefaultAsync(f => f.Id == id);
if (file == null)
{
return NotFound();
}
return View(file);
}
// POST: Students/Delete/5
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var file = await _context.Files.FindAsync(id);
if (file == null)
{
return RedirectToAction("Index");
}
_context.Files.Remove(file);
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}
}
}
Upload メソッドは Microsoft のドキュメント「バッファー モデル バインドを使用して小さいファイルをデータベースにアップロードする」を参考にしました。Action Method と View の間のデータのやり取りにはビューモデル FileUploadViewModel を使用し、それをエンティティクラス FileEntity に移し替えるとともにサムネイル画像を追加し SQL Server データベースに保存しています。
アップロードするファイルの検証については、アップロードする時点でのファイル選択の有無とサーバー側でのサイズのチェックしか行っていません。Microsoft のドキュメント「ASP.NET Core でファイルをアップロードする」の「セキュリティに関する考慮事項」のセクションに書かれたセキュリティ関する配慮はしていませんので注意してください。
先の記事「ファイルアップロード時の検証 (CORE)」に、ASP.NET Core. 3.1 MVC アプリでファイルをアップロードする際に、カスタム検証属性を利用してファイルのサイズとタイプをクライアント側とサーバー側の両方で検証し、検証結果 NG の場合はエラーメッセージを表示する方法を書きましたので、興味があれば見てください。
Edit / EditPost メソッドではタイトルと説明のみ編集して結果を SQL Server データベースに反映するようにしました。画像の差し替えは上のコードではできません (画像を差し替えるなら、削除してから新たに Upload し直した方が良いと思いましたので)。
タイトルと説明のみ変更するため TryUpdateModelAsync メソッドを使っているところに注目してください。よくあるパターンとしては、上の EditPost メソッドの引数に ビューモデル FileUploadViewModel を使ってそれにモデルバインドということをすると思いますが、画像データはアップロードされてこないところが問題です。
上の EditPost メソッドのコードでは、既存のエンティティを読み取り、TryUpdateModel を呼び出してポストされたタイトルと説明からフィールドを更新しています。既存のエンティティの読み取りによって画像データも取得されるので、その上でタイトルと説明だけを変更して SaveChanges を適用するので画像データが消えることはないです。
TryUpdateModelAsync メソッドについては Microsoft のチュートリアルの「HttpPost Edit メソッドの更新」が参考になると思いますので興味があれば見てください。
GetThumb メソッドは、DB からサムネイル画像を取得してダウンロードするためのメソッドです。この記事の一番上の一覧表示の画像ようにサムネイルを表示するために使います。View に img 要素を配置し、その src 属性に GetThumb メソッドを設定することによりサムネイル画像が表示されます。
src 属性に GetThumb メソッドを設定するということは、ブラウザはそこで GetThumb メソッドを呼んでデータベースからデータを取得してくるという動きになることに注意してください。特にこの記事の一番上の画像のように一覧表示する場合はレコードの数だけ GetThumb メソッドが呼ばれることになります。
それを避けるのは、アクションメソッドですべてのデータを Model として View に渡し済みですので、View で Model に含まれる画像のバイト列を Data URL 形式に変換して src 属性に設定することにより可能です。その例は下の「(7) View」のセクションの Index.cshtml に書きます。ただし、そのようにした場合はブラウザはその画像をキャッシュできないことに注意してください。
(7) View
Index.cshtml, Upload.cshtml, Edit.cshtml, Delete.cshtml のコードをその順に以下に記載しておきます。Index.cshtml と Delete.cshtml には上のコントローラーのコードの GetThumb メソッドを使ってサムネイル画像を表示するようにしています。
Index.cshtml
@model IEnumerable<MvcCore6App.Models.FileEntity>
@{
ViewData["Title"] = "Index";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<p>
<a asp-action="Upload">Uoload New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.FileName)
</th>
<th>
@Html.DisplayNameFor(model => model.Description)
</th>
<th>
@Html.DisplayNameFor(model => model.ThumbImage)
</th>
<th></th>
</tr>
</thead>
<tbody>
@{
#nullable disable
foreach (var item in Model) {
<tr>
<td>
@Html.DisplayFor(modelItem => item.FileName)
</td>
<td>
@Html.DisplayFor(modelItem => item.Description)
</td>
<td>
@*以下はコメントアウトして代わりのコードを書いた
<img src="/File/GetThumb/@item.Id"
alt="@item.FileName" title="@item.FileName" />*@
@*アクションメソッドですべてのデータを Model として受け取って
いるので、Model に含まれる画像のバイト列を Data URL 形式に変
換して src 属性に設定することにより画像を表示できる。*@
<img src="data:image/jpeg;base64,@Convert.ToBase64String(item.ThumbImage)"
alt="@item.FileName" title="@item.FileName" />
</td>
<td>
<a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
<a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
</td>
</tr>
}
}
</tbody>
</table>
Upload.cshtml
@model MvcCore6App.Models.FileUploadViewModel
@{
ViewData["Title"] = "Upload";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>Upload</h1>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="Upload" enctype="multipart/form-data" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Title" class="control-label"></label>
<input asp-for="Title" class="form-control" />
<span asp-validation-for="Title" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Description" class="control-label"></label>
<input asp-for="Description" class="form-control" />
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="PostedFile" class="control-label"></label>
<input asp-for="PostedFile" type="file" class="form-control" />
<span asp-validation-for="PostedFile" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-action="Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
Edit.cshtml
@model MvcCore6App.Models.FileEntity
@{
ViewData["Title"] = "Edit";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>Edit</h1>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="Edit">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Id" />
<div class="form-group">
<label asp-for="FileName" class="control-label"></label>
<input asp-for="FileName" class="form-control" />
<span asp-validation-for="FileName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Description" class="control-label"></label>
<input asp-for="Description" class="form-control" />
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-action="Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
Delete.cshtml
@model MvcCore6App.Models.FileEntity
@{
ViewData["Title"] = "Delete";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>Delete</h1>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>FileEntity</h4>
<hr />
<dl class="row">
<dt class = "col-sm-2">
@Html.DisplayNameFor(model => model.FileName)
</dt>
<dd class = "col-sm-10">
@Html.DisplayFor(model => model.FileName)
</dd>
<dt class = "col-sm-2">
@Html.DisplayNameFor(model => model.Description)
</dt>
<dd class = "col-sm-10">
@Html.DisplayFor(model => model.Description)
</dd>
<dt class = "col-sm-2">
@Html.DisplayNameFor(model => model.ThumbImage)
</dt>
<dd class = "col-sm-10">
@{
if (Model != null)
{
<img src="/File/GetThumb/@Model.Id"
alt="@Model.FileName"
title="@Model.FileName" />
}
}
</dd>
</dl>
<form asp-action="Delete">
<input type="hidden" asp-for="Id" />
<input type="submit" value="Delete"
class="btn btn-danger" /> |
<a asp-action="Index">Back to List</a>
</form>
</div>