vaguely

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

【Blazor Server】【ASP.NET Core Identity】カスタムユーザーでサインインしたい

はじめに

今回はいつぞやに試した、 ASP.NET Core Identity のカスタムユーザー(プロパティから電話番号などを外すとか)を使って、Blazor Server のページからサインインしてみることにします。

バージョンが古いので今と違っているところもあるかと思いますが、その辺りは GitHub のサンプルを参照して、ということで。。。

Environments

  • .NET Core ver.5.0.102

Samples

過去記事

サインイン

SignInManager でサインイン(失敗)

以前試した通り Blazor では DI が使えるので、まずは SignInManager を使ってサインインしてみることにしました。

ApplicationUserService.cs

...
    public async Task<bool> SignInAsync(string email, string password)
    {
        var target = await applicationUsers.GetByEmailAsync(email);
        if (target == null)
        {
            return false;
        }
        var result = await signInManager.PasswordSignInAsync(target, password, false, false);
        return result.Succeeded;
    }
...

SignIn.razor

@page "/Pages/SignIn"
<div id="background">
    <div id="sign_in_frame">
        <h1>Sign In</h1>
        <div class="sign_in_input_container">
            <input type="text" @bind="Email" class="sign_in_input @AdditionalClassName">
        </div>
        <div class="sign_in_input_container">
            <input type="password" @bind="Password" class="sign_in_input @AdditionalClassName">
        </div>
        <div id="sign_in_controller_container">
            <button @onclick="StartSigningIn">Sign In</button>
        </div>
    </div>
</div>

SignIn.razor.cs

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ApprovementWorkflowSample.Applications;

namespace ApprovementWorkflowSample.Views
{
    public partial class SignIn
    {
        [Inject]
        public IJSRuntime? JSRuntime { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }
        [Inject]
        public IApplicationUserService? ApplicationUsers{get; init; }

        [Parameter]
        public string Email { get; set; } = "";
        [Parameter]
        public string Password { get; set; } = "";
        [Parameter]
        public string AdditionalClassName { get; set; } = "";

        public async Task StartSigningIn()
        {
            if(string.IsNullOrEmpty(Email) ||
                string.IsNullOrEmpty(Password))
            {
                await HandleSigningInFailedAsync("Email と Password は入力必須です");
                return;
            }
            var result = await ApplicationUsers!.SignInAsync(Email, Password);
            if(result)
            {
                // サインインに成功したら次のページへ
                Navigation!.NavigateTo("/Pages/Edit");
                return;
            }
            AdditionalClassName = "login_failed";
            await JSRuntime!.InvokeAsync<object>("Page.showAlert","Email か Password が違います");
        }
        private async Task HandleSigningInFailedAsync(string errorMessage)
        {
            AdditionalClassName = "login_failed";
            await JSRuntime!.InvokeAsync<object>("Page.showAlert", errorMessage);   
        }
    }
}

実行してみると、「SignInAsync」を呼ぶところで例外が発生しました。

Unhandled exception rendering component: Headers are read-only, response has already started. System.InvalidOperationException: Headers are read-only, 
response has already started.
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpHeaders.ThrowHeadersReadOnlyException()
...

この方法はダメなようです。

Controller からサインイン(失敗)

JavaScript などからサインインするのと同じように、 Controller を通してサインインしてみることにしました。

UserController.cs

...
        [HttpPost]
        [Route("Users/SignIn")]
        public async ValueTask<bool> SignIn([FromBody]SignInValue value)
        {
            if(string.IsNullOrEmpty(value.Email) ||
                string.IsNullOrEmpty(value.Password))
            {
                return false;
            }
            return await users.SignInAsync(value.Email, value.Password);
        }
...

SignInValue.cs

namespace ApprovementWorkflowSample.Applications.Dto
{
    public record SignInValue(string Email, string Password);
}

SignIn.razor.cs

using System.IO;
using System.Text;
using System.Net.Http;
using System.Threading.Tasks;
using ApprovementWorkflowSample.Applications.Dto;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.Extensions.Configuration;
using ApprovementWorkflowSample.Applications;
using Newtonsoft.Json;

