vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【Blazor Server】【ASP.NET Core】CSS isolation と MapControllers

はじめに

この記事は Blazor Advent Calendar 2020 の16日目の記事です。

.NET 5 で追加された CSS Isolation(CSS の分離)を試してみることにしました。

元のプロジェクト

プロジェクト自体は(バージョンは異なりますが)この時のものをベースにしています。

Razor および Blazor は、 Controller クラスから返しています。

HomeController.cs

using System;
using Microsoft.AspNetCore.Mvc;

namespace BlazorSample.Controllers
{
    public class HomeController: Controller
    {
        [Route("")]
        [Route("{page}")] // <- 後述しますがこれだと問題が発生します
        public ActionResult OpenPage(string page)
        {
            ViewData["Title"] = $"Page {page}";
            return View("Views/_Host.cshtml");         
        }
    }
}

_Layout.cshtml

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>@ViewData["Title"]</title>
    <base href="~/" />
</head>
<body>
    @RenderBody()
    <script src="_framework/blazor.server.js"></script>
</body>
</html>

_Host.cshtml

@namespace BlazorSample.Views
@(await Html.RenderComponentAsync<App>(RenderMode.ServerPrerendered))

App.razor

@using Shared
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

MainLayout.razor

@inherits LayoutComponentBase
@Body

DisplayGridPage.razor

@page "/"

<h1>Hello, world!</h1>

Welcome to your new app.
<div id="sheet_area">
</div>

CSS isolation

先のリンクでも説明されていますが、CSS isolation は各 .razor ごとに個別の CSS を作ることができる、というものです。

手順としては CSS ファイルとして {プロジェクト名}.styles.css を読み込むよう設定すること、(先ほどの DisplayGridPage.razor であれば) DisplayGridPage.razor.css という CSS ファイルを用意する、という2点です。

_Layout.cshtml

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>@ViewData["Title"]</title>
    <base href="~/" />
    <!-- Blazorが読み込まれるときに個別の CSS が渡される -->
    <link href="BlazorSample.styles.css" rel="stylesheet" />
</head>
<body>
    @RenderBody()
    <script src="_framework/blazor.server.js"></script>
</body>
</html>
  • ドキュメントなどでは Host.cshtml で設定することになっていますが、 Layout.cshtml がある場合はこちらに書いても問題ありませんでした。

DisplayGridPage.razor.css

#sheet_area
{
    height: 30vh;
    width: 30vw;
    background-color: aqua;
    display: grid;
}
h1{
    color: blue;
}

失敗

これで実行すれば CSS が割当たり・・・・ませんorz

Controller クラスで「localhost:5000」と「localhost:5000/{page}」をルーティングして View を返しているため、「localhost:5000/BlazorSample.styles.css」までルーティングされていたという。。。


※2020-12-21 更新

Start.cs の Configure 内で、「app.UseStaticFiles();」より先に「app.UseRouting();」を実行してしまっていたのが原因でした。

検証までしていただいた @jsakamoto さん、ありがとうございます(..)_

何となくで書いてしまっていたところなので、勉強になりました :)


HomeController.cs

...
namespace BlazorSample.Controllers
{
    public class HomeController: Controller
    {
        [Route("")]
        [Route("Pages/{page}")]
        public ActionResult OpenPage(string page)
...
  • Middlewareで何とかする、という方法もありそうですが、可能であればパスを変えるのがシンプルな気はします。

わざわざ書くほどのことでもないのですが、他にも起こっていたはずの問題がなぜか再現しなかったため、せめてこれだけは残しておくことにします。

生成される CSS

「BlazorSample.styles.css」として渡される CSS は MainLayout.razor.css など親要素がある場合はそれらをマージしたものとなります。

BlazorSample.styles.css()

/* _content/BlazorSample/Views/DisplayGridPage.razor.rz.scp.css */
#sheet_area[b-p832tuedyv]
{
    height: 30vh;
    width: 30vw;
    background-color: aqua;
    display: grid;
}
h1[b-p832tuedyv]{
    color: blue;
}
/* _content/BlazorSample/Views/Shared/MainLayout.razor.rz.scp.css */
h1[b-m6a6nzx0h4]{
    color: red;
}
header[b-m6a6nzx0h4]{
    background-color: rosybrown;
}

PostCSS で Autoprefixer を使ったときのようにベンダープレフィックスをつけたりしてくれるわけではないので、(IE とか IE とか IE とか)必要な場合は(今回の場合) Views に PostCSS から生成した CSS を出力する、というのが良さそうです。

なお、 .razor と .razor.css ファイルは同一階層にある必要があります。

Partial クラス (.razor.cs) と合わせて一か所に置くのが良いですね。

【C#】Play records

はじめに

この記事は C# その2 Advent Calendar 2020 - Qiita の四日目の記事です。

今回は C# 9 から登場した record で遊んでみたいと思います。

Environments

  • .NET ver.5.0.100

変換で使用

まずはおさらい

まずは record とはどんなものか、を簡単にまとめておきます。

下記のように書くことで、初期化の時のみ値をセットできる、init only setters のプロパティが自動生成されます。

Book.cs

public record Book(int Id, string Name);

中身はほぼこれと同じです。

Book.cs

public record Book
{
    public int Id { get; }
    public string Name { get; }

    public Book(int id, string name)
    {
        Id = id;
        Name = name;
    }
}

ほぼ、と書いたのはインスタンスを作るときに若干の違いがあるためです。

Program.cs

using System;

class Program
{
    static void Main(string[] args)
    {
        // 最初の書き方ならOK。後の書き方だと new Book(1, "Programming C# 8.0"); でないとエラー
        var book = new Book(Id: 1, Name: "Programming C# 8.0");
        Console.WriteLine($"Book ID: {book.Id} Name: {book.Name}");
    }
}

また、後述しますが中身は class であるため、継承やメソッドを持つことができます。

この場合は後者の書き方にする必要があります。

ISomething.cs

public interface ISomething
{
    void Message();
}

Book.cs

public record Book: ISomething
{
    public int Id { get; }
    public string Name { get; }

    public Book(int id, string name)
    {
        Id = id;
        Name = name;
    }
    public void Message()
    {
        System.Console.WriteLine("Hello");
    }
}

要注意?なところでは、下記のように書くと(当然といえば当然ですが) init only setter にならず、好きに変更できてしまいます。

Book.cs

public record Book
{
    public int Id { get; set; }
    public string Name { get; set; }
}

開始早々だいぶ脇道に逸れている気はしますが。

比較

record には他にも特徴があり、比較を値で行ってくれます。

ClassSample.cs

public class ClassSample
{
    public int Id { get; init; }
    public string Name { get; init; }

    public ClassSample(int id, string name)
    {
        Id = id;
        Name = name;
    }
}

Program.cs

...
    static void Main(string[] args)
    {
        var book = new Book(1, "Programming C# 8.0");
        var book2 = new Book(1, "Programming C# 8.0");

        var class1 = new ClassSample(1, "Programming C# 8.0");
        var class2 = new ClassSample(1, "Programming C# 8.0");
        // 結果は record: True class: False
        Console.WriteLine($"record: {book == book2} class: {class1 == class2}");
    }
...

With

また元のインスタンスの一部の値を変更する with を使うことができます。

Program.cs

