vaguely

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

【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 にしていたためコンパイルエラーになり、ちょっとハマりました)

【ASP.NET Core】Blazor を試す (Blazor Server)

はじめに

気になりつつも試せていなかった Blazor 。

ASP.NET Core Preview 7 でアップデートがたくさん入ったっぽいこともあり、試してみることにしました。

実際使うとすると、既存のプロジェクトに追加(置き換え)していくことになると思うので、 MVC プロジェクトを作り、そこに必要なファイルを追加していく形で進めます。

Blazor には Blazor Server と Blazor WebAssembly があるのですが、今回 PWA などを試さないこと、 Blazor Server の方がわかりやすい( Razor などを使ったプロジェクトに近いため)気がしたので、今回は Blazor Server を試します。

Environments

Base project

まず Empty テンプレートで ASP.NET Core のプロジェクトを作り、 Controller や Razor などを追加しておきます。

Startup.cs

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

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

HomeController.cs

using Microsoft.AspNetCore.Mvc;
using Models;

namespace Controllers
{
    public class HomeController: Controller
    {
        [Route("/")]
        public ActionResult Index()
        {
            return View("Views/Index.cshtml");
        }
    }
}

Product.cs

namespace Models
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

_ViewStart.cshtml

@{
    Layout = "_Layout";
}

_Layout.cshtml

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

Index.cshtml

@{
    ViewData["Title"] = "Home Page";
}
<h2>This is Index.cshtml</h2>

Blazor を追加する

下記の記事をもとに Blazor を使うのに必要な物を追加していきます。

middleware の追加、 Blazors SignalR hub のマッピング

Startup.cs

...
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddControllers();
        }
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseRouting();
            app.UseStaticFiles();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
                endpoints.MapBlazorHub();
            });
        }
...

_Imports.razor

@using System
@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using BlazorSample

BlzrSample.razor

<h1>Hello @Name!</h1>
<p>This is Blazor Components.</p>
<div>@SampleInfo</div>
<button @onclick="ShowConsole">Click</button>
@code {
    [Parameter]
    public string Name {get; set;}
    [Parameter]
    public string SampleInfo { get; set; }
    public string GetName(){
        var name = $"{Name} {DateTime.Now}";
        Console.WriteLine(name);
        return name;
    }
    public void ShowConsole()
    {
        SampleInfo = GetName();
        Console.WriteLine($"{SampleInfo}");
    }
}

Index.cshtml

@using BlazorSample.Views
@{
    ViewData["Title"] = "Home Page";
}
<h2>This is Index.cshtml</h2>
@(await Html.RenderComponentAsync<BlzrSample>(RenderMode.ServerPrerendered, new { Name = "BlazorSample" }))

Result

f:id:mslGt:20200815194613j:plain

ボタンをクリックすると div 要素の値が変わります。

IE11

いい加減フロントエンド関連のことを試すとき、まず IE を考えるのをやめたいところですが。。。

ともかく、 IE11 で開くと、 Blazor の要素は表示されるものの、ボタンをクリックしても動作しません。

対策は色々あると思いますが、その一つである Blazor.Polyfill を試します。

_Layout.cshtml

<!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>
        @RenderBody()
    <script src="_framework/blazor.server.js"></script>
    </body>
</html>

base タグを忘れるとエラーになるので注意が必要です。

DOM 操作

どうも C#(Blazor) からは JavaScript で行うような DOM 操作を直接行うことはできないようです。
( @foreach などを使ってリストの数だけ DOM を生成する、とかはできますが)

@* 「Numbers」の要素を増減すると div の数が変わる *@
@foreach(var n in Numbers)
{
    <div>@SampleInfo</div>
}

ということで、そのような操作がしたい場合は JavaScript を呼び出す必要があります。

JavaScript とのやり取り

JavaScript を Blazor から呼び出す

IJSRuntime を使って JavaScript を呼び出すことができます。

普段 webpack や TypeScript を使っているため、それらを追加します。

Samples

package.json

{
    "dependencies": {
        "ts-loader": "^8.0.2",
        "tsc": "^1.20150623.0",
        "typescript": "^3.9.7",
        "webpack": "^4.44.1",
        "webpack-cli": "^3.3.12"
    }
}

webpack.config.js

var path = require('path');

module.exports = {
    mode: 'development',
    entry: {
        'blazorSample': './wwwroot/ts/blazor-sample.ts',
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/
            }
        ]
    },
    resolve: {
        extensions: [ '.tsx', '.ts', '.js' ]
    },
    output: {
        filename: '[name].bundle.js',
        path: path.resolve(__dirname, './wwwroot/js'),
        library: 'Page',
        libraryTarget: 'umd'
    }
};

blazor-sample.ts

export function callFromBlazor(name: string): string
{
    console.log("Hello Blazor");
    return `Hello Blazor ${name}`;
}

BlzrSample.razor

@inject IJSRuntime JSRuntime;
...
<button @onclick="CallFromBlazor">Click2</button>

@code {
...
    private async Task CallFromBlazor()
    {
        var result = await JSRuntime.InvokeAsync<string>("Page.callFromBlazor", SampleInfo);
        Console.WriteLine(result);
    }
}

JavaScript から C# の static メソッドを呼ぶ

C# の static メソッドは DotNet.invokeMethodAsync を使って呼び出すことができます。
まずは Blazor の型定義を追加します。

npm install --save @types/blazor__javascript-interop

blazor「__」javascript ??と気にならなくはないのですが、ほかにそれらしきものが見つからなかったので。

blazor-sample.ts

...
export function callStaticFromJs() {
    // 1. Assembly name, 2. Method name, 3. Arguments
    DotNet.invokeMethodAsync("BlazorSample", "CallStaticFromJs", "FromJS")
        .then(result => console.log(result));
}

BlzrSample.razor

...
<button onclick="Page.callStaticFromJs()">Click3</button>

