Zenn
👌

Windows環境でWebアプリを構築する場合

2025/01/07に公開1
4

はじめに

普段Webアプリを開発する場合は今までLinux系しかなかったので、ApacheやNGINXにDjangoなどのPythonWebフレームワークで開発したアプリをデプロイしていたが(単層構造)、IISの場合はどうするのかふと疑問に思った。
Linux系と同じ感じにできるのかと思ったら、どうやらPythonとの相性が良くないらしいので、プログラミング言語から考え直す必要がありそう。C#でASP.NETがIISと一番親和性が高そうなので、試しにこれを使って簡単なWebアプリを作ってみた。
(IIS環境までは用意できなかったので、ローカルPCのデバック環境での実行のみ確認)

前提環境

Windows 10 pro
Visual Studio 2022
.NET 8.0
PostgreSQL 12

ASP.NETのテンプレート選択

Visual Studio が提供する標準テンプレートを使うのが効率的らしく、今回は以下のテンプレートを選択した。

ASP.NET Core Web アプリケーションASP.NET Web アプリケーション (.NET Framework) のフレームワークがあるが、基本は新しくアプリ構築するのであればASP.NET Core Web アプリケーションが良さそう。

MVC(Model-View-Controller)のテンプレートを使った場合、以下のようなフォルダ構造になる。

  • Models: アプリのデータやビジネスロジックを管理します。
  • Views: HTMLを含むユーザーインターフェース(UI)を定義します。
  • Controllers: HTTPリクエストを受け取り、処理してViewまたはデータを返します。
  • wwwroot: 静的なファイル(CSS、JavaScript、画像など)を格納します。

htmlファイル と cshtml ファイルについて

cshtmlファイルは見たことがなかったので、一応違いについてメモ。
完全な静的コンテンツとする場合、wwwroot/static/ の様な静的フォルダを作成し、その中に index.html ファイルなどを配置することができる。
また、そのファイルをデフォルト遷移先にしたい場合などは、Webアプリ起動時に処理される Program.cs を以下の様に編集すればOK。