    static void Main(string[] args)
    {
        var book = new Book(1, "Programming C# 8.0");
        var book2 = book with { Name = "Programming C# 5.0" };
        // 結果は book Id: 1 Name: Programming C# 8.0 book2 Id: 1 Name: Programming C# 5.0
        Console.WriteLine($"book Id: {book.Id} Name: {book.Name} book2 Id: {book2.Id} Name: {book2.Name}");
    }

中を見てみる

触ってると中がどうなってるのか気になりますよね~、ということで、VSCodeILSpy先生の力を借りて C# (dll) -> IL -> C# に変換したコードを見てみます。

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;

public class Book : IEquatable<Book>
{
    protected virtual Type EqualityContract
    {
        [CompilerGenerated]
        get
        {
            return typeof(Book);
        }
    }

    public int Id
    {
        get;
        set;
    }

    public string Name
    {
        get;
        set;
    }

    public Book(int Id, string Name)
    {
        this.Id = Id;
        this.Name = Name;
        base..ctor();
    }

    public override string ToString()
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("Book");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(" ");
        }
        stringBuilder.Append("}");
        return stringBuilder.ToString();
    }

    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("Id");
        builder.Append(" = ");
        builder.Append(Id.ToString());
        builder.Append(", ");
        builder.Append("Name");
        builder.Append(" = ");
        builder.Append((object?)Name);
        return true;
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator !=(Book? r1, Book? r2)
    {
        return !(r1 == r2);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator ==(Book? r1, Book? r2)
    {
        if ((object)r1 != r2)
        {
            return r1?.Equals(r2) ?? false;
        }
        return true;
    }

    public override int GetHashCode()
    {
        return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<int>.Default.GetHashCode(Id)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Name);
    }

    public override bool Equals(object? obj)
    {
        return Equals(obj as Book);
    }

    public virtual bool Equals(Book? other)
    {
        if ((object)other != null && EqualityContract == other!.EqualityContract && EqualityComparer<int>.Default.Equals(Id, other!.Id))
        {
            return EqualityComparer<string>.Default.Equals(Name, other!.Name);
        }
        return false;
    }

    public virtual Book <Clone>$()
    {
        return new Book(this);
    }

    protected Book(Book original)
    {
        Id = original.Id;
        Name = original.Name;
    }

    public void Deconstruct(out int Id, out string Name)
    {
        Id = this.Id;
        Name = this.Name;
    }
}

Wao!

一行のコードが実はこんなに、という感じですが、行数的に多いのはやはり比較。

ここをもっと追いかけたいところですが、長くなりそうなので次回以降ということで。

あと地味に ToString() で「Book { Id = 1, Name = Programming C# 8.0 }」のように表示される、というのも見どころかと。 デバッグがはかどりますね。

変換

record の中身は class というのはわかりましたが、例えば Entity Framework Core のモデルクラスの代わりに使ったり、JSONに変換することはできるのかも見てみます。

。。。結論から書くと、難なく変換できました。

Entity Framework Core

この時の記事を元に、モデルクラスのみ record に差し替えてみました。

Book.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

[Table("Books")]
public record Book
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id{ get; init; }
    [Required]
    public string Name { get; init; }
    public Book(int id, string name)
    {
        Id = id;
        Name = name;
    } 
}

JSON も問題なしですね。

MainController.cs

using System;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

public class MainController
{
    private readonly ConsoleSampleContext db;
    public MainController(ConsoleSampleContext db)
    {
        this.db = db;
    }
    public async Task StartAsync()
    {
        var book = await db.Books
            .Where(b => b.Id >= 0)
            .FirstOrDefaultAsync();
        Console.WriteLine($"Book: {book}");
        var jsonText = JsonSerializer.Serialize(book);
        Console.WriteLine($"JSON: {jsonText}");
        var deserializedBook = JsonSerializer.Deserialize<Book>(jsonText);
        Console.WriteLine($"Deserialized: {deserializedBook}");
    }
}

Result

Book: Book { Id = 1, Name = Book: 1999 }
JSON: {"Id":1,"Name":"Book: 1999"}
Deserialized: Book { Id = 1, Name = Book: 1999 }

おわりに

まだ軽く触っただけとはいえ、init only setter や record は必要な場面では結構気軽に使ってしまって良いのでは?という気がしました。

まぁ Database については更新時は値を変更する必要があるため、実際に使えるのは参照専用のデータ(所謂マスターと呼ばれるやつ)とか、普通のクラスとしてデータを取得したあと record に詰めて表示などに利用、といった使い方になるとは思いますが。

明日はjsakamotoさんです。よろしくお願いいたします(..)_

【C#】 SQLite-net (sqlite-net-pcl) を試す

はじめに

この記事は C# Advent Calendar 2020 - Qiita の三日目の記事です。

いつもの諸事情により SQLite を扱うことになりました。

条件はこんな感じ。

  1. .NET, Mono で SQLite を扱うことができる
  2. Unity でも動く
  3. 今も更新されている

条件 2.がなければ EntityFramework Core 一択という気持ちはあるのですが、今回は SQLite-net を使うことにしました。

なお、以下のサンプルは .NET 5 のコンソールアプリですが、Unityでも(インストール以外は)同じように使える。。。はずです。

Environments

  • .NET ver.5.0.100
  • sqlite-net-pcl ver.1.7.335
  • Microsoft.Extensions.DependencyInjection ver.5.0.0

準備

インストール

README に従って、 NuGet が使える環境では NuGet で、Unity のように NuGet が使えない場合は SQLite.cs をプロジェクトに追加します。

DI を追加する

ありがたいことに .NET Core 、 .NET 5 ではこの通り コンソールアプリでも DI が使えるので、「Microsoft.Extensions.DependencyInjection」を NuGet で追加して、 DI を使えるようにします。

Program.cs

using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using SqliteSample.Controllers;

namespace SqliteSample
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var servicesProvider = BuildDi();
            using (servicesProvider as IDisposable)
            {
                var mainController = servicesProvider.GetRequiredService<MainController>();
                await mainController.StartAsync();
            }
        }
        private static IServiceProvider BuildDi()
        {
            var services = new ServiceCollection();
            services.AddTransient<MainController>();
            return services.BuildServiceProvider();
        }
    }
}

Database の生成と接続

sqlite-net-pcl は Syncronous API と Asynchronous API の二つの API を持っており、それぞれ専用のクラスがあります。

今回は Asynchronous API の方を試してみます。(Unity の場合は Syncronous API の方が良いと思いますが)

で、まず Database ファイルが存在しない場合は新規作成、存在する場合はそのまま使うようにしてみます。

DbContext.cs

using System;
using System.IO;
using System.Threading.Tasks;
using SQLite;

namespace SqliteSample.Models
{
    public class DbContext: IDisposable
    {
        private readonly SQLiteAsyncConnection db;
        public DbContext()
        {
            var path = Path.Combine(Directory.GetCurrentDirectory(), "Databases/memory.db");
            // if the database file isn't exist, a new database file will be created.
            this.db = new SQLiteAsyncConnection(path,
                SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex | SQLiteOpenFlags.ReadWrite );
        }
    }
}