@code {
...
    // must add JSInvokable and set public static method.
    [JSInvokable]
    public static async Task<string> CallStaticFromJs(string message)
    {
        Console.WriteLine($"Hello JS {message}");
        return await Task.FromResult("Hello static");
    }
...

JavaScript から C#インスタンスメソッドを呼ぶ

C#インスタンスメソッドを呼ぶには、まず C# のメソッド内でインスタンスを生成し、それを JavaScript に渡します。

HelloHelper.cs

using Microsoft.JSInterop;

public class HelloHelper
{
    public HelloHelper(string name)
    {
        Name = name;
    }
    public string Name { get; set; }
    // call from JavaScript
    [JSInvokable]
    public string SayHello() => $"Hello, {Name}!";
}

blazor-sample.ts

...
// argument: HelloHelper instance
export function callInstanceFromJs(dotnetHelper: any) {
    return dotnetHelper.invokeMethodAsync('SayHello')
      .then((r: any) => console.log(r));
}

BlzrSample.razor

@inject IJSRuntime JSRuntime;
...
<button @onclick="CallInstanceFromJs">Click4</button>

@code {
...
    public async Task CallInstanceFromJs()
    {
        using(var helperRef = DotNetObjectReference.Create(new HelloHelper("Js instance")))
        {
            await JSRuntime.InvokeAsync<string>(
                "Page.callInstanceFromJs", helperRef);
        }  
    }
...

今回は using 句を使っているわけですが、実際にはインスタンスの寿命の関係から生成するメソッドと破棄するメソッドは分けることになるのかな~と思っています。

サーバー側のメソッドを呼ぶ

では サーバー側のメソッドを呼ぶことはできるでしょうか。

と思ったら、実は Blazor でも DI が使えると( IJSRuntime も Inject されてましたね)。

ということで、サーバー側と同じように Service クラスなどを Inject すれば OK です。

IBlazorService.cs

using System.Threading.Tasks;
using Models;

namespace Services
{
    public interface IBlazorService
    {
        Task<Product> GetMessageAsync(string Name);
    }
}

BlazorService.cs

using System.Threading.Tasks;
using Models;
namespace Services
{
    public class BlazorService: IBlazorService
    {
        public async Task<Product> GetMessageAsync(string name)
        {
            return await Task.FromResult(new Product
            {
                Id = 2,
                Name = name,
            });
        }
    }
}

Startup.cs

...
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRazorPages();
        services.AddServerSideBlazor();
        services.AddControllers();
        services.AddScoped<IBlazorService, BlazorService>();
    }
...

BlzrSample.razor

@inject Services.IBlazorService _blazor
...
<button @onclick="CallServerside">Click5</button>

@code {
...
    public async Task CallServerside()
    {
        var result = await _blazor.GetMessageAsync(Name);
        if (result == null)
        {
            Console.WriteLine("Cannot get message");
            return;
        }
        Console.WriteLine($"Success ID: {result.Id} Name: {result.Name}");
    }
}

おわりに

以前 Razor も試しました が、動的に処理を実行できる分 Blazor の方が幅が広がりますね。

とはいえ DOM 操作など JavaScript でしかできない処理があるため、うまく住み分けしないと中途半端になってしまいそうですね。

WebAssembly の方も気になるところです。

【.NET】Console Appを試す

はじめに

これまで ASP.NET Core のアプリは作ってきたものの、よく考えると Console アプリってあまり作ったことないよな~。と思ったので試してみることにしました。

気になるところとしては、 ASP.NET Core のように DI やログ出力などできるのか、というところなので、順に試していくことにします。

Environments

  • .NET Core ver.5.0.100-preview.7.20366.6

元のプロジェクト

とりあえず Console アプリを作ります。

Program.cs

using System;

namespace ConsoleSample
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
    }
}

設定ファイルの読み込み

Database の接続文字列などは設定ファイルを使って読み込みたいところ。

当然ながら StreamReader などを使って通常のファイル読み込みのように JSON ファイルを読み込むことはできるのですが、 ASP.NET Core のようにもう少しシンプルな方法はないでしょうか。

Startup.cs

...
    private readonly IConfiguration configuration;
    public Startup(IHostEnvironment env)
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(env.ContentRootPath)
            .AddJsonFile("appsettings.json", false, true)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", false, true)
            .AddEnvironmentVariables();
        configuration = builder.Build();
    }
...

と思っていたら、 ConfigurationBuilder がそのまま使えるそうです。

Install

Samples

NuGet でインストールができたら、読み込むための JSON ファイルと、読み込む機能を追加します。

appsettings.json

{
    "ConnectionStrings": "Host=localhost;Port=5432;Database=Example;Username=postgres;Password=example"
}

appsettings.Development.json

{
    "Message": "Hello Development"
}

appsettings.Production.json

{
    "Message": "Hello Production"
}

Program.cs

using System;
using Microsoft.Extensions.Configuration;

namespace ConsoleSample
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = GetConfiguration();
            Console.WriteLine(config["ConnectionStrings"]);
            Console.WriteLine("Hello World!");
        }
        private static IConfiguration GetConfiguration()
        {
            var builder = new ConfigurationBuilder()
            .SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
            .AddJsonFile("appsettings.json", false, true)
            .AddEnvironmentVariables();
            return builder.Build();
        }
    }
}

なお Base Path に「System.IO.Directory.GetCurrentDirectory()」を設定することもできます。

EnvironmentName を取得する

「appsettings.json」については読み込みができましたが、デバッグ時には「appsettings.Development.json」を、リリースビルドでは「appsettings.Production.json」を読み込みたい。

ASP.NET Core であれば、 IHostEnvironment の EnvironmentName から受け取ることができます。
この値はどうやら Visual Studio などで登録しておけば同じようなことができるようです。

var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

が、プロジェクト作成のたびに登録するのはなぁ。。。(もっとシンプルな方法があるのかもしれませんが)

ということで、 #if ディレクティブを使うことにしました。

        private static IConfiguration GetConfiguration()
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
                .AddJsonFile("appsettings.json", false, true)
#if DEBUG
                .AddJsonFile($"appsettings.Development.json", false, true)
#else
                .AddJsonFile($"appsettings.Production.json", false, true)