namespace ApprovementWorkflowSample.Views
{
    public partial class SignIn
    {
        [Inject]
        public IJSRuntime? JSRuntime { get; init; }
        [Inject]
        public IHttpClientFactory? HttpClients { get; init; }
        [Inject]
        public IConfiguration? Configuration { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }     
...
        public async Task StartSigningIn()
        {
...
            var httpClient = HttpClients.CreateClient();
            var signInValue = new SignInValue(Email, Password);
            var context = new StringContent(JsonConvert.SerializeObject(signInValue), Encoding.UTF8, "application/json");
            var response = await httpClient.PostAsync(Path.Combine(Configuration!["BaseUrl"], "Users/SignIn"), context);
            if(response.IsSuccessStatusCode == false)
            {
                await HandleSigningInFailedAsync("アクセス失敗");
                return;
            }
            string resultText = await response.Content.ReadAsStringAsync();
            bool.TryParse(resultText, out var result);
            if(result)
            {
                Navigation!.NavigateTo("/Pages/Edit");
                return;
            }
            AdditionalClassName = "login_failed";
            await HandleSigningInFailedAsync("Email か Password が違います");
        }
...

例外は発生せず、戻り値としては「true」が得られるのですが、認証済みとは扱われませんでした。

EditWorkflow.razor

@page "/Pages/Edit"
@attribute [Authorize]

<CascadingAuthenticationState>
    <AuthorizeView>
        <Authorized>
            <h1>Hello, @context.User.Identity!.Name!</h1>
            <p>You can only see this content if you're authorized.</p>
        </Authorized>
        <NotAuthorized>
            <h1>Authentication Failure!</h1>
            <p>You're not signed in.</p>
        </NotAuthorized>
    </AuthorizeView>
</CascadingAuthenticationState>

認証処理が完了した後も、「NotAuthorized」の要素が表示されます。

どうもこれは、 Controller を通して認証するのは、 HTTP を使うのに対し、 Blazor Server アプリケーションとサーバーとの接続には SignalR を使っていることが原因のようです。

ClaimsPrincipal 、ClaimsIdentity 、AuthenticationState を使う(OK)

先ほどの記事を参考に、「IHostEnvironmentAuthenticationStateProvider」の追加と「SignIn.razor.cs」の変更を試してみることにします。

Startup.cs

...
        public void ConfigureServices(IServiceCollection services)
        {
...
            services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(sp =>
                (ServerAuthenticationStateProvider) sp.GetRequiredService<AuthenticationStateProvider>()
            );
...
        }
...

SignIn.razor.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ApprovementWorkflowSample.Applications;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authentication.Cookies;

namespace ApprovementWorkflowSample.Views
{
    public partial class SignIn
    {
        [Inject]
        public IJSRuntime? JSRuntime { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }
        [Inject]
        public IApplicationUserService? ApplicationUsers{get; init; }
        [Inject]
        public SignInManager<ApplicationUser>? SignInManager { get; init; }
        [Inject]
        public IHostEnvironmentAuthenticationStateProvider? HostAuthentication { get; init; }
        [Inject]
        public AuthenticationStateProvider? AuthenticationStateProvider{get; init; }
...
        public async Task StartSigningIn()
        {
...
            ApplicationUser? user = await ApplicationUsers!.GetUserByEmailAsync(Email);
            if(user == null)
            {
                await HandleSigningInFailedAsync("Email か Password が違います");
                return;
            }
            SignInResult loginResult = await SignInManager!.CheckPasswordSignInAsync(user, Password, false);
            if(loginResult.Succeeded == false)
            {
                await HandleSigningInFailedAsync("Email か Password が違います");
                return;
            }
            if(loginResult.Succeeded)
            {
                ClaimsPrincipal principal = await SignInManager.CreateUserPrincipalAsync(user);
                ClaimsIdentity identity = new ClaimsIdentity(principal.Claims,
                        CookieAuthenticationDefaults.AuthenticationScheme);
                SignInManager.Context.User = principal;
                HostAuthentication!.SetAuthenticationState(
                    Task.FromResult(new AuthenticationState(principal)));

                AuthenticationState authState = await AuthenticationStateProvider!.GetAuthenticationStateAsync();
            
                Navigation!.NavigateTo("/Pages/Edit");
            }
        }
...

ようやくサインインと、サインイン完了後に「認証済み」として扱われるようになりました。 f:id:mslGt:20210207230745j:plain

未認証ユーザーの自動リダイレクト

ASP.NET Core では、未認証ユーザーが認証が必要なルートにアクセスした際、自動でログインページに遷移させる仕組みがあります。

では Blazor は?

デフォルトで設定してくれる仕組みは見つけられなかったため、下記の記事を参考にリダイレクトさせることにしました。

App.razor

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

RedirectToSignIn.razor.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;

namespace ApprovementWorkflowSample.Views
{
    public partial class RedirectToSignIn
    {
        [CascadingParameter]
        private Task<AuthenticationState>? AuthenticationStateTask { get; init; }
        [Inject]
        public NavigationManager? Navigation { get; init; }
        protected override async Task OnInitializedAsync()
        {
            var authenticationState = await AuthenticationStateTask!;

            if (authenticationState?.User?.Identity is null || !authenticationState.User.Identity.IsAuthenticated)
            {
                var returnUrl = Navigation!.ToBaseRelativePath(Navigation.Uri);
                if (string.IsNullOrWhiteSpace(returnUrl))
                {
                    Navigation.NavigateTo("Pages/SignIn", true);
                }
                else
                {
                    Navigation.NavigateTo($"Pages/SignIn?returnUrl={returnUrl}", true);
                }
            }
        }
    }
}
  • 今回端折ってますが、 SignIn.razor で「returnUrl」を受け取る仕組みは必要です

参考

【Blazor Server】ここんぽーねんとで遊びたい

はじめに

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

今回は子コンポーネントを追加してみることにします。 環境、ベースとなるプロジェクトは下記を参照してください。

コンポーネントを追加する

コンポーネントを追加する、といっても特別な何かがあるわけではなく、ただ .razor ファイル(必要に応じて + .razor.cs 、.razor.css)を作成し、HTMLタグのように親コンポーネントに追加するだけです。

Cell.razor

<div class="cell_frame">Hello cell</div>

Cell.razor.cs

using Microsoft.AspNetCore.Components;

namespace BlazorSample.Views.Components
{
    public partial class Cell
    {
    }
}

Cell.razor.css

.cell_frame{
    background-color: gray;
    border: 1px solid black;
    color: goldenrod;
}

DisplayGridPage.razor

@page "/"

<h1>Hello, world!</h1>
Welcome to your new app.
<div id="sheet_area">
    @for(var i = 1; i <= 3; i++){
        <BlazorSample.Views.Components.Cell></BlazorSample.Views.Components.Cell>
    }
</div>

結果

f:id:mslGt:20201222060717j:plain

コンポーネントCSS

DisplayGridPage.razor を表示したとき、BlazorSample.styles.css の中身は親?となる MainLayout.razor.css と DisplayGridPage.razor.css がマージされたものとなっていましたが、子どもとなる Cell.razor.css はどうでしょうか。

結果としてはそれもマージされます。

BlazorSample.styles.css

/* _content/BlazorSample/Views/Components/Cell.razor.rz.scp.css */
.cell_frame[b-64je09gotq]{
    background-color: gray;
    border: 1px solid black;
    color: goldenrod;
}
/* _content/BlazorSample/Views/Components/SheetSelector.razor.rz.scp.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;
}

データのやりとり

Parameter

親から子にデータを渡す場合は Parameter を使います。

Cell.razor.cs

using System;
using Microsoft.AspNetCore.Components;

namespace BlazorSample.Views.Components
{
    public partial class Cell
    {
        [Parameter]
        public int Index { get; set; }
        [Parameter]
        public BlazorSample.Spreadsheets.Cell CellValue{ get; set; }
    }
}

Cell.razor

<div class="cell_frame" id="cell_@Index">Hello cell@(Index)</div>

DisplayGridPage.razor.cs

...
    public partial class DisplayGridPage
    {
        [Parameter]
        public List<BlazorSample.Spreadsheets.Cell> Cells { get; set; }

        public DisplayGridPage()
        {
            Cells = new List<Cell>
            {
                new Cell(1, 1, "Hello"),
                new Cell(1, 2, "World"),
            };
        }
...

DisplayGridPage.razor

...
<div id="sheet_area">
    @code
    {
        int index = 1;
    }
    @foreach (var cell in Cells)
    {
        <BlazorSample.Views.Components.Cell Index="index" CellValue="cell">
        </BlazorSample.Views.Components.Cell>
        index += 1;
    }
</div>
...

いつデータがセットされるか

コンストラクタが実行された時点では Parameter に値がセットされていません。

初期化時点、かつ値がセットされたタイミングで処理を行いたい場合は「OnInitialized()」などを使います。

Cell.razor.cs

...
    public partial class Cell
    {
        [Parameter]
        public int Index { get; set; }
        [Parameter]
        public BlazorSample.Spreadsheets.Cell CellValue{ get; set; }
        public Cell()
        {
            // ここではまだ値がセットされていない.
            // Output: [Constructor] CellValue Index: 0 Cell?:True
            Console.WriteLine($"[Constructor] CellValue Index: {Index} Cell?:{CellValue == null}" );
        }
        protected override void OnInitialized()
        {
            // Output: [Init] CellValue Index: 1 Cell?:False
            Console.WriteLine($"[Init] CellValue Index: {Index} Cell?:{CellValue == null}" );
        }
    }
...

こう見ると(影響を受けているかどうかはともかく) Angular を想起させます。

細かい部分は当然異なるわけですが、 Angular を触ったことのある方であれば、Blazor に慣れてなくとも何となくどんな風に書くかが想像できるのでは、という気がしました。

二回ロードされる?

値セットのタイミングを確認しているときに気づいたのが、ページを開く際、初期化処理が二回動いていることです。

どうやら _Host.cshtml で Blazor を読み込むときに「RenderMode.ServerPrerendered」にしていると、 App.razor が二回初期化されるため以降の処理も二回ずつ実行される、と。

_Host.cshtml

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

「RenderMode.Server」に変更すると一度しか呼ばれないため、コンポーネントのプリレンダーが関連している、ということになります。

普段はあまり気にしなくても。。。という気はしますが、ページロード時に DB をいじるとか?の場合は注意が必要そうです。

状態管理

子から親に何らかの状態が変わったことを伝える方法はいくつかあります。

EventCallback

Parameter として EventCallback を設定しておくことで、イベントの発火を伝えることができます。

Cell.razor.cs

...
    public partial class Cell
    {
...
        [Parameter]
        public EventCallback<string> OnCellClicked { get; set; }
        public async Task OnClick()
        {
            await OnCellClicked.InvokeAsync();
        }
    }
...

DisplayGridPage.razor

...
    @foreach (var cell in Cells)
    {
        <BlazorSample.Views.Components.Cell Index="index" CellValue="cell"
            OnCellClicked="@((message) => Console.WriteLine(message))">
        </BlazorSample.Views.Components.Cell>
        index += 1;
    }
...

data-binding

親の持つインスタンスをバインドさせておくことで、子コンポーネント側で値が変更されたときに自動的に反映されるようになります。

Cell.razor.cs

...
    public partial class Cell
    {
...
        [Parameter]
        public BlazorSample.Spreadsheets.BindSample Sample{ get; set; }
        [Parameter]
        public EventCallback<BlazorSample.Spreadsheets.BindSample> SampleChanged { get; set; }
        public void OnClick()
        {
            Sample.Name = $"{Sample.Name} {Index}";
        }
    }
...

DisplayGridPage.razor

...
    <BlazorSample.Views.Components.Cell Index="10" CellValue='new BlazorSample.Spreadsheets.Cell(10, 10, "HelloWorld")'
        OnCellClicked='_ => Console.WriteLine("World")' @bind-Sample="sample">
    </BlazorSample.Views.Components.Cell>
...
<button @onclick="ClickSample">Click</button>
@code
{
    BlazorSample.Spreadsheets.BindSample sample = new BlazorSample.Spreadsheets.BindSample
    {
        Name = "Hello bind sample",
    };

    void ClickSample()
    {
        Console.WriteLine($"BindSample {sample.Name}");
    }
}

条件は「{バインドさせるParameter名}Changed」という EventCallback を作っておくことです(無いとエラーになります)。

Rx で別ルート

ここまでは 親 -> 子 -> 孫 というコンポーネント構造があった場合、 孫 -> 子 、 子 -> 親 と一つずつ状態の変化を伝える、というものでした。 f:id:mslGt:20201222060810p:plain

今度は Angular における NgRX よろしく、状態の管理を別クラスにさせてみることにします。

※雰囲気だけまねているので NgRX の正しい動作・仕様はNgRXのドキュメントなどをご確認ください f:id:mslGt:20201222060926p:plain

WorkSheet.cs

using System.Collections.Generic;
namespace BlazorSample.Spreadsheets
{
    public class WorkSheet
    {
        public string Name { get; init; }
        public List<Cell> Cells { get; init; } = new List<Cell>();
    }
}

WorkSheetStore.cs

using System;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using BlazorSample.Spreadsheets;

namespace BlazorSample.Views.States
{
    public class WorkSheetStore
    {
        public Subject<WorkSheet> Subject { get; } = new();
        public IObservable<WorkSheet> Sheets => Subject.AsObservable();
    }
}

WorkSheetReducer.cs

using System;

namespace BlazorSample.Views.States
{
    public class WorkSheetReducer
    {
        private readonly WorkSheetStore store;
        public WorkSheetReducer(WorkSheetStore store)
        {
            this.store = store;
        }
        public void ChangeSheet(Spreadsheets.WorkSheet value)
        {
            store.Subject.OnNext(value);
        }
    }
}

WorkSheetEffect.cs

using System.Threading.Tasks;
namespace BlazorSample.Views.States
{
    public class WorkSheetEffect
    {
        private readonly WorkSheetReducer reducer;
        public WorkSheetEffect(WorkSheetReducer reducer)
        {
            this.reducer = reducer;
        }
        public async Task LoadSheetAsync(string sheetName)
        {
            // サーバーサイドからデータを取ってきて・更新して Reducer に反映
            await Task.Run(() => reducer.ChangeSheet(new Spreadsheets.WorkSheet
            {
                Name = sheetName,
            }));
        }
    }
}

WorkSheetAction.cs

using System.Threading.Tasks;

namespace BlazorSample.Views.States
{
    public class WorkSheetAction
    {
        private readonly WorkSheetReducer reducer;
        private readonly WorkSheetEffect effect;
        public WorkSheetAction(WorkSheetReducer reducer,
            WorkSheetEffect effect)
        {
            this.reducer = reducer;
            this.effect = effect;
        }
        public void ChangeSheet(BlazorSample.Spreadsheets.WorkSheet value)
        {
            reducer.ChangeSheet(value);
        }
        public async Task LoadSheetAsync(string sheetName)
        {
            await effect.LoadSheetAsync(sheetName);
        }
    }
}

で、状態を更新するコンポーネントから WorkSheetAction を呼び出し、状態の変化を受け取りたいコンポーネントで WorkSheetStore を Subscribe する、と。

Start.cs

...
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddControllers();

            services.AddScoped<WorkSheetStore>();
            services.AddScoped<WorkSheetAction>();
            services.AddScoped<WorkSheetReducer>();
            services.AddScoped<WorkSheetEffect>();
        }
...

Cell.razor.cs

...
namespace BlazorSample.Views.Components
{
    public partial class Cell
    {
        [Inject]
        public WorkSheetAction Action{ get; set; }
....
        public void OnClick()
        {
            Action.ChangeSheet(new Spreadsheets.WorkSheet
            {
                Name = "New Sheet Name",
            });
        }
...

DisplayGridPage.razor.cs

...
namespace BlazorSample.Views
{
    public partial class DisplayGridPage
    {
        [Inject]
        public WorkSheetStore Store { get; set; }
...
        protected override void OnInitialized()
        {
            Store.Sheets.Subscribe(sheet => Console.WriteLine($"New sheet {sheet.Name}"));
        }
...

とりあえずものまねしてやってみたものの、ボタンクリックなど、ちょっとした状態の変化を取るには明らかにやりすぎだと思うので、使いどころは選ぶと思います。

Blazor で状態を管理するようなライブラリ・フレームワークはあるのかしら。

おわりに

途中でも降れましたが、Angular っぽいコードを TypeScript ではなく C# で書ける、というのはなんだか不思議な感じがします。

あ、最近あまり触れられていませんが、 Angular や TypeScript も好きですよ。念のため。

ただ、思っていた型と違っているんじゃ。。。といった心配をあまりしなくて良い安心感がありがたいですね。

【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