参照専用のデータを持つテーブルがある場合、「SQLiteAsyncConnection」実行より前にファイルの有無を調べて、ない場合のみデータ追加、といった処理を行うと良さそうです。

Entity Framework Core と同じように使いたいため、このクラスを DI で依存クラスに挿入できるようにしておきます。

Program.cs

using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using SqliteSample.Controllers;
using SqliteSample.Models;

namespace SqliteSample
{
    class Program
    {
...
        private static IServiceProvider BuildDi()
        {
            var services = new ServiceCollection();
            services.AddTransient<MainController>();
            services.AddScoped<DbContext>();            
            return services.BuildServiceProvider();
        }
    }
}

テーブル生成とマイグレーション

次はテーブルの生成とマイグレーションです。

こちらも先ほどと同じような感じで、テーブルが存在しなければ作成、存在すればそれを使う、という処理を自動で行ってくれます。

なおかつテーブルに変更があった場合はそのマイグレーションも自動で行ってくれます。

Author.cs

using SQLite;

namespace SqliteSample.Models
{
    [Table("author")]
    public class Author
    {
        [PrimaryKey, AutoIncrement]
        [Column("id")]
        [NotNull]
        public int Id{ get; set; }
        [Column("name")]
        [NotNull]
        public string Name { get; set; } = "";
    }
}

Book.cs

using SQLite;

namespace SqliteSample.Models
{
    [Table("book")]
    public class Book
    {
        [PrimaryKey, AutoIncrement]
        [Column("id")]
        [NotNull]
        public int Id{ get; set; }
        [Column("name")]
        [NotNull]
        public string Name { get; set; } = "";
        public int AuthorId{get; set; }
    }
}

なお、「SQLiteAsyncConnection」を使う場合、テーブル生成もすべて非同期となります。

DbContext.cs

    public class DbContext: IDisposable
    {
        private readonly SQLiteAsyncConnection db;
        public DbContext()
        {
...
        }
        public async Task InitAsync()
        {
            await db.CreateTableAsync<Book>();
            await db.CreateTableAsync<Author>();
        }
    }
}

コンストラクタで呼べないので、「Program.cs」から実行することにします。

Program.cs

...
    class Program
    {
        static async Task Main(string[] args)
        {
            var servicesProvider = BuildDi();
            using (servicesProvider as IDisposable)
            {
                var dbContext = servicesProvider.GetRequiredService<DbContext>();
                await dbContext.InitAsync();
                
                var mainController = servicesProvider.GetRequiredService<MainController>();
                await mainController.StartAsync();
            }
        }
...

マイグレーションの注意点

カラムの追加については問題なく行えるのですが、リネーム、削除については注意が必要です。

というのも、リネームすると別カラムとして追加され、1.カラムにデータ追加 -> 2.削除 -> 3.同じ名前でカラム追加 とすると1.のデータが復活します。

ということで、基本的にリネームや削除が必要な場合、中のデータを引き上げて一旦削除 -> 再作成して登録、とする必要がありそうです。

外部キーの設定

結果から言うと、少なくとも .NET 5 環境 (sqlite-net-pcl 環境?)で外部キーを扱うのは難しそうです。

もともと sqlite-net-pcl は該当の機能を持っておらず、「OneToMany」や「ManyToOne」を追加できる「SQLiteNetExtensions」を試したところ、ビルド時にエラーとなりました。

まぁそもそも PostgreSQL などで行うような複雑な構造(いやそれが PostgreSQL なら良いかは別として)を SQLite で持つべきではない気がするので、下手に頑張るよりシンプルさを心がける方が良いかもしれません。

テーブルにアクセスする

EntityFramework Core ぽく扱えるようにするため、 DbContext クラスにもう少し追加します。

DbContext.cs (Full)

using System;
using System.IO;
using System.Threading.Tasks;
using SQLite;

namespace SqliteSample.Models
{
    public class DbContext: IDisposable
    {
        private readonly SQLiteAsyncConnection db;
        public DbContext()
        {
            var path = Path.Combine(Directory.GetCurrentDirectory(), "Databases/memory.db");
            // if the database file isn't exist, a new database file will be created.
            this.db = new SQLiteAsyncConnection(path,
                SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex | SQLiteOpenFlags.ReadWrite );
        }
        public async Task InitAsync()
        {
            await db.CreateTableAsync<Book>();
            await db.CreateTableAsync<Author>();
        }
        public async void Dispose()
        {
            await db.CloseAsync();
        }
        // TransactionやCreate, Update, Delete はこちらを使う
        public SQLiteAsyncConnection Database => db;
        // Readはこちらから
        public AsyncTableQuery<Book> Books => db.Table<Book>();
        public AsyncTableQuery<Author> Authors => db.Table<Author>();
    }
}

。。。ほとんど「SQLiteAsyncConnection」を渡して何とかするみたいになってしまいますが、まぁ雰囲気で。

とにあれデータを追加して検索してみます。

MainController.cs (Full)

using System;
using System.Threading.Tasks;
using SqliteSample.Models;

namespace SqliteSample.Controllers
{
    public class MainController
    {
        private readonly DbContext db;
        public MainController(DbContext db)
        {
            this.db = db;
        }
        public async Task StartAsync()
        {
            await AddSamplesAsync();
            // maybe I can't get the result as IEnumerable<Book>
            foreach(var book in (await db.Books.Where(b => b.Id > 0).ToArrayAsync()))
            {
                Console.WriteLine($"Book ID: {book.Id} Name: {book.Name} AuthorId: {book.AuthorId}");
            }
        }
        private async Task AddSamplesAsync()
        {
            var author = new Author
            {
                Name = "Ian Griffiths"
            };
            await db.Database.RunInTransactionAsync(connection => {
                connection.Insert(author);
                connection.Insert(new Book
                {
                    Name = "Programming C# 8.0",
                    AuthorId = author.Id,
                });
            });
        }
    }
}

おわりに

多少制限はあるものの、想像以上に便利に使えるライブラリが存在してくれていることに感謝。

特に Unity でもクラス一つ追加するだけで使えるというのは本当にありがたい。

あえて難点を挙げるとすればググりにくいことw

ともあれ、今後も状況に合わせて Entity Framework Core と使い分けていきたいと思います。

明日は hiro_matsuno2 さんです。

よろしくお願いいたします(..)_。

【PostgreSQL】【EntityFramework Core】SQL クエリのデバッグ 1

はじめに

作ったアプリにパフォーマンス上の問題が発生している場合、ボトルネックを探索する必要があるわけなんですが、今回は Entity Framework Core の処理を計測してみたいと思います。

Environments

  • .NET ver.5.0.100-rc.1.20452.10
  • Microsoft.EntityFrameworkCore ver.5.0.0-rc.1.20451.13
  • Npgsql.EntityFrameworkCore.PostgreSQL ver.5.0.0-rc1
  • NLog.Web.AspNetCore ver.4.9.3
  • Microsoft.AspNetCore.Mvc.NewtonsoftJson ver.5.0.0-rc.1.20451.17

Base Project

Company.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BookStoreSample.Models
{
    public class Company
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        [Required]
        public string Name { get; set; }
        
        public List<Book> Books { get; set; } = new List<Book>();
    }
}