#endif
                .AddEnvironmentVariables();
            return builder.Build();
        }

正直いまいち感がないではないのですが、とにかく環境別で切り替えることができました。

DI(Dependency Injection)

DI も ASP.NET Core と同じものが使えます。

Install

Samples

IProductService.cs

namespace Products
{
    public interface IProductService
    {       
    }
}

ProductService.cs

namespace Products
{
    public class ProductService: IProductService
    {       
    }
}

MainController.cs

using System;
using System.Threading.Tasks;
using Products;

namespace Controllers
{
    public class MainController
    {
        private readonly IProductService _product;
        public MainController(IProductService product)
        {
            _product = product;
        }
        public async Task StartAsync()
        {
            await Task.Run(() => Console.WriteLine(_product == null));
        }
    }
}

Program.cs

using System;
using System.Threading.Tasks;
using Controllers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Products;

namespace ConsoleSample
{
    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>();
            services.AddScoped<IProductService, ProductService>();
            return services.BuildServiceProvider();
        }
    }
}

IConfiguration のインジェクション

ASP.NET Core であれば、Controller クラスからでも Configuration の値を参照できます。

これは Microsoft.AspNetCore.Mvc.Controller を継承しているからだと思いますが、 Console アプリの場合は自分で IServiceProvider に渡す必要があります。

Program.cs

...
        private static IServiceProvider BuildDi()
        {
            var services = new ServiceCollection();

            services.AddSingleton<IConfiguration>(GetConfiguration());
            
            services.AddTransient<MainController>();
            services.AddScoped<IProductService, ProductService>();
            return services.BuildServiceProvider();
        }
...

ということで Configuration の値を参照できるようになりました。

MainController.cs

using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Products;

namespace Controllers
{
    public class MainController
    {
        private readonly IProductService _product;
        public MainController(IConfiguration config,
            IProductService product)
        {

            Console.WriteLine(config["Message"]); // <- print "Hello Development"
            _product = product;
        }

Entity Framework Core を使う

DI が同じなので当然といえば当然ですが、 Entity Framework Core も ASP.NET Core と同じように使うことができます。

Install

ConsoleSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public class ConsoleSampleContext: DbContext
    {
        public ConsoleSampleContext(DbContextOptions<ConsoleSampleContext> options)
            :base(options)
        {
        }
    }
}

Program.cs

...
        private static IServiceProvider BuildDi()
        {
            var config = GetConfiguration();
            var services = new ServiceCollection();
            services.AddSingleton<IConfiguration>(config);
            
            services.AddDbContext<ConsoleSampleContext>(options =>
                options.UseNpgsql(config["ConnectionStrings"]));
            
            services.AddTransient<MainController>();
            services.AddScoped<IProductService, ProductService>();
            return services.BuildServiceProvider();
        }
...

ログ出力(NLog)

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

Install

