vaguely

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

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

はじめに

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

Environments

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

Base Project

Company.cs

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

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

Genre.cs

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

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

Book.cs

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

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

BookStoreContext.cs

using Microsoft.EntityFrameworkCore;

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

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

ISampleCreator.cs

using System.Threading.Tasks;

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

SampleCreator.cs

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

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

HomeController.cs

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

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

}

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

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

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

Startup.cs

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

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

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

appsettings.Development.json

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

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

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

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

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

EXPLAIN, ANALYZE

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

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

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

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

f:id:mslGt:20201010215559p:plain

実行例

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

Sample 1

SearchedCompany.cs

using BookStoreSample.Models;

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

BookSearchSample.cs

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

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

Generated SQL

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

Planning Time

  • 0.942 ms

Execution Time

  • 4941.233 ms

Sample 2

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

BookSearchSample.cs

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

Generated SQL

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

Planning Time

  • 0.341 ms

Execution Time

  • 2209.166 ms

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

Raw SQL クエリの実行

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

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

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

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

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

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

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

ではどうするか。

Entity Framework Core で Raw SQL を実行する

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

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

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

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

SearchedCompany.cs

using System;
using System.ComponentModel.DataAnnotations;

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

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

BookStoreContext.cs

using BookStoreSample.Books;
using Microsoft.EntityFrameworkCore;

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

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

BookSearchSample.cs

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

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

Raw SQL を省いた場合は?

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

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

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

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

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

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

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

Where 句

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

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

BookSearchSample.cs

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

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

Generated SQL queries

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

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

はじめに

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

Environments

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

Base project

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

Startup.cs

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

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

Product.cs

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

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

HomeController.cs

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

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

IProductService.cs

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

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

ProductService.cs

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

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

_Layout.cshtml

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

Index.cshtml

<div>Hello World</div>

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

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

f:id:mslGt:20200913095625p:plain

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

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

dotnet new sln -n XUnit
dotnet sln add XUnitSample

Add xUnit project

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

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

f:id:mslGt:20200913095703p:plain

Moq も追加します。

XUnitSampleTest.csproj

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

テストクラスを作る

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

HomeControllerTest.cs

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

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

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

f:id:mslGt:20200913095737p:plain

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

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

f:id:mslGt:20200913095803p:plain

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

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

Resources

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

はじめに

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

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

Environments

Base Project

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

ルーティング

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

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

RouteConfig.cs

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

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

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

RouteConfig を使う

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

Global.asax

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

Global.asax.cs

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

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

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

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

Controller クラスを追加する

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

ProductsController.cs

using System.Web.Mvc;

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

「/」始まり

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

ディレクトリの名前

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

ProductsController.cs

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

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

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

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

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

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

ログ出力 (NLog)

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

Install

  • NLog.Web ver.4.9.3

設定

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

Web.config

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

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

nlog.config

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

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

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

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

インスタンスを作る

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

ProductsController.cs

using System.Web.Mvc;
using NLog;

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

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

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

DI (Dependency Injection)

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

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

Install

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

依存されるクラスを登録

IProductsService.cs

namespace NetFrameworkSample.Products
{
    public interface IProductsService
    {
    }
}

ProductsService.cs

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

UnityConfig.cs

using System;
using Unity;
using NetFrameworkSample.Products;

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

依存クラスを挿入する

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

ProductsController.cs

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

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

インスタンスを登録する

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

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

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

Type(Instance) lifetime

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

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

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

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

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

おわりに

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

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

はじめに

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

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

ルーティング

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

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

HomeController.cs

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

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

_Host.cshtml

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

App.razor

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

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

MainLayout.razor

@inherits LayoutComponentBase
@Body

SearchPage.razor

@page "/SearchPage";

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

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

_Layout.cshtml

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

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

_Host.cshtml

@using BlazorSample.Views;

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

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

ViewData

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

HomeController.cs

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

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

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

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

App.razor

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

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

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

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

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

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

SearchPage.razor

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

SearchResultRow.razor

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

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

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

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

SearchPage.razor.cs

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

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

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

SearchPage.razor

@page "/SearchPage";

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

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

【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 許容を避けてみようかな、とは思っています。

参照