Genre.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BookStoreSample.Models
{
    public class Genre
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        [Required]
        public string Name { get; set; }
    }
}

Book.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BookStoreSample.Models
{
    public class Book
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        [Required]
        public string Name { get; set; }
        [Column(TypeName = "timestamp with time zone")]
        public DateTime? PublishDate { get; set; }
        [ForeignKey(nameof(Company))]
        public int CompanyId { get; set; }
        [ForeignKey(nameof(Genre))]
        public int GenreId { get; set; }        
        public Company Company { get; set; }
        public Genre Genre { get; set; }
    }
}

BookStoreContext.cs

using Microsoft.EntityFrameworkCore;

namespace BookStoreSample.Models
{
    public class BookStoreContext: DbContext
    {
        public BookStoreContext(DbContextOptions<BookStoreContext> options)
            : base(options)
        {
        }
        public DbSet<Company> Companies => Set<Company>();
        public DbSet<Genre> Genres => Set<Genre>();
        public DbSet<Book> Books => Set<Book>();
    }
}

とりあえずサンプルのデータを生成する

ISampleCreator.cs

using System.Threading.Tasks;

namespace BookStoreSample.Samples
{
    public interface ISampleCreator
    {
        Task CreateAsync();
    }
}

SampleCreator.cs

using System;
using System.Threading.Tasks;
using BookStoreSample.Models;

namespace BookStoreSample.Samples
{
    public class SampleCreator: ISampleCreator
    {
        private readonly BookStoreContext _context;
        public SampleCreator(BookStoreContext context)
        {
            _context = context;
        }
        public async Task CreateAsync()
        {
            using(var transaction = _context.Database.BeginTransaction())
            {
                try
                {
                    for(var i = 0; i < 1000; i++)
                    {
                        _context.Companies.Add(new Company
                        {
                            Name = $"Company: {i}",
                        });
                    }
                    for(var i = 0; i < 1000; i++)
                    {
                        _context.Genres.Add(new Genre
                        {
                            Name = $"Genre: {i}",
                        });
                    }
                    await _context.SaveChangesAsync();
                    var random = new Random();
                    for(var i = 0; i < 1000000; i++)
                    {
                        _context.Books.Add(new Book
                        {
                            Name = $"Book: {i}",
                            PublishDate = DateTime.Now,
                            CompanyId = random.Next(999) + 1,
                            GenreId = random.Next(999) + 1,
                            Price = 600,
                        });
                    }
                    await _context.SaveChangesAsync();
                    transaction.Commit();
                }
                catch(Exception ex)
                {
                    transaction.Rollback();
                    throw ex;
                }
            }
        }
    }
}

HomeController.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using BookStoreSample.Samples;

namespace BookStoreSample.Controllers
{
    public class HomeController: Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly ISampleCreator _sample;
        public HomeController(ILogger<HomeController> logger,
            ISampleCreator sample)
        {
            _logger = logger;
            _sample = sample;
        }
        [Route("Sample")]
        public async Task CreateSamples()
        {
            await _sample.CreateAsync();
        }
    }

}

生成された SQL クエリをログ出力する

EntityFramework Core は内部的に C# のコードから SQL クエリを生成するわけですが、実際どのようなものが生成されているのかが知りたいところ。

そのために「EnableSensitiveDataLogging」を使います。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
...

namespace BookStoreSample
{
    public class Startup
    {
        private readonly IConfiguration configuration;
        public Startup(IConfiguration config)
        {
            configuration = config;
        }
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers()
                .AddNewtonsoftJson(options =>
                    options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore);
            services.AddDbContext<BookStoreContext>(options =>
            {
                options.EnableSensitiveDataLogging();
                options.UseNpgsql(configuration["ConnectionStrings"]);
            });
...
        }
...

この SQL クエリ出力のログレベルは Information なので、 Microsoft のログレベルを Information 以下にしておく必要があります(デフォルトだと Warning )。

appsettings.Development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Information",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  }
}

で、例えばこのようなコードを実行すると。。。

public async Task<List<SearchedCompany>> SearchCompaniesAsync()
{
    return await _context.Companies
        .ToListAsync();
}

こんなログが出力されるわけです。

...
2020-10-06 18:20:17.1528|20101|INFO|Microsoft.EntityFrameworkCore.Database.Command|Executed DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT c."Id", c."Name"
FROM "Companies" AS c |url: http://localhost/Company/Search|action: SearchCompany
...

EXPLAIN, ANALYZE

出力された SQL クエリのパフォーマンスを見たい場合に使えるのが「EXPLAIN」と「ANALYZE」。

EXPLAIN ANALYZE SELECT c."Id", c."Name" FROM "Companies" AS c

SQL クエリの前につけて(例えば) PgAdmin4 で実行してやると下記のような結果が得られます。

実行時間やどのキーを使って検索しているかなどの情報が得られます(今回は触れませんが)。

f:id:mslGt:20201010215559p:plain

実行例

試しにいくつか実行してみます。

Sample 1

SearchedCompany.cs

using BookStoreSample.Models;

namespace BookStoreSample.Books
{
    public class SearchedCompany
    {
        public Company? Company { get; set; }
        public Book? Book { get; set; }
    }
}

BookSearchSample.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BookStoreSample.Models;
using Microsoft.EntityFrameworkCore;

namespace BookStoreSample.Books
{
    public class BookSearchSample: IBookSearchSample
    {
        private readonly BookStoreContext _context;
        public BookSearchSample(BookStoreContext context)
        {
            _context = context;
        }
        public async Task<List<SearchedCompany>> SearchCompaniesAsync()
        {
            return await _context.Companies
                .Include(c => c.Books)
                .Select(c => new SearchedCompany
                {
                    Company = c,
                    Book = c.Books
                        .OrderByDescending(b => b.Id).First(),
                })
                .ToListAsync();
        }
    }
}

Generated SQL

SELECT c."Id", c."Name", t0."Id", t0."CompanyId", t0."GenreId", t0."Name", t0."Price", t0."PublishDate", b0."Id", b0."CompanyId", b0."GenreId", b0."Name", b0."Price", b0."PublishDate"
FROM "Companies" AS c
LEFT JOIN (
    SELECT t."Id", t."CompanyId", t."GenreId", t."Name", t."Price", t."PublishDate"
    FROM (
        SELECT b."Id", b."CompanyId", b."GenreId", b."Name", b."Price", b."PublishDate", ROW_NUMBER() OVER(PARTITION BY b."CompanyId" ORDER BY b."Id" DESC) AS row
        FROM "Books" AS b
    ) AS t
    WHERE t.row <= 1
) AS t0 ON c."Id" = t0."CompanyId"
LEFT JOIN "Books" AS b0 ON c."Id" = b0."CompanyId"
ORDER BY c."Id", t0."Id", b0."Id"

Planning Time

  • 0.942 ms

Execution Time

  • 4941.233 ms