  • NLog ver.4.7.3
  • NLog.Extensions.Logging ver.1.6.4

Samples

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\ConsoleSample\${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" />
        <!--Microsoft.* のクラスの Info レベル以下のログはスキップ-->
        <logger name="Microsoft.*" maxLevel="Info" final="true" />
        <logger name="*" minlevel="Debug" writeTo="outputfile" />
    </rules>
</nlog>

ConsoleSample.csproj

...
  <ItemGroup>
    <Content Include="nlog.config">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
    <Content Include="appsettings.json">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
    <Content Include="appsettings.Development.json">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
    <Content Include="appsettings.Production.json">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
  </ItemGroup>
</Project>

NLog ver.5.0.0-beta11

ほぼドキュメント通りなのですが、一点注意が。

NLog ver.5.0.0-beta11 で試したところ、依存関係の解決に失敗してエラーになりました。

Attempt by method 'NLog.Extensions.Logging.ConfigureExtensions.CreateNLogLoggerProvider(System.IServiceProvider, Microsoft.Extensions.Configuration.IConfiguration, NLog.Extensions.Logging.NLogProviderOptions, NLog.LogFactory)' to access method 'NLog.LogManager.get_LogFactory()' failed.

おそらく ver.5.0.0 で何か変わったのかなぁ、と思っているのですが、少なくとも今(2020-08-09)、上記のコードを試す場合は ver.4.7.3 を使うのがよいと思います。

おわりに

ASP.NET Core は色々特別に作られた機能があるために DI などが実現できているのだろうな~と思っていたのですが、結構 Console アプリでもそのまま使えるのだとわかったのが今回の収穫です。

Angular などもそうだと思うのですが、「複雑でよくわからない一つの塊」ではなく、一つ一つの小さな機能(それでも DI などは相当大きいとは思いますが)が組み合わさって作られているのだ、というまぁ当たり前といえば当たり前のことに気づいたというか。

今後それぞれもう少し突っ込んでみていきたいところ。

【C#】【EntityFrameworkCore】 null 許容参照型を試す

はじめに

気になりつつも試せていなかった、 null 許容参照型 (nullable reference types) を試してみます。

プロジェクトは前回のものを使います。

Environments

準備

null 許容参照型を使うにはプロジェクトの C# バージョンを 8.0 以上にすること、また Nullable を有効にする必要があります。

CodeFirstSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
...

Nullable を有効にすると、大量の Warning が出てきます。

null 許容参照型について

C# 8 以前、 null 許容にできるのは値型だけでした。

int nonNullNumber = 0;
nonNullNumber = null; // <- compile error
int? nullableNumber = 0;
nullableNumber = null; // <- OK

// null 許容の値型を通常の値型に代入するにはキャストが必要
nonNullNumber = (int)nullableNumber;

c# 8 以降は参照型についても null になりうるか、そうでないかを区別できるようになりました。

using System;
using Models;

namespace CSharpEightSample
{
    class Program
    {
        static void Main(string[] args)
        {
            // Nullable instance
            User? nullableUser = null;  // <- OK
            // Non-nullable instance
            User user = null;           // <- Warning
        }
    }
}

残念ながら(後方互換性のため)コンパイルエラーではなく Warning のみではありますが。

IL

さて、この null 許容型と非 null 許容型のインスタンスですが、実行時の違いはあるのでしょうか。

ILSpy Visual Studio Code Extension( https://github.com/icsharpcode/ilspy-vscode )を使って IL を覗いてみます。

.class /* 02000005 */ private auto ansi beforefieldinit CSharpEightSample.Program
    extends [System.Runtime]System.Object
{
    // Methods
    .method /* 06000005 */ private hidebysig static 
        void Main (
            string[] args
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
        // Method begins at RVA 0x2094
        // Code size 6 (0x6)
        .maxstack 1
        .entrypoint
        .locals /* 11000001 */ init (
            [0] class Models.User,
            [1] class Models.User
        )

        IL_0000: nop
        IL_0001: ldnull
        IL_0002: stloc.0
        IL_0003: ldnull
        IL_0004: stloc.1
        IL_0005: ret
    } // end of method Program::Main
...

ローカル変数においては特に違いがなさそうです。

プロパティはどうでしょうか。

namespace CSharpEightSample
{
    public class Sample
    {
        public string Name { get; set; } = "";
        public string? NullableName {get; set; }
    }
    class Program
    {
        static void Main(string[] args)
        {
        }
    }
}
.class /* 02000005 */ public auto ansi beforefieldinit CSharpEightSample.Sample
    extends [System.Runtime]System.Object
{
    .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
        01 00 01 00 00
    )
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
        01 00 00 00 00
    )
    // Fields
    .field /* 04000003 */ private string '<Name>k__BackingField'
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    .custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState) = (
        01 00 00 00 00 00 00 00
    )
    .field /* 04000004 */ private string '<NullableName>k__BackingField'
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
        01 00 02 00 00
    )
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    .custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState) = (
        01 00 00 00 00 00 00 00
    )

    // Methods
    .method /* 06000005 */ public hidebysig specialname 
        instance string get_Name () cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x2092
        // Code size 7 (0x7)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld string CSharpEightSample.Sample::'<Name>k__BackingField' /* 04000003 */
        IL_0006: ret
    } // end of method Sample::get_Name

    .method /* 06000006 */ public hidebysig specialname 
        instance void set_Name (
            string 'value'
        ) cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x209a
        // Code size 8 (0x8)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld string CSharpEightSample.Sample::'<Name>k__BackingField' /* 04000003 */
        IL_0007: ret
    } // end of method Sample::set_Name

    .method /* 06000007 */ public hidebysig specialname 
        instance string get_NullableName () cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x20a3
        // Code size 7 (0x7)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld string CSharpEightSample.Sample::'<NullableName>k__BackingField' /* 04000004 */
        IL_0006: ret
    } // end of method Sample::get_NullableName

    .method /* 06000008 */ public hidebysig specialname 
        instance void set_NullableName (
            string 'value'
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x20ab
        // Code size 8 (0x8)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld string CSharpEightSample.Sample::'<NullableName>k__BackingField' /* 04000004 */
        IL_0007: ret
    } // end of method Sample::set_NullableName

    .method /* 06000009 */ public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        // Method begins at RVA 0x20b4
        // Code size 19 (0x13)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldstr "" /* 70000001 */
        IL_0006: stfld string CSharpEightSample.Sample::'<Name>k__BackingField' /* 04000003 */
        IL_000b: ldarg.0
        IL_000c: call instance void [System.Runtime]System.Object::.ctor() /* 0A00000F */
        IL_0011: nop
        IL_0012: ret
    } // end of method Sample::.ctor

    // Properties
    .property /* 17000001 */ instance string Name()
    {
        .get instance string CSharpEightSample.Sample::get_Name()
        .set instance void CSharpEightSample.Sample::set_Name(string)
    }
    .property /* 17000002 */ instance string NullableName()
    {
        .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
        .get instance string CSharpEightSample.Sample::get_NullableName()
        .set instance void CSharpEightSample.Sample::set_NullableName(string)
    }

} // end of class CSharpEightSample.Sample

「NullableName」の方には「System.Runtime.CompilerServices.NullableAttribute」が付与されていますね。

default!

例えば非 null 許容の string 型のインスタンスがあるとして、デフォルト値として空文字を設定するか、「default!」を渡すことで Warning を回避できます。

string sample = ""; // <- OK
string sample2 = default! // <- OK

さて、「sample2」にはデフォルトで何が入っているのか?というと、やっぱり null でした。

ということで、「default!」はただ Warning を抑えるためだけに使える、といった印象を受けました。

Generics

Generics はどうでしょうか。

まず、下記のように書くことはできません。

...
    public class Sample<T> where T: class
    {
        public T Name { get; set; } = default!;
        public T? NullableName {get; set; } // <- compile error
    }
...

「T」に対して class または struct の制約がつけられるのであれば、「T?」を使うことができます。

...
    public class Sample<T> where T: class
    {
        public T Name { get; set; } = default!;
        public T? NullableName {get; set; } // <- OK
    }
...

また「T」に null 許容の型を指定したい場合は「class ?」を使うことができます。

...
    public class Sample<T> where T: class ?
    {
        public T Name { get; set; } = default!;
        public T NullableName {get; set; } = default!;
    }
    class Program
    {
        static void Main(string[] args)
        {
            var s = new Sample<string?>(); // <- OK
        }
    }
...

またプロパティやメソッドに対しては属性を使う、といった方法もあります。