Program.cs(冒頭部分)
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 追記部分!!!  DefaultFilesOptions を使用してデフォルトファイル名を変更
var options = new DefaultFilesOptions();
options.DefaultFileNames.Clear();
options.DefaultFileNames.Add("static/index.html"); // 新しいデフォルト名
app.UseDefaultFiles(options);
app.UseStaticFiles();
// 追記ここまで

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{

静的ファイルをただ返すだけでなく、サーバー側で何かしらの処理をして動的にレスポンスを変えたい時は Views/*.cshtml をコーディングする。
ちなみに、こちらのデフォルトを変えたい時についてもProgram.csを編集する。

Program.cs(下の方)
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

// ↓↓↓ ここ
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

※ F5デバックの実行時、ブラウザキャッシュに情報が残ってしまい何度か反映されない事が起きてしまった。そういう時は、Ctrl + F5 でスーパーリロードする。

ここからは、簡易Webアプリの構築を行うが、今回は htmlファイルではなく、cshtmlファイルのみを使う。

構築するWebアプリについて

超ざっくりだが、以下の様な要件で作ってみる。

  • ユーザーはタイトル入力とファイルアップロードをし、それを登録することができる。
  • ユーザーは既に登録されている一覧からタイトルを閲覧し、ファイルをダウンロードできる。
  • タイトル名を絞り込み検索できる。

※ 試しに作ることが目的なので、アプリとしての意味は特になし。

今回はDBへの登録を行う必要があるため、ローカル環境にポスグレのインストールをしておく。

DB準備

まずは pgAdmin等でデータベースに本Webアプリ用のテーブルを作成する。

CREATE TABLE webapp_table (
    id SERIAL PRIMARY KEY, -- PostgreSQLでは、SERIAL 型を使うことで、自動インクリメントが設定される。
    title VARCHAR(255) NOT NULL,
    filepath VARCHAR(500) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW() NOT NULL
);

また、管理者用アカウントを使い操作させるわけにもいかないので、webuserというDBアカウントも作っておき、そのアカウントに C(Insert) R(Select) の権限を与えておく。

GRANT INSERT ON webapp_table TO webuser;
GRANT SELECT ON webapp_table TO webuser;
GRANT USAGE, SELECT, UPDATE ON SEQUENCE webapp_table_id_seq TO webuser;  -- auto increment を使っているため、シーケンス操作をするための権限が必要

確認方法としては、webuserのアカウントで以下のSQLが実行でき、TruncateができなければOK

INSERT INTO webapp_table (title, filepath) VALUES ('Sample Title', '/path/to/file');

ホーム画面から登録画面の画面遷移

まずはホーム画面となる、Index.cshtmlから、以下の様に編集する。

Index.cshtml
@{
    ViewData["Title"] = "お試しWebアプリ ホーム";

}
<!-- コメント箇所は BootStrap使用箇所
    ■全体レイアウ
    container: コンテンツを中央揃えし、画面サイズに応じて幅を調整。    mt-5: 上部に余白を追加。
    text-center: テキストを中央揃えにするクラス。
-->
<div class="container mt-5">
    <h1 class="text-center">Welcome to お試しWebアプリ</h1>
    <p class="text-center">以下のボタンをクリックして、他のページに遷移できます。</p>

    <div class="row">

        <!-- 
            ■カードデザイン
            card: Bootstrapのカードコンポーネント。見出しやテキスト、ボタンを囲むボックスのデザインを提供。
            card-body: カード内の余白や配置を整える部分。
            card-title/card-text: カード内のタイトルやテキストをスタイル付きで表示。
        -->
        <div class="col-md-6">
            <div class="card">
                <div class="card-body">
                    <h5 class="card-title">登録ページ</h5>
                    <p class="card-text">登録ページに遷移します。タイトル入力のファイルのアップロードができます。</p>
                    <a href="/Home/Regist" class="btn btn-primary btn-block">
                        登録ページに移動する
                    </a>
                </div>
            </div>
        </div>
        
    </div>
</div>

上記の状態でレイアウトはできるが、遷移先になる登録画面が存在しないため、以下ファイルの作成および編集が必要になる。

Regist.cshtml(追加作成)
@{
    ViewData["Title"] = "データ登録";
}

<div class="container mt-5">
    <h1>ファイル登録</h1>
    <!-- asp-action="Save" は、 HomeController.cs の Save メソッドを実行している。 --></>
    <form method="post" enctype="multipart/form-data" asp-action="Save">
        <div class="form-group">
            <label for="Title">タイトル</label>
            <input type="text" class="form-control" id="Title" name="Title" required>
        </div>
        <div class="form-group">
            <label for="File">ファイル</label>
            <input type="file" class="form-control" id="File" name="File" required>
        </div>
        <button type="submit" class="btn btn-primary mt-3">登録</button>
    </form>
</div>
HomeController.cs(編集) ※ 登録ボタンの処理はまだなし。
public IActionResult Index()
{
  return View();
}

// 以下が追加部分
public IActionResult Regist()
{
  return View();
}
// 追加部分ここまで

public IActionResult Privacy()
{
  return View();
}

上記まで完成した状態で、F5デバック実行を試してみると下図の様な画面遷移ができる様になっているはず。

ファイルやデータの登録処理

次に登録画面上の登録ボタン実行による処理を作成する。
今回はDB操作が入るため、せっかくなのでModelsも使ってみようと思う。
(この規模だとモデルファイルを使わず、もっとシンプルにコーディングする方法もあるが、せっかくなのでモデルを使ってみる事にする)

以下の流れで処理を作っていく。

  1. ユーザーからのデータ送信にモデルを使ってバリデーションを行い、問題なければファイルをサーバー上に保存
  2. データベースにレコードを追加
    タイトル、ファイルの保存パス、登録日時などのデータをデータベースに挿入します。(Modelsはここで使う)
  3. ユーザーに登録結果を通知

1. ユーザーからのデータ送信にモデルを使ってバリデーションを行い、問題なければファイルをサーバー上に保存

Modelsのファイルはデータベースのエンティティを表す場合もあれば、ビューに表示するデータを扱う場合もある。
今回の作り方は、登録画面で入力したデータをパラメータで受け渡してるというより、あらかじめ型の決まった変数群に値をセットしてあげているイメージ。

まずはModelsの直下に DataOpeModel.csのクラスファイルを作成する。

Models/DataOpeModel.cs
using System;
using Microsoft.AspNetCore.Http;

namespace WebApplication1.Models
{
    public class DataInputModel
    {
        [Display(Name = "タイトルを入力してください:")]
        [Required(ErrorMessage = "タイトルは必須です。")]
        [MaxLength(20, ErrorMessage = "タイトルは20文字以内で入力してください。")]
        public string Title { get; set; }    // 空文字を初期値に設定  //タイトル

        [Required(ErrorMessage = "ファイルを選択しないとアップロードできません。")]
        public IFormFile UploadedFile { get; set; }    // ファイルを受け取るプロパティ
    }
}

モデルを使用した作りに、Regist.cshtmlを修正する。
先程作成した Regist.cshtml は単純な htmlベースのinputの仕方でコーディングしていたが、modelを使ったやり方に修正。

Home/Regist.cshtml
@model WebApplication1.Models.DataInputModel

<h2>データ登録</h2>
<form method="post" enctype="multipart/form-data" asp-action="Regist">
    <div class="form-group">
        <label asp-for="Title"></label>    <!-- モデルのDisplayとリンクしている -->
        <input asp-for="Title" class="form-control" />    <!-- モデルの変数 "Title" とリンクしている -->
        <span asp-validation-for="Title" class="text-danger"></span>    <!-- モデルの変数 "Title" の有効評価とリンクしている -->
    </div>
    <br>
    <div class="form-group">
        <label for="file">ファイルを選択してください:</label>
        <input asp-for="UploadedFile" type="file" class="form-control" />
        <span asp-validation-for="UploadedFile" class="text-danger"></span>
    </div>
    <button type="submit" class="btn btn-primary">登録</button>
</form>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

HomeController.csを修正する。
入力情報に問題がないか確認し、一旦ファイルを保存するところまでの処理をコーディングする。

Controllers/HomeController.cs(変更・追加部分のみ抜粋)
    private readonly ILogger<HomeController> _logger;
    private readonly IWebHostEnvironment _webHostEnvironment;    // 追記ステップ
    
    public HomeController(IWebHostEnvironment webHostEnvironment, ILogger<HomeController> logger)    // 編集 webHostEnvironment を引数に追加
    {
        _webHostEnvironment = webHostEnvironment;    // 追記ステップ
        _logger = logger;
    }

    [HttpGet]    // 追記ステップ
    public IActionResult Regist()
    {
        return View();
    }

    [HttpPost]    // ここの処理は全部追加
    public async Task<IActionResult> Regist(DataInputModel model)
    {
        // モデルバリデーション  入力情報が有効でない場合は、そのままの状態で一旦返す。
        if (!ModelState.IsValid)
        {
            return View(model);
        }
    
        try
        {
            //------------------------------------------
            // ファイル保存処理
            //------------------------------------------
            string uploadsFolder = Path.Combine(_webHostEnvironment.WebRootPath, "uploads");
        
            // 一意なファイル名を生成して保存
            string uniqueFileName = Guid.NewGuid().ToString() + "_" + model.UploadedFile.FileName;
            string filePath = Path.Combine(uploadsFolder, uniqueFileName);
        
            using (var fileStream = new FileStream(filePath, FileMode.Create))
            {
                model.UploadedFile.CopyTo(fileStream);
            }        
        
            return RedirectToAction("Index"); // 成功したらトップページに飛ぶ
        }
        catch (Exception ex)
        {
            ModelState.AddModelError("", "ファイル保存中にエラーが発生しました: " + ex.Message);
            return View(model);
        }
    }

アップロードの先のフォルダを用意する。
wwwroot/uploadsフォルダを作成しておく。

一旦ここまでの処理で、サーバーにアップロードファイルが保存される仕組みまではOK。

2. データベースにレコードを追加

DBへのインプットのカラムは、string title および string filepath になるため、それ用のモデルクラスも用意しておく必要がある。
DataOpeModel.csのクラスファイルに追記。(DataRegistModel クラスを作成)

Models/DataOpeModel.cs
using System;
using Microsoft.AspNetCore.Http;

namespace WebApplication1.Models
{
    public class DataInputModel
    {
        [Display(Name = "タイトルを入力してください:")]
        [Required(ErrorMessage = "タイトルは必須です。")]
        [MaxLength(20, ErrorMessage = "タイトルは20文字以内で入力してください。")]
        public string Title { get; set; }    // 空文字を初期値に設定  //タイトル

        [Required(ErrorMessage = "ファイルを選択しないとアップロードできません。")]
        public IFormFile UploadedFile { get; set; }    // ファイルを受け取るプロパティ
    }

    public class DataRegistModel
    {
        [Key]
        public int id { get; set; }    // DBテーブルに格納する情報になるため、DBテーブルのカラム名と揃える必要がある

        [Required]
        public string title { get; set; }    // DBテーブルに格納する情報になるため、DBテーブルのカラム名と揃える必要がある

        [Required]
        public string filepath { get; set; }    // DBテーブルに格納する情報になるため、DBテーブルのカラム名と揃える必要がある
    }
}

※ ポスグレのテーブルでは IDCreated_atのカラムが存在するが、これらは自動入力のため最初はモデルに含めなかった。しかし、プライマリキーについては値のセットに関わらず必要だったので、上記の様に入れている。

次にデータベース操作を管理するApplicationDbContextを作成する必要があるが、DBへの接続には Microsoft.ENtityFrameworkCoreというのが必要になるらしいので、まずはNugetパッケージの管理から以下をインストールする。


プロジェクト直下にDataフォルダを作成し、さらにその配下にDBコンテキストのファイルを作成する。

ApplicationDbContext .cs
using Microsoft.EntityFrameworkCore;
using WebApplication1.Models;

namespace WebApplication1.Data
{
    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
        {
        }

        public DbSet<DataRegistModel> webapp_table { get; set; }    // ここには実在するポスグレ内のテーブル名を入れる
    }
}

Program.cs にサービスを登録
Program.cs の冒頭に以下の様な処理を追加。

Program.cs
using Microsoft.EntityFrameworkCore;    // 追加ステップ
using WebApplication1.Data;    // 追加ステップ

// PostgreSQL 用に DbContext を登録   追加ステップ
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

また、appsettings.json に接続文字列を追加します。

appsettings.json
"ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=your_database;Username=your_username;Password=your_password"
}

DBへの登録をする処理をHomeController.cs に追記する

Controllers/HomeController.cs(変更・追加部分のみ抜粋)
using WebApplication1.Data;    // 追加ステップ

namespace WebApplication1.Controllers
{
    public class HomeController : Controller
    {
        private readonly ApplicationDbContext _dbContext;    // 追加ステップ

        public HomeController(IWebHostEnvironment webHostEnvironment, ApplicationDbContext dbContext, ILogger<HomeController> logger)    // 引数に dbContext を追加
        {
            _webHostEnvironment = webHostEnvironment;
            _dbContext = dbContext;    // 追加ステップ
            _logger = logger;
        }

        [HttpPost]
        public async Task<IActionResult> Regist(DataInputModel model)
        {
            // モデルバリデーション ~省略~    
            try
            {
                //------------------------------------------
                // ファイル保存処理 ~大部分省略~
                //------------------------------------------
                string filePath = Path.Combine(uploadsFolder, uniqueFileName);
    
                //------------------------------------------
                // DBへの登録処理  追加処理部分!
                //------------------------------------------
    
                // モデルを使用してデータを保存
                var registModel = new DataRegistModel
                {
                    title = model.Title,
                    filepath = filePath,
                };
    
                _dbContext.webapp_table.Add(registModel);
                await _dbContext.SaveChangesAsync();
            
                return RedirectToAction("Index"); // 成功したらトップページに飛ぶ
            }
            catch (Exception ex)
            {
                ModelState.AddModelError("", "ファイル保存中にエラーが発生しました: " + ex.Message);
                return View(model);
            }
        }
    }
}

ここまで完成すると、ファイルの保存 & DBへの登録処理が出来上がる!

登録データの閲覧・ダウンロード処理

最後に登録内容の閲覧およびファイルのダウンロードができる機能を作る。
まず、前提としてDBのテーブル中身は以下の様になっている。

まずはトップ画面から画面遷移するためのボタンを追加。

Index.cshtml
<div class="container mt-5">
    <h1 class="text-center">Welcome to お試しWebアプリ</h1>
    <p class="text-center">以下のボタンをクリックして、他のページに遷移できます。</p>

    <div class="row">

        <div class="col-md-6">
            <div class="card">
                <div class="card-body">
                    <h5 class="card-title">登録ページ</h5>
                    <p class="card-text">登録ページに遷移します。タイトル入力のファイルのアップロードができます。</p>
                    <a href="/Home/Regist" class="btn btn-primary btn-block">
                        登録ページに移動する
                    </a>
                </div>
            </div>
        </div>

        <!-- ここから下が追加部分 -->
        <div class="col-md-6">
            <div class="card">
                <div class="card-body">
                    <h5 class="card-title">閲覧・ダウンロードページ</h5>
                    <p class="card-text">登録データのタイトルを確認し、ファイルのダウンロードができます。</p>
                    <a href="/Home/ViewAndDownload" class="btn btn-success btn-block">
                        閲覧・ダウンロードページに移動する
                    </a>
                </div>
            </div>
        </div>
    </div>
</div>

次に HomeController.csViewAndDownload アクションを追加する。

HomeController.cs(追加部分以外は省略)
    // 登録データの一覧表示
    public async Task<IActionResult> ViewAndDownload()
    {
        // データベースからデータを取得
        var files = await _dbContext.webapp_table
            .OrderByDescending(f => f.id)
            .ToListAsync();
    
        return View(files);
    }

Views/Home/ViewAndDownload.cshtml を新規で作成する。

ViewAndDownload.cshtml
@model IEnumerable<WebApplication1.Models.DataRegistModel>

@{
    ViewData["Title"] = "ファイル一覧";
}

<h2>ファイル一覧</h2>

<!-- 一覧表示 -->
<table class="table table-striped">
    <thead>
        <tr>
            <th>#</th>
            <th>ファイル名</th>
            <th>ダウンロード</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var file in Model)
        {
            <tr>
                <td>@file.id</td>
                <td>@file.title</td>
                <td>
                    <a asp-controller="Home" asp-action="Download" asp-route-id="@file.id" class="btn btn-primary">
                        ダウンロード
                    </a>
                </td>
            </tr>
        }
    </tbody>
</table>

ダウンロードボタン実行時のアクションを作るため、HomeController.csをさらに改修

HomeController.cs(先程の追加部分以外は省略)
     // 登録データの一覧表示
     public async Task<IActionResult> ViewAndDownload()
     {
         // データベースからデータを取得
         var files = await _dbContext.webapp_table
             .OrderByDescending(f => f.id)
             .ToListAsync();
    
         return View(files);
     }
    
     // ファイルダウンロード のイベントハンドラー
     public async Task<IActionResult> Download(int id)
     {
         // データベースから対象ファイルを検索
         var fileRecord = await _dbContext.webapp_table.FindAsync(id);
         if (fileRecord == null)
         {
             return NotFound();
         }
    
         // サーバー上のファイルパス
         var filePath = fileRecord.filepath;
    
         // ファイルをダウンロードとして返す
         var fileBytes = System.IO.File.ReadAllBytes(filePath);
         var fileName = Path.GetFileName(filePath);
         return File(fileBytes, "application/octet-stream", fileName);
     }

ここまでできたら下図の状態になっている。

さらに閲覧画面の方にキーワードによる絞り込み機能を追加

HomeController.csViewAndDownload アクションを書き換え。

HomeController.cs(追加部分以外は省略)
    // 登録データの一覧表示
    [HttpGet]
    public IActionResult ViewAndDownload(string? keyword)
    {
        // 検索条件をクエリに適用
        var files = _dbContext.webapp_table.AsQueryable();
    
        if (!string.IsNullOrWhiteSpace(keyword))
        {
            files = files.Where(f => f.title.Contains(keyword));
        }
    
        // ViewDataで現在のキーワードをビューに渡す
        ViewData["CurrentKeyword"] = keyword;
    
        return View(files.ToList());
    }

閲覧画面のGUIも改修する。

ViewAndDownload.cshtml
@model IEnumerable<WebApplication1.Models.DataRegistModel>

@{
    ViewData["Title"] = "ファイル一覧";
}

<h2>ファイル一覧</h2>
<!-- 絞り込みフォーム 追加部分!!! -->
<form asp-controller="Home" asp-action="ViewAndDownload" method="get">
    <div>
        <label for="searchKeyword">キーワード:</label>
        <input type="text" id="searchKeyword" name="keyword" value="@ViewData["CurrentKeyword"]" />
        <button type="submit">絞り込み</button>
    </div>
</form>

<!-- 一覧表示 -->
<table class="table table-striped">
    <thead>
        <tr>
            <th>#</th>
            <th>ファイル名</th>
            <th>ダウンロード</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var file in Model)
        {
            <tr>
                <td>@file.id</td>
                <td>@file.title</td>
                <td>
                    <a asp-controller="Home" asp-action="Download" asp-route-id="@file.id" class="btn btn-primary">
                        ダウンロード
                    </a>
                </td>
            </tr>
        }
    </tbody>
</table>

閲覧画面上で絞り込み機能を追加するとこんな感じ。

これで一旦目標としていた機能は開発できたので終了

最後に

今回はコントローラーの部分を全てHomeに集約してしまったのだが、データ連携ロジックの部分は別のコントローラーに分離した方が作り方的には良さそうな気がした。
また、モデルの作り方についても、かなりイマイチな作り方をしてしまった感が強い。。。
いずれにせよ、1つ作ってみて考え方・やり方が理解できたので良い勉強になったと思う

4

Discussion

あいや - aiya000あいや - aiya000

ASP.NETを使うのもいいのですが、言語選定の選択肢の話であれば、WindowsならWSL2を使うと選択肢が増えますよ〜!

最近のWSL2はホストのWindowsと連携しやすいので
(例: WSL2で開いたポートにWindowsからアクセスできる・VS CodeのインテグレーションでWSL2環境をWindows環境と差がなく操作できる)
何か事情がなければ、WSL2を使うのがよさそうだと思っています

ログインするとコメントできます