Sample 2

...
    public class SearchedCompany
    {
        public int CompanyId { get; set; }
        public string CompanyName { get; set; } = "";
        public Book? Book { get; set; }
    }
...

BookSearchSample.cs

...
        public async Task<List<SearchedCompany>> SearchCompaniesAsync()
        {
            return await _context.Companies
                .Include(c => c.Books)
                .Select(c => new SearchedCompany
                {
                    CompanyId = c.Id,
                    CompanyName = c.Name,
                    Book = c.Books
                        .OrderByDescending(b => b.Id).First(),
                })
                .ToListAsync();
        }
...

Generated SQL

SELECT c."Id", c."Name", t0."Id", t0."CompanyId", t0."GenreId", t0."Name", t0."Price", t0."PublishDate"
FROM "Companies" AS c
LEFT JOIN (
    SELECT t."Id", t."CompanyId", t."GenreId", t."Name", t."Price", t."PublishDate"
    FROM (
        SELECT b."Id", b."CompanyId", b."GenreId", b."Name", b."Price", b."PublishDate", ROW_NUMBER() OVER(PARTITION BY b."CompanyId" ORDER BY b."Id" DESC) AS row
        FROM "Books" AS b
    ) AS t
    WHERE t.row <= 1
) AS t0 ON c."Id" = t0."CompanyId"

Planning Time

  • 0.341 ms

Execution Time

  • 2209.166 ms

データの渡し方が変わるだけで結構実行時間が変わってくるあたり、面白くも怖くもありますね(パフォーマンスの問題がでなければそこまで気にしなくても良さそうですが)。

Raw SQL クエリの実行

上記の例でいくと、どちらかというとこんな SQL クエリを作りたい。

SELECT c."Id", c."Name" AS "CompanyName", b."Id" AS "BookId", b."Name" AS "BookName", 
b."PublishDate", b."GenreId", b."Price" FROM "Companies" c 
    INNER JOIN LATERAL(SELECT * FROM "Books" innerb
        WHERE innerb."CompanyId" = c."Id" ORDER BY innerb."Id" DESC LIMIT 1) b
    ON c."Id" = b."CompanyId"

EXPLAIN, ANALYZE の実行時間は 350.582 ms で、SQL の実行時間としては一番短くなりました。

ただ、 C# からの生成だと実現がちょっと難しそうです。

となると SQL クエリをそのまま書いて実行してほしくなります。

ASP.NET Framework(Entity Framework 6) なら下記のように書けますが、 Entity Framework Core では存在しません。

using (var context = new BloggingContext())
{
    context.Database.ExecuteSqlCommand(
        "UPDATE dbo.Blogs SET Name = 'Another Name' WHERE BlogId = 1");
}

ではどうするか。

Entity Framework Core で Raw SQL を実行する

Raw SQL を実行するには「DbSet」を通す必要があります。

...
var blogs = context.Blogs
    .FromSqlRaw("EXECUTE dbo.GetMostPopularBlogsForUser {0}", user)
    .ToList();

それは良いのですが、下記のようにテーブルに紐づいていないクラスを扱いたい場合はどうするの?という疑問がわきます。

SELECT c."Id", c."Name" AS "CompanyName", b."Id" AS "BookId", b."Name" AS "BookName", 
b."PublishDate", b."GenreId", b."Price" FROM "Companies" c 
    INNER JOIN LATERAL(SELECT * FROM "Books" innerb
        WHERE innerb."CompanyId" = c."Id" ORDER BY innerb."Id" DESC LIMIT 1) b
    ON c."Id" = b."CompanyId"

SearchedCompany.cs

using System;
using System.ComponentModel.DataAnnotations;

namespace BookStoreSample.Books
{
    public class SearchedCompany
    {
        [Key]
        public int CompanyId { get; set; }
        public string CompanyName { get; set; } = "";
        public int BookId { get; set; }
        public string BookName { get; set; } = "";
        public DateTime? PublishDate { get; set; }
        public int GenreId { get; set; }
        public decimal? Price { get; set; }
    }
}

ずっと「DbSet」はテーブルに紐づくクラスに対してだけ使えるものだと思っていたのですが、実は上記のようなクラスも扱うことができます。

BookStoreContext.cs

using BookStoreSample.Books;
using Microsoft.EntityFrameworkCore;

namespace BookStoreSample.Models
{
    public class BookStoreContext: DbContext
    {
...
        public DbSet<SearchedCompany>  SearchedCompanies => Set<SearchedCompany>();
    }
}

これで、下記のような SQL から値を取得できるようになります。

BookSearchSample.cs

...
        public async Task<List<SearchedCompany>> SearchCompaniesAsync()
        {
            var sql = "SELECT c.\"Id\", c.\"Name\" AS \"CompanyName\", " +
                "b.\"Id\" AS \"BookId\", b.\"Name\" AS \"BookName\", " +
                "b.\"PublishDate\", b.\"GenreId\", b.\"Price\" FROM \"Companies\" c " +
                "INNER JOIN LATERAL(SELECT * FROM \"Books\" innerb " +
                "WHERE innerb.\"CompanyId\" = c.\"Id\" ORDER BY innerb.\"Id\" DESC LIMIT 1) b " +
                "ON c.\"Id\" = b.\"CompanyId\" ";
            return await _context.SearchedCompanies.FromSqlRaw(sql)
                .ToListAsync();
        }
...

重要なポイントとしては、「DbSet」に渡すクラスは「Key」属性か「Id」という名前を持つプロパティが必要で、これがないと実行時に例外が発生します。

Raw SQL を省いた場合は?

先ほどのサンプルから「FromSqlRaw」を省くと、空のデータが返るのでしょうか。

...
    public async Task<List<SearchedCompany>> SearchCompaniesAsync()
    {
        // Don't do this
        return await _context.SearchedCompanies
            .ToListAsync();
    }
...

答えとしては例外が発生します。

2020-10-10 07:04:17.1997|13|ERROR|Microsoft.AspNetCore.Server.Kestrel|Connection id "0HM3CJT1I07AV", Request id "0HM3CJT1I07AV:00000002": An unhandled exception was thrown by the application. Npgsql.PostgresException (0x80004005): 42P01: relation "SearchedCompanies" does not exist
   at Npgsql.NpgsqlConnector.<>c__DisplayClass160_0.<<DoReadMessage>g__ReadMessageLong|0>d.MoveNext()
...

気になるところとしては、下記の2例で例外が発生するかどうかを判別するには、クラスの中身を知っている、または名前くらいしか材料がない、というところ。

この辺良い解決方法はあるものなのでしょうか。

// OK
var companies = await _context.Companies
    .ToListAsync();
// Exception
var searchedCompanies = await _context.SearchedCompanies
    .ToListAsync();

Where 句

当然 Raw SQL クエリに Where ~ を入れても良いわけですが、 SQL インジェクション対策などを考えなければならないわけです。

パラメータを渡す方法などもいくつかあるわけなのですが、「FromSqlRaw」は Linq と併用できるため、あまり複雑な条件でないのであれば下記のように Where 句は C# の世界に戻してしまうのが良さそうです。

BookSearchSample.cs