...
    public class Sample<T>
    {
        public T Name { get; set; } = default!;
        [AllowNull]
        public T NullableName {get; set; } = default!;
    }
    class Program
    {
        static void Main(string[] args)
        {
            var s = new Sample<string>();
            s.NullableName = null;          // <- OK
        }
...
...
    public class Sample<T>
    {
        public T Name { get; set; } = default!;
        [AllowNull]
        public T NullableName {get; set; } = default!;
        [return: MaybeNull]
        public T GetNull()
        {
            return default(T);
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            var s = new Sample<string>();
            s.NullableName = null;
            Console.WriteLine((s.GetNull() == null));          // <- true
        }
...

Entity FrameWork Core

DbContext

DbContext では、「DbSet<T>」のインスタンスに対して 「Set<T>()」を使い、 null 許容を避けることができます。

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public class CodeFirstSampleContext: DbContext
    {
        public CodeFirstSampleContext(DbContextOptions<CodeFirstSampleContext> options)
            : base(options)
        {

        }
        public DbSet<Workflow> Workflows => Set<Workflow>();
        public DbSet<WorkflowReader> WorkflowReaders => Set<WorkflowReader>();
    }
}

Model

ではモデルクラスはどうでしょうか。

こちらは特に null 許容を避けるメソッドなどが用意されているわけではないようなので、 Database のテーブルなどから null 許容にすべきかどうかを判断する、という必要があります。

null 許容・非 null 許容参照型はマイグレーションファイルに影響を与えるか

答えはイエスです。

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

namespace Models
{
    public class Sample
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        public string? Name { get; set; } = "hello";
    }
}

これでマイグレーションファイルを作ると、デフォルト値を設定していても「Name」カラムは null 許容となります。

...
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Samples",
                columns: table => new
                {
                    Id = table.Column<int>(type: "integer", nullable: false)
                        .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
                    Name = table.Column<string>(type: "text", nullable: true)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Samples", x => x.Id);
                });
        }
...

今のところ、できる限り全プロパティを非 null 許容にすべきか?というところはわかっていないのですが、まずはできる限り null 許容を避けてみようかな、とは思っています。

参照

【ASP.NET Core】【Entity Framework Core】Code first / DB first ってみる

はじめに

Entity Framework Core でコードからデータベースのテーブルを生成する Code first、データベースからコードを生成する DB first をやってみたよというお話。

Environments

Microsoft.EntityFrameworkCore.Abstractions と Microsoft.EntityFrameworkCore.Relational は Microsoft.EntityFrameworkCore (だったと思う)が依存しているとリストア時にエラーを出したので追加しました。

※個人的な興味から .NET 5 を使ってますが、現状(2020-07-24 時点)では .NET Core 3.1 と書き方などほとんど変わっていないと思います。

Code first

ではまず Code first から。

プロジェクトを作る

空で ASP.NET Core のプロジェクトを作ります。

dotnet new empty -n CodeFiirstSample

NuGet でパッケージを追加しておきます。

Samples

接続文字列やテーブルに対応するクラスなどを追加します。

appsettings.Development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "ConnectionStrings": "Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX"
}

Models/Workflow.cs

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

namespace Models
{
    [Table("Workflow")]
    public class Workflow
    {
        // 主キー
        [Key]
        // Auto increament
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set;}
        // not null
        [Required]
        public int ProductId { get; set; }
        [Required]
        [Column("CreateUserMail", TypeName="text")]
        public string CreateUserMail { get; set;}
        [Column(TypeName="timestamp with time zone")]
        public DateTime? CirculationLimit { get; set; }
        [Column(TypeName="timestamp with time zone")]
        // 更新のたびに現在時刻を入れたい
        [DatabaseGenerated(DatabaseGeneratedOption.Computed)]
        public DateTime LastUpdateDate { get; set; }

        public List<WorkflowReader> Readers {get; set; }
    }
}

Models/WorkflowReader.cs

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

namespace Models
{
    public class WorkflowReader
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        // 外部キー
        [ForeignKey("Workflow")]
        [Required]
        public int WorkflowId { get; set; }
        [Required]
        public string Name{ get; set; }

        public Workflow Workflow {get; set; }
    }
}

Models/CodeFirstSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public class CodeFirstSampleContext: DbContext
    {
        public CodeFirstSampleContext(DbContextOptions<CodeFirstSampleContext> options)
            : base(options)
        {

        }
        public DbSet<Workflow> Workflows { get; set; }
        public DbSet<WorkflowReader> WorkflowReaders { get; set; }
    }
}

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Models;
using Workflow;

namespace CodeFirstSample
{
    public class Startup
    {
        private readonly IConfiguration configuration;
        public Startup(IHostEnvironment env)
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", false, true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", false, true)
                .AddEnvironmentVariables();
            configuration = builder.Build();
        }
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();
            services.AddDbContext<ProofreadingWorkflowContext>(options =>
                options.UseNpgsql(configuration["ConnectionStrings"]));
            services.AddScoped<IWorkflowService, WorkflowService>();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

最初のマイグレーションファイルを生成する

さてさて準備ができたところでマイグレーションファイルを生成しますよ。

dotnet tool update --global dotnet-ef --version 5.0.0-preview.7.20365.15

※.NET 5 で試す場合、バージョン指定しないと最新の安定板がインストールされ、コマンド実行時にエラーが出るので注意が必要です。

ドキュメントによると、「dotnet add package Microsoft.EntityFrameworkCore.Design」で「Microsoft.EntityFrameworkCore.Design」をインストール、ということなのですが、これでインストールされるのは安定板 (3.1.6) なので、 NuGet でインストールする必要があります。
(VSCode の NuGet Package Manager 便利ですね)

前置きが長くなりましたが、マイグレーションファイルを作成します。

dotnet ef migrations add InitialCreate

コマンドを実行すると、 Migrations というフォルダ以下に下記が出力されます。

  • 20200719144103_InitialCreate.cs
  • 20200719144103_InitialCreate.Designer.cs
  • WorkflowSampleContextModelSnapshot.cs

コマンドを実行してデータベースに反映します。

dotnet ef database update

すでに同名のテーブルが存在する場合

普通にエラーになります。

ということで、 Code first の場合はデータベースを空で作成する必要があります。
(それが難しい場合は後述の DB first で進めるのが良さそうです)

マイグレーションの取り消し

データベースに反映したマイグレーションを取り消したい場合。
例えば上記のあと何らかのマイグレーションを実行し、それを取り消したい場合は下記のようにします。

dotnet ef database update InitialCreate

直前の処理をなかったことにする、というよりは、戻したい状態を指定してそこに戻る、という方法はちょっと Git を思い出させます。