...
        public async Task<List<SearchedCompany>> SearchCompaniesAsync()
        {
            var sql = "SELECT c.\"Id\", c.\"Name\" AS \"CompanyName\", " +
                "b.\"Id\" AS \"BookId\", b.\"Name\" AS \"BookName\", " +
                "b.\"PublishDate\", b.\"GenreId\", b.\"Price\" FROM \"Companies\" c " +
                "INNER JOIN LATERAL(SELECT * FROM \"Books\" innerb " +
                "WHERE innerb.\"CompanyId\" = c.\"Id\" ORDER BY innerb.\"Id\" DESC LIMIT 1) b " +
                "ON c.\"Id\" = b.\"CompanyId\" ";
            return await _context.SearchedCompanies.FromSqlRaw(sql)
                .Where(c => c.CompanyId == 1)
                .ToListAsync();
        }
...

出力されたログによれば、 Linq の部分もちゃんと SQL に変換されています。

Generated SQL queries

SELECT s."Id", s."BookId", s."BookName", s."CompanyName", s."GenreId", s."Price", s."PublishDate"
FROM (
    SELECT c."Id", c."Name" AS "CompanyName", b."Id" AS "BookId", b."Name" AS "BookName", b."PublishDate", b."GenreId", b."Price" FROM "Companies" c INNER JOIN LATERAL(SELECT * FROM "Books" innerb WHERE innerb."CompanyId" = c."Id" ORDER BY innerb."Id" DESC LIMIT 1) b ON c."Id" = b."CompanyId" 
) AS s
WHERE s."Id" = 1

【ASP.NET Core】【xUnit】【Moq】ユニットテストを追加する 1

はじめに

今回は既存の ASP.NET Core プロジェクトにテストを追加してみます。 ASP.NET Core のテストは以前もやりましたが、今回も xUnit と Moq を使います。

Environments

  • .NET Core ver.3.1.402
  • xUnit ver.2.4.0
  • Moq ver.4.14.5

Base project

元のプロジェクトは「dotnet new empty -n XUnitSample」で作ったものにいくつかクラスを加えています。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Services;

namespace XUnitSample
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddControllers();
            services.AddScoped<IProductService, ProductService>();
        }
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseRouting();
            app.UseStaticFiles();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

Product.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Models
{
    [Table("Product")]
    public class Product
    {
        [Key]
        public int Id { get; set; }
        public string? ModelName { get; set; }
    }
}

HomeController.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Models;
using Services;

namespace Controllers
{
    public class HomeController: Controller
    {
        private readonly IProductService _product;
        public HomeController(IProductService product)
        {
            _product = product;
        }
        [Route("/")]
        public IActionResult Index()
        {
            ViewData["Title"] = "Home";
            return View("Views/Index.cshtml");
        }
        [Route("/Products/All")]
        public async Task<List<Product>> GetAllProducts()
        {
            return await _product.GetProductsAsync();
        }
    }
}

IProductService.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using Models;

namespace Services
{
    public interface IProductService
    {
        Task<List<Product>> GetProductsAsync();
    }
}

ProductService.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using Models;

namespace Services
{
    public class ProductService: IProductService
    {
        public async Task<List<Product>> GetProductsAsync()
        {
            return await Task.FromResult(new List<Product>{
                new Product { Id = 0, ModelName = "SampleModel" }
            });
        }
    }
}

_Layout.cshtml

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>@ViewData["Title"]</title>
</head>
<body>
    @RenderBody()
</body>
</html>

Index.cshtml

<div>Hello World</div>

テストプロジェクトを追加する

まずはテスト用のプロジェクトを追加するわけですが、既存のプロジェクト以下に作ってしまうとエラーが発生します。

f:id:mslGt:20200913095625p:plain

obj\Debug\netcoreapp3.1\.NETCoreApp,Version=v3.1.AssemblyAttributes.cs(4,12): error CS0579: Duplicate 'global::System.Runtime.Versioning.TargetFrameworkAttribute' attribute [C:\Users\example\OneDrive\Documents\workspace\Dotnet31\AspNetCore31Sample\AspNetCore31Sample.csproj]
obj\Debug\netcoreapp3.1\AspNetCore31Sample.AssemblyInfo.cs(13,12): error CS0579: Duplicate 'System.Reflection.AssemblyCompanyAttribute' attribute 
...

ということで、まずソリューション( .sln )を作成し、そこにプロジェクトを追加する必要があります。

dotnet new sln -n XUnit
dotnet sln add XUnitSample

Add xUnit project

さて、それでは「xunit」テンプレートでテスト用のプロジェクトを追加します。

dotnet new xunit -n XUnitSampleTest
dotnet sln add XUnitSampleTest
dotnet add reference ../XUnitSample/XUnitSample.csproj

f:id:mslGt:20200913095703p:plain

Moq も追加します。

XUnitSampleTest.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0"/>
    <PackageReference Include="xunit" Version="2.4.0"/>
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0"/>
    <PackageReference Include="coverlet.collector" Version="1.2.0"/>
    <PackageReference Include="Moq" Version="4.14.5"/>
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\XUnitSample\XUnitSample.csproj"/>
  </ItemGroup>
</Project>

テストクラスを作る

「HomeController」をテストするクラスを追加してみます。

HomeControllerTest.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Models;
using Moq;
using Services;
using Xunit;

namespace Controllers
{
    public class HomeControllerTest
    {
        private readonly HomeController _target;
        public HomeControllerTest()
        {
            var productMock = new Mock<IProductService>();
            productMock.Setup(p => p.GetProductsAsync())
                .ReturnsAsync(new List<Product>
                {
                    new Product
                    {
                        Id = 1,
                        ModelName = "ModelTest"
                    }
                });
            _target = new HomeController(productMock.Object);
        }
        [Fact]
        public async Task GetAllProductsReturnsOneItem()
        {
            Assert.True((await _target.GetAllProducts()).Count == 1);
        }
        [Fact]
        public void IndexReturnsView()
        {
            Assert.IsType<ViewResult>(_target.Index());
        }
        [Fact]
        public void PageTitleIsHome()
        {
            var page = _target.Index();
            var viewResult = Assert.IsType<ViewResult>(page);
            Assert.Equal("Home", viewResult.ViewData["Title"].ToString());
        }
    }
}

dotnet test」コマンドでテスト結果が得られます。

f:id:mslGt:20200913095737p:plain

Visual Studio Code でテストを実行する

dotnet test」コマンドでも問題はないのですが、やっぱり下記のような見た目が欲しいところ。

f:id:mslGt:20200913095803p:plain

ということで、 VSCode拡張機能を追加してみます。
( Visual Studio でやればもっと簡単なのでしょうが、 VSCode が好きなので。。。)

「.NET Core Test Explorer」という名前の拡張機能は二つあるのですが、テスト前にビルドやテストの更新などをしなくてよい、という点でこちらの方が好きですね。

Resources

【.NET Framework】Back to ASP.NET Framework 1

はじめに

これまでの通り、普段プライベートのプログラミングでは .NET Core を使っているわけですが、今日は .NET Framework に挑戦しますよ。