ただ、特にデータを挿入する前にテーブル名間違えたりしていた場合は、マイグレーションファイルを作って~とするより、マイグレーションファイル、テーブルを削除 -> 作り直して再度生成、とした方が楽かもしれませんね。

デフォルト値の設定

ここまでを実行すると、とりあえずテーブルは生成されます。

が、データを追加しようとすると、「Workflow」テーブルで「LastUpdateDate」が Null だと怒られます。

というのも、「DatabaseGenerated」がつけられている要素は Entity Framework が生成する INSERT 文から外されるから + デフォルト値が設定されていないから です。

INSERT INTO "Workflow" ("CirculationLimit", "CreateUserMail", "ModelName", "ProductId", "SizaiCode")
      VALUES (@p4285, @p4286, @p4287, @p4288, @p4289)
      RETURNING "Id", "LastUpdateDate";

なお「Id」は(おそらく) データベースでは「serial」型として扱われるため、自動でインクリメントされた値が挿入されます。

最初「DatabaseGenerated」をつければ自動で値をセットしてくれるのかな?と思い込んでいたため、ちょっとハマりました。

デフォルト値の設定は Context クラスでできます。

Models/CodeFirstSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public class CodeFirstSampleContext: DbContext
    {
...
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Workflow>()
                .Property(w => w.LastUpdateDate)
                .HasDefaultValueSql("CURRENT_TIMESTAMP");
        }
...
    }
}

現在時刻の設定を、ついつい DateTime.Now とかしたくなりますが、その場合「マイグレーションファイルを作った時間」がデフォルト値になってしまうので要注意です。

DB first

では、先ほど作ったデータベース、テーブルを使って DB first も試してみます。

dotnet new empty -n DbFiirstSample

NuGet で Code first と同じパッケージを入れておきます。

で、コマンドを実行します。

dotnet ef dbcontext scaffold "Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX" Npgsql.EntityFrameworkCore.PostgreSQL -d -o Models -n Models

コマンドのオプションについて

DB first でコードを生成するコマンドのオプションはいくつかありますが、その中でも下記 3 つは特に必要かな~、と思いました。

  • -d(--data-annotations): 生成するモデルクラスの要素にアノテーション([Key]とか)を付けます。
  • -o(--output-dir): デフォルトだとプロジェクト直下にファイルが生成されるので、これを「Models」というフォルダー以下に生成されるよう変更しています。
  • -n(--namespace): 生成されるクラスの namespace を指定しています。デフォルトでは「DbFiirstSample.Models」となります。

生成されたクラス

Models/Workflow.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace Models
{
    public partial class Workflow
    {
        public Workflow()
        {
            WorkflowReaders = new HashSet<WorkflowReaders>();
        }
        [Key]
        public int Id { get; set; }
        public int ProductId { get; set; }
        [Required]
        public string CreateUserMail { get; set; }
        [Column(TypeName = "timestamp with time zone")]
        public DateTime? CirculationLimit { get; set; }
        [Column(TypeName = "timestamp with time zone")]
        public DateTime LastUpdateDate { get; set; }
        [InverseProperty("Workflow")]
        public virtual ICollection<WorkflowReader> WorkflowReaders { get; set; }
    }
}

Models/WorkflowReaders.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace Models
{
    [Index(nameof(WorkflowId))]
    public partial class WorkflowReaders
    {
        [Key]
        public int Id { get; set; }
        public int WorkflowId { get; set; }
        [Required]
        public string Name{ get; set; }
        [ForeignKey(nameof(WorkflowId))]
        [InverseProperty("WorkflowReaders")]
        public virtual Workflow Workflow { get; set; }
    }
}

Models/WorkflowSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public partial class WorkflowSampleContext : DbContext
    {
        public WorkflowSampleContext()
        {
        }

        public WorkflowSampleContext(DbContextOptions<WorkflowSampleContext> options)
            : base(options)
        {
        }

        public virtual DbSet<WorkflowReaders> WorkflowReaders { get; set; }
        public virtual DbSet<Workflow> Workflow { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
                optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX");
            }
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            OnModelCreatingPartial(modelBuilder);
        }

        partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
    }
}

Code first で生成されるコードとはやはり異なっていますね。

特に「DatabaseGenerated」については反映されていないようです。
(別のオプション、もしくは別のファイルで表現されている?)

【RxJS】処理に失敗したときに早期リターンしたい話

はじめに

C# でコードを書く時に、下記のような処理をよく書きます。

private async Task ExecuteExampleAsync()
{
    var result1 = await DoSomethingAsync1();
    if (result1 == null)
    {
        return;
    }
    var result2 = await DoSomethingAsync2();
    if (result2 == null)
    {
        return;
    }
    return await DoSomethingAsync3();
}

例えば一つ目の処理に失敗した場合はそこで処理をストップする、という話なのですが、これを RxJS でやるとどうなるの?というのが前から気になっていたので試してみました。

Environments

  • Angular ver.10.1.0-next.1
  • RxJS ver.6.6

empty, throwError, throw new Error

今回調べてみたなかで、実現できそうな方法は3つありました。

サンプルコード(ベースだけ)

今回は下記の ngOnInit からメソッドを呼ぶ、という形で試すことにしました。

workflow-page.component.ts

import { Component, OnInit } from '@angular/core';
import { Observable, of, empty, throwError } from 'rxjs';
import { flatMap, catchError } from 'rxjs/operators';

@Component({
  selector: 'app-workflow-page',
  templateUrl: './workflow-page.component.html',
  styleUrls: ['./workflow-page.component.css']
})
export class WorkflowPageComponent implements OnInit {

  constructor() { }

  ngOnInit(): void {
      // call methods
  }
}

empty

「empty」が呼ばれると、subscribe の「complete」ハンドラーが呼ばれて処理が完了します。