理由はまぁ、お察しください

Environments

Base Project

Empty テンプレートでプロジェクトを作ります。
すると名前に恥じない Empty っぷりで、そのまま実行するとエラーになります(localhost:59522)。 f:id:mslGt:20200902223547j:plain

ルーティング

まずルーティングですが、 「RouteConfig.cs」で定義します。
とその前に、「ASP.NET MVC」を NuGet でインストールしておきます。

ASP.NET Core と同じく、「規則ベースのルーティング」と「属性ベースのルーティング」を使うことができます。
(個人的な)扱いやすさから「属性ベースのルーティング」にします。

RouteConfig.cs

using System.Web.Mvc;
using System.Web.Routing;

namespace NetFrameworkSample
{
    public class RouteConfig
    {
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            // 属性ベースのルーティングを有効にする
            routes.MapMvcAttributeRoutes();
        }
    }
}

RouteConfig を使う

そのままだと有効にならないので、 Global Application Class(asax) を作って RouteConfig を追加します。
(Global Application Class(asax) は Visual Studio から追加できます)

Global.asax

<%@ Application Codebehind="Global.asax.cs" Inherits="NetFrameworkSample.Global" Language="C#" %>

Global.asax.cs

using System;
using System.Web.Mvc;
using System.Web.Routing;

namespace NetFrameworkSample
{
    public class Global : System.Web.HttpApplication
    {

        protected void Application_Start(object sender, EventArgs e)
        {
            AreaRegistration.RegisterAllAreas();
            RouteConfig.RegisterRoutes(RouteTable.Routes);
        }
    }
}

「Global.asax.cs」には何やらたくさんメソッドが作成されてますが、今回は「Application_Start」だけ使います。

Controller クラスを追加する

属性ベースのルーティングも有効になったところで Controller を追加します。

ProductsController.cs

using System.Web.Mvc;

namespace NetFrameworkSample.Controllers
{
    public class ProductsController: Controller
    {
        public ProductsController()
        {
        }
        [Route("")]
        public string GetMessage()
        {
            return "Hello world!";
        }
    }
}

「/」始まり

「Route」でのルート指定ですが、「[Route("/")]」のようにスラッシュから始めると例外が発生します。
ASP.NET Core だと普通に動くためちょっと混乱しました。

ディレクトリの名前

もう一つルートを追加します。

ProductsController.cs

...
        [Route("Products")]
        public string GetProductsMessage()
        {
            return "Hello Products";
        }
    }
}

localhost:59522/Products」を開くともちろん「Hello Products」が表示されるわけです。

で、「Products」という名前でディレクトリを追加し、「IProductService.cs」という interface を追加してみます。
その後もう一度「localhost:59522/Products」を開こうとすると例外(403)が発生。

どうもルートと同じ名前のディレクトリがある場合、 Controller よりそちらを先に探しに行くらしく、当然 View は見つからない、という状況のようです。

ということでディレクトリ名には注意が必要です。
(「localhost:59522/Api/Products」のようなルートにして回避する、という方法もあります。)

回避する方法もありそうですが。。。

ログ出力 (NLog)

NLog を使ってログ出力しますよ。

Install

  • NLog.Web ver.4.9.3

設定

ASP.NET Framework で NLog を使うには、 Web.config に設定を追加します。

Web.config

...
  <system.webServer> 
    <modules runAllManagedModulesForAllRequests="true"> 
      <add name="NLog" type="NLog.Web.NLogHttpModule, NLog.Web" />
    </modules>
  </system.webServer>
</configuration>

そして .NET Core と同じく nlog.config を追加します。

nlog.config

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true">

    <targets>
        <target xsi:type="Console" name="outputconsole"
            layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />

        <target xsi:type="File" name="outputfile" fileName="C:\tmp\logs\NetFrameworkSample\${date:format=yyyy}\${date:format=MM}\${shortdate}.log"
            layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}|url: ${aspnet-request-url}|action: ${aspnet-mvc-action}" />
    </targets>

    <rules>
        <logger name="*" minlevel="Debug" writeTo="outputconsole" />>
        <logger name="Microsoft.*" maxLevel="Info" final="true" />
        <logger name="*" minlevel="Debug" writeTo="outputfile" />
    </rules>
</nlog>

インスタンスを作る

「LogManager.GetCurrentClassLogger()」を使ってインスタンスを作ります。

ProductsController.cs

using System.Web.Mvc;
using NLog;

namespace NetFrameworkSample.Controllers
{
    public class ProductsController: Controller
    {
        private readonly Logger _logger;
        public ProductsController()
        {
            _logger = LogManager.GetCurrentClassLogger();
        }
        [Route("")]
        public string GetMessage()
        {
            _logger.Debug("Hello");
            return "Hello world!";
        }
...

これで下記のようなログが出力されます。

2020-09-02 21:05:58.0307||DEBUG|NetFrameworkSample.Controllers.ProductsController|Hello |url: http://localhost/|action: 

DI (Dependency Injection)

ASP.NET Framework は DI をデフォルトで持っていません。
ということで今回 Unity Container を使ってみます。

Microsoft.Extensions.DependencyInjection」も使えるようなので、こちらも試してみたいと思っています。

Install