...
  ngOnInit(): void {
    console.log('-- Throw empty from first call --');
    this.executeEmpty(4);
    console.log('-- Throw empty from second call --');
    this.executeEmpty(3);
    console.log('-- End --');
  }
  private executeEmpty(startValue: number) {
    this.getEmpty(startValue)
      .pipe(
        flatMap(result => {
          console.log(`2nd execution: ${result}`);
          return this.getEmpty(result);
        }),
        catchError(error => {
          console.error(`catch: ${error}`);
          return of(error);
        })
      )
      .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  private getEmpty(lastValue: number): Observable<number> {
    if (lastValue > 3) {
      return empty();
    }
    return of(lastValue + 1);
  }

結果

-- Throw empty from first call --
complete
-- Throw empty from second call --
2nd execution: 4
complete
-- End --

以上から、「empty」を使う場合、処理の結果(今回は null が返ってきた)は呼び出し先である「getEmpty」がハンドリングする必要があります。

呼び出し元には「complete」ハンドラーが呼ばれたことしか返ってこないからですね。 f:id:mslGt:20200718144847p:plain

throwError

...
  ngOnInit(): void {
    console.log('-- Throw throwError from first call --');
    this.executeThrowError(4);
    console.log('-- Throw throwError from second call --');
    this.executeThrowError(3);
    
    console.log('-- Throw throwError with catchError from first call --');
    this.executeThrowErrorWithCatchError(4);
    console.log('-- Throw throwError with catchError from second call --');
    this.executeThrowErrorWithCatchError(3);
    console.log('-- End --');
  }
  private executeThrowError(startValue: number) {
    this.getThrowError(startValue)
    .pipe(
      flatMap(result => {
        console.log(`2nd execution: ${result}`);
        return this.getThrowError(result);
      })
    )
    .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  private executeThrowErrorWithCatchError(startValue: number) {
    this.getThrowError(startValue)
    .pipe(
      flatMap(result => {
        console.log(`2nd execution: ${result}`);
        return this.getThrowError(result);
      }),
      catchError(error => {
        console.error(`catch: ${error}`);
        return of(error);
      })
    )
    .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  public getThrowError(lastValue: number): Observable<number> {
    if (lastValue > 3) {
      return throwError('Error from throwError');
    }
    return of(lastValue + 1);
  }

結果

-- Throw throwError from first call --
error: Error from throwError
...
-- Throw throwError from second call --
2nd execution: 4
error: Error from throwError
...
-- Throw throwError with catchError from first call --
catch: Error from throwError
...
next: Error from throwError
complete
-- Throw throwError with catchError from second call --
2nd execution: 4
catch: Error from throwError
...
next: Error from throwError
complete
-- End --
  • 「catchError」を使っていない場合はsubscribeの「error」ハンドラーが呼ばれます。 f:id:mslGt:20200718144926p:plain

  • 「catchError」を使っている場合、「catchError」で「of(error)」を返すと通常と同じく「next」と「complete」が呼ばれます。 f:id:mslGt:20200718144947p:plain

ということで、処理に失敗したことを subscribe でエラーとしてハンドリングしたい場合は「catchError」無しで「throwError」を使うか、「catchError」からさらに「throwError」を実行する、といった書き方になりそうです。

throw new Error

「throw new Error」で Error を投げる、または呼び出したメソッド内で Error が発生した場合はどうでしょうか。

...
  ngOnInit(): void {
    console.log('-- Throw new Error from first call --');
    this.executeThrowNewError(4);
    console.log('-- Throw new Error from second call --');
    this.executeThrowNewError(3);
  }
  private executeThrowNewError(startValue: number) {
    this.getThrowNewError(startValue)
      .pipe(
        flatMap(result => {
          console.log(`2nd execution: ${result}`);
          return this.getThrowNewError(result);
        })
      )
      .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  // throw new Error
  public getThrowNewError(lastValue: number): Observable<number> {
    if (lastValue > 3) {
      throw new Error('Error from new Error()');
    }
    return of(lastValue + 1);
  }

結果

-- Throw new Error from first call --
ERROR Error: Error from new Error()

最初の結果しか出力されない。。。(´・ω・`)

というのも、 pipe でつながれていない、最初の処理で Error が発生してしまうと「catchError」や subscribe の「error」では受け取ることができず、処理が止まってしまうからですね。 f:id:mslGt:20200718145020p:plain

引数を2回目の実行時に Error を投げることになる 3 に変更すると、下記のような結果になります。

-- Throw new Error from first call --
2nd execution: 4
error: Error: Error from new Error()
...

f:id:mslGt:20200718145049p:plain

ということで、 Observable を返すメソッド(関数)では「throw new Error」は使わない方が良さそうです。

また、メソッド内で Error が発生する場合も呼び出し元には「throwError」として返す必要があるかと。

処理失敗の通知に「throwError」を使うべきか

今回試した「empty」、「throwError」、「throw new Error」の中では「throwError」を使うのがよさそうです。

が、まだ処理失敗の通知に「throwError」を使うべきか、というのはよくわかっておりません。

もう少しサンプルなど漁った方がよさそうですね。

TypeOrm を追加する

はじめに

今回は TypeOrm を追加して Database にアクセスできるようにしますよ。

Angular 10 + NgRx + Nest.js でプロジェクトを作る

TypeOrm を使う

Install

前回と同じくいくつかパッケージをインストールします。

Nest.js のプロジェクトに統合するためのパッケージもあるので合わせてインストールします。

npm install --save @nestjs/typeorm typeorm pg

Configuration

前回は「ormconfig.json」を使って TypeOrm の設定( Database の接続文字列など)を行っていました。

Nest.js のプロジェクトでは、「app.module.ts」の中で設定することもできます。

server/app.module.ts


import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { Workflow } from './entities/workflow.entity';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    }),
    TypeOrmModule.forRoot({
      type: "postgres",
      host: "localhost",
      port: 5432,
      username: "test",
      password: "test",
      database: "Example",
      entities: [Workflow],
      synchronize: true,
      logging: true,
      cache: false
    })
  ],
  controllers: [],
  providers: []
})
export class ApplicationModule {}

結局 JSON ファイルを選択した

結局、前回同様「ormconfig.json」を使うことにしました。

理由は、マイグレーションファイルを使った Database 更新をしたい場合、 TypeOrm のコマンドを使うのですが、この時コマンドからは「app.module.ts」の内容が参照できません(多分)。

ということで、いずれにしても「ormconfig.json」が必要になるわけですね。

ormconfig.json


{
    "type": "postgres",
    "host": "localhost",
    "port": 5432,
    "username": "test",
    "password": "test",
    "database": "Example",
    "entities": ["dist/**/entities/*.entity{.ts,.js}"],
    "migrations": ["server/migrations/*.{.ts,.js}"],
    "synchronize": true,
    "logging": true,
    "cache": false,
    "cli": {
        "entitiesDir": "server/entities",
        "migrationsDir": "server/migrations"
    }
}

なお「ormconfig.json」を使う場合の「app.module.ts」はこんな感じです。

server/app.module.ts


~省略~
@Module({
  imports: [
    ~省略~
    TypeOrmModule.forRoot()
  ],
  controllers: [],
  providers: []
})
export class ApplicationModule {}

Entity クラスを追加する

server/entities/workflow.entity.ts


import {Entity, PrimaryGeneratedColumn, Column, OneToMany, UpdateDateColumn} from "typeorm";

@Entity("Workflow")
export class Workflow {
    @PrimaryGeneratedColumn()
    id: number = -1;
    @Column({ type: 'text', nullable: false })
    createUserMail: string = '';
    @Column({ type: 'timestamp with time zone', nullable: true })
    circulationLimit: Date|null = null;
    @Column({ type: 'timestamp with time zone', nullable: false })
    lastUpdateDate: Date = new Date();
    
    public create(createUserMail: string, circulationLimit: Date|null) {
        if (createUserMail == null ||
            createUserMail.length <= 0) {
            throw new Error("Can not set createUserMail empty");
        }
        this.createUserMail = createUserMail;
        this.update(circulationLimit);
    }
    public update(circulationLimit: Date|null) {
        this.circulationLimit = circulationLimit;
        this.lastUpdateDate = new Date();
    }
}

「nest generate」を使う

TypeOrm のクラスにアクセスするため、いくつか Nest.js のプロジェクトにクラスを追加していきます。

が、「nest generate ~」を実行すると、ファイルが「src」ディレクトリにできてしまう。。。(´・ω・`)
サーバー側のファイルを置いている「server」ディレクトリ以下にファイルを出力するにはどうすれば・・・?

結果としては、プロジェクト直下に「nest-cli.json」を置くことで解決しました。

nest-cli.json


{
    "collection": "@nestjs/schematics",
    "sourceRoot": "server"
}

「@nestjs/testing」の追加

Controller や Service などのクラスを追加したところ、エラー発生。
どうも一緒に追加されたテストクラスが必要としている、「@nestjs/testing」が足りない模様。

ということでインストールしました。

npm install --save-dev @nestjs/testing

Repository クラスから Database にアクセスする

Database にアクセスする(失敗)

まず Database にアクセスするための Controller や Service を追加します。

server/workflow/workflow.controller.ts


import { Controller, Get } from '@nestjs/common';
import { WorkflowService } from './workflow.service';
import { Workflow } from '../entities/workflow.entity';

@Controller('workflow')
export class WorkflowController {
    constructor(private readonly service: WorkflowService) {
    }
    @Get("search")
    public async getWorkflowItems(): Promise<Workflow[]> {
        return await this.service.findAll();
    }
}

server/workflow/workflow.service.ts


import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Workflow } from '../entities/workflow.entity';
import { Repository } from 'typeorm';
import { of } from 'rxjs';

@Injectable()
export class WorkflowService {
    constructor(
        @InjectRepository(Workflow)
        private workflowRepositiory: Repository<Workflow>
    ){}
    public findAll(): Promise<Workflow[]> {
        return this.workflowRepositiory.find();
    }
}

server/app.module.ts


...
import { WorkflowController } from './workflow/workflow.controller';
import { WorkflowService } from './workflow/workflow.service';
import { Workflow } from './entities/workflow.entity';

@Module({
  imports: [
...
  ],
  controllers: [WorkflowController],
  providers: [WorkflowService]
})
export class ApplicationModule {}

OK。では実行してみます。

…エラーが発生しました(´・ω・`)

...
[Nest] 17376   - 07/11/2020, 8:22:27 PM   [ExceptionHandler] Nest can't resolve dependencies of the WorkflowService (?). Please make sure that the argument WorkflowRepository at index [0] is available in the ApplicationModule context.

Potential solutions:
- If WorkflowRepository is a provider, is it part of the current ApplicationModule?
- If WorkflowRepository is exported from a separate @Module, is that module imported within ApplicationModule?
  @Module({
    imports: [ /* the Module containing WorkflowRepository */ ]
  })
 +570ms
Error: Nest can't resolve dependencies of the WorkflowService (?). Please make sure that the argument WorkflowRepository at index [0] is available in the ApplicationModule context.

Potential solutions:
- If WorkflowRepository is a provider, is it part of the current ApplicationModule?
- If WorkflowRepository is exported from a separate @Module, is that module imported within ApplicationModule?
  @Module({
    imports: [ /* the Module containing WorkflowRepository */ ]
  })

    at Injector.lookupComponentInParentModules (C:\Users\example\Documents\workspace\proofreading-workflow\node_modules\@nestjs\core\injector\injector.js:190:19)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
[nodemon] app crashed - waiting for file changes before starting...

依存を解決する

どうも Repository クラスが DI できてないようです。

結局「app.module.ts」にモジュールを追加しました。

server/app.module.ts


...
import { Workflow } from './entities/workflow.entity';

@Module({
  imports: [
...
    TypeOrmModule.forRoot(),
    TypeOrmModule.forFeature([Workflow])
  ],
  controllers: [ProofreaderController, WorkflowController],
  providers: [ProofreaderService, WorkflowService]
})
export class ApplicationModule {}

ようやく Database にアクセスできるようになりました :).

参照