  • Unity.Container ver.5.11.8
  • Unity.Mvc ver.5.11.1

依存されるクラスを登録

IProductsService.cs

namespace NetFrameworkSample.Products
{
    public interface IProductsService
    {
    }
}

ProductsService.cs

namespace NetFrameworkSample.Products
{
    public class ProductsService: IProductsService
    {
    }
}

UnityConfig.cs

using System;
using Unity;
using NetFrameworkSample.Products;

namespace NetFrameworkSample
{
    /// <summary>
    /// Specifies the Unity configuration for the main container.
    /// </summary>
    public static class UnityConfig
    {
        #region Unity Container
        private static Lazy<IUnityContainer> container =
          new Lazy<IUnityContainer>(() =>
          {
              var container = new UnityContainer();
              RegisterTypes(container);
              
              container.RegisterType<IProductsService, ProductsService>(TypeLifetime.Scoped);
              
              return container;
          });
...

依存クラスを挿入する

Microsoft.Extensions.DependencyInjection」と同様、 Unity Container もコンストラクタインジェクションで依存性を解決します。

ProductsController.cs

using System.Web.Mvc;
using NetFrameworkSample.Products;

namespace NetFrameworkSample.Controllers
{
    public class ProductsController: Controller
    {
        private readonly IProductsService _product;
        public ProductsController(IProductsService product)
        {
            _product = product;
        }
...

インスタンスを登録する

DbContext のように、特定のインスタンスを渡したい場合は「RegisterInstance」を使います。

var sample = new ProductsService();
container.RegisterInstance<IProductsService>(sample, InstanceLifetime.PerContainer);              

NLog のインスタンスも渡すことができるのですが、使うべきではないようです。
というのも、ログに出力した時の「logger」のクラス名が全部「UnityConfig」になってしまうので。。。

Type(Instance) lifetime

Unity Container は「Microsoft.Extensions.DependencyInjection」よりきめ細やかに lifetime の設定ができるようです。

この使い分けも次回以降でもう少し追いかけたいところ。

具象クラスのインスタンスを挿入する

実は RegisterType や RegisterInstance で登録しなくても、クラスインスタンスの挿入をすることができます。

ですが、 Lifetime は Transient であるため、 DbContext(Scoped) のように Transient 以外に設定する必要のあるクラスはやっぱり明示的に登録が必要となります。

おわりに

状況的に仕方がないとはいえ、 .NET Framework でのやり方が非常にググりづらくてツラいですね。。。

【ASP.NET Core】【Blazor Server】SPA を試す

はじめに

今回は Blazor を使って Single Page Application を作ってみますよ。

ということでルーティングやコンポーネントの中にコンポーネントを作る、といったところを試します。

ルーティング

まずはルーティングから。

localhost:5000/」と「localhost:5000/{ページ名}」にアクセスしたときに、 Blazor のページが表示されるようにしてみます。

HomeController.cs

...
        [Route("/")]
        [Route("/{page}")]
        public ActionResult OpenPage(string page)
        {
            return View("Views/_Host.cshtml");            
        }
...

_Host.cshtml は Blazor のルーターを呼んでるだけです。

_Host.cshtml

@using BlazorSample.Views;
@(await Html.RenderComponentAsync<App>(RenderMode.ServerPrerendered))

App.razor

@using BlazorSample.Views.Shared;
<Router AppAssembly="@typeof(Startup).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <p>Sorry, there's nothing at this address.</p>
    </NotFound>
</Router>

MainLayout.razor は Blazor の共通レイアウトです。 Razor でいうところの _Layout.cshtml ですね。

MainLayout.razor

@inherits LayoutComponentBase
@Body

SearchPage.razor

@page "/SearchPage";

<input type="text" @bind="productName">
<button @onclick="UpdateValue">Update</button>
@code{
    public string productName = "";
    
    public async Task UpdateValue()
    {
        productName = "Hello World!";
    }
}

Blazor では、「@page」で表示する画面のパスが決まります。
なので上記の SearchPage は 「localhost:5000/SearchPage」で表示できます。

_Layout.cshtml

現状 SearchPage の構造はこんな感じになってます。 f:id:mslGt:20200821195935p:plain

ただ、他に Razor のページがないのであれば、「ViewStart.cshtml」を削除して、「Layout.cshtml」の中身を「_Host.cshtml」にまとめてしまうこともできます。

_Host.cshtml

@using BlazorSample.Views;

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>@ViewData["Title"]</title>
        <base href="/">
        <script type="text/javascript">
            if (/MSIE \d|Trident.*rv:/.test(navigator.userAgent)) {
                document.write('<script src="https://polyfill.io/v3/polyfill.min.js?features=Element.prototype.closest%2CIntersectionObserver%2Cdocument.querySelector%2Cfeatures=Array.prototype.forEach%2CNodeList.prototype.forEach"><\/script>');
                document.write('<script src="js/blazor.polyfill.min.js"><\/script>');
            }
        </script>
    </head>
    <body>        
        <div>Hello Host</div>
        @(await Html.RenderComponentAsync<App>(RenderMode.ServerPrerendered))
        <script src="_framework/blazor.server.js"></script>
    </body>
</html>

共通のレイアウト、ということでは「MainLayout.razor」に書くこともできるのですが、(少なくともデフォルトでは) Blazor ファイルに <script> を書くことができないため、 JavaScript については「Layout.cshtml」や「Host.csthml」に書く必要があります。

ViewData

「ViewData」や「ViewBag」も Blazor から扱うことができないため、 ViewData["Title"] は Controller クラスや Razor で設定する必要があります。

HomeController.cs

...
        [Route("/")]
        [Route("/{page}")]
        public ActionResult OpenPage(string page)
        {
            ViewData["Title"] = GetTitle(page);
            return View("Views/_Host.cshtml");            
        }
        private string GetTitle(string page)
        {
            switch(page)
            {
                case "SearchPage":
                    return "Search";
                default:
                    return "Home";
            }
        }
...

ルーターからコンポーネントにデータを渡す

前回 Razor から Blazor コンポーネントにデータを渡すのに「[Parameter]」を使っていました。

ではルーターは?と思ったら、 Razor と Blazor でのデータのやり取りと同じ方法で渡すことができました。

App.razor

@using BlazorSample.Views.Shared;
<Router AppAssembly="@typeof(Startup).Assembly">
    <Found Context="routeData">
        @{
            var values = routeData.RouteValues as Dictionary<string, object> ?? new Dictionary<string, object>() ;
            values.Add("Name", "Hello World");
            
            var newRouteData = new RouteData(routeData.PageType, values);
        }
        <RouteView RouteData="@newRouteData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
...
</Router>

上記のような感じで、ページが見つからない場合にデフォルトのページを表示する、ということもできます。

@using BlazorSample.Views.Shared;
<Router AppAssembly="@typeof(Startup).Assembly">
...
    <NotFound>
        @{
            var routeData = new RouteData(typeof(SearchPage), new Dictionary<string, object>());
        }
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </NotFound>
</Router>

Blazor コンポーネントの中に Blazor コンポーネントを追加する

Angular ではコンポーネントの中に子供のコンポーネントを持たせることができます。 f:id:mslGt:20200821200018p:plain

では Blazor ではどうかというと、ほぼ同じ感じで扱うことができます。

SearchPage.razor

...
@foreach (var item in Products)
{
    <SearchResultRow Product=item></SearchResultRow>
}
...

SearchResultRow.razor

@using Models;
<div class="search_result_row">
    <div class="search_result_row_id">@Product.Id</div>
    <div class="search_result_row_name">@Product.Name</div>
</div>
@code{
    [Parameter]
    public Product Product {get; set; }
}

Blazor(HTML + C#) から C# のコードを分割する

小さいコンポーネントならこれまでと同じく HTML と C# が同じファイルにあっても問題ないのですが、複雑かつ大きくなってくると、両者を分けたくなってきます。

これを partial class を使うことで実現できます。

SearchPage.razor.cs

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Models;

namespace BlazorSample.Views
{
    public partial class SearchPage
    {
        [Inject]
        public Services.IBlazorService Blazor{ get; set; }
        [Parameter]
        public string Name {get; set;}
        public string productName = "";
        public List<Product> Products = new List<Product>
        {
            new Product
            {
                Id = 0,
                Name = "Hello",
            },
            new Product
            {
                Id = 1,
                Name = "World",
            },
        };
        public async Task UpdateValue()
        {
            var product = await Blazor.GetMessageAsync("Hello");
            Console.WriteLine(productName);
            Console.WriteLine(Name);
            productName = product.Name;
        }
    }
}

これで SearchPage.razor から C# のコードを取り除くことができます。

SearchPage.razor

@page "/SearchPage";

<input type="text" @bind="productName">
<button @onclick="UpdateValue">Update</button>
@foreach (var item in Products)
{
    <SearchResultRow Product=item></SearchResultRow>
}

一つ重要なのが、両者の namespace を同じにする、ということがあります。
(SearchPage.razor.cs の namespace を Views にしていたためコンパイルエラーになり、ちょっとハマりました)