【C#】 SQLite-net (sqlite-net-pcl) を試す
はじめに
この記事は C# Advent Calendar 2020 - Qiita の三日目の記事です。
いつもの諸事情により SQLite を扱うことになりました。
条件はこんな感じ。
- .NET, Mono で SQLite を扱うことができる
- Unity でも動く
- 今も更新されている
条件 2.がなければ EntityFramework Core 一択という気持ちはあるのですが、今回は SQLite-net を使うことにしました。
なお、以下のサンプルは .NET 5 のコンソールアプリですが、Unityでも(インストール以外は)同じように使える。。。はずです。
Environments
準備
インストール
README に従って、 NuGet が使える環境では NuGet で、Unity のように NuGet が使えない場合は SQLite.cs をプロジェクトに追加します。
DI を追加する
ありがたいことに .NET Core 、 .NET 5 ではこの通り コンソールアプリでも DI が使えるので、「Microsoft.Extensions.DependencyInjection」を NuGet で追加して、 DI を使えるようにします。
Program.cs
using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using SqliteSample.Controllers; namespace SqliteSample { class Program { static async Task Main(string[] args) { var servicesProvider = BuildDi(); using (servicesProvider as IDisposable) { var mainController = servicesProvider.GetRequiredService<MainController>(); await mainController.StartAsync(); } } private static IServiceProvider BuildDi() { var services = new ServiceCollection(); services.AddTransient<MainController>(); return services.BuildServiceProvider(); } } }
Database の生成と接続
sqlite-net-pcl は Syncronous API と Asynchronous API の二つの API を持っており、それぞれ専用のクラスがあります。
今回は Asynchronous API の方を試してみます。(Unity の場合は Syncronous API の方が良いと思いますが)
で、まず Database ファイルが存在しない場合は新規作成、存在する場合はそのまま使うようにしてみます。
DbContext.cs
using System; using System.IO; using System.Threading.Tasks; using SQLite; namespace SqliteSample.Models { public class DbContext: IDisposable { private readonly SQLiteAsyncConnection db; public DbContext() { var path = Path.Combine(Directory.GetCurrentDirectory(), "Databases/memory.db"); // if the database file isn't exist, a new database file will be created. this.db = new SQLiteAsyncConnection(path, SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex | SQLiteOpenFlags.ReadWrite ); } } }
参照専用のデータを持つテーブルがある場合、「SQLiteAsyncConnection」実行より前にファイルの有無を調べて、ない場合のみデータ追加、といった処理を行うと良さそうです。
Entity Framework Core と同じように使いたいため、このクラスを DI で依存クラスに挿入できるようにしておきます。
Program.cs
using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using SqliteSample.Controllers; using SqliteSample.Models; namespace SqliteSample { class Program { ... private static IServiceProvider BuildDi() { var services = new ServiceCollection(); services.AddTransient<MainController>(); services.AddScoped<DbContext>(); return services.BuildServiceProvider(); } } }
テーブル生成とマイグレーション
次はテーブルの生成とマイグレーションです。
こちらも先ほどと同じような感じで、テーブルが存在しなければ作成、存在すればそれを使う、という処理を自動で行ってくれます。
なおかつテーブルに変更があった場合はそのマイグレーションも自動で行ってくれます。
Author.cs
using SQLite; namespace SqliteSample.Models { [Table("author")] public class Author { [PrimaryKey, AutoIncrement] [Column("id")] [NotNull] public int Id{ get; set; } [Column("name")] [NotNull] public string Name { get; set; } = ""; } }
Book.cs
using SQLite; namespace SqliteSample.Models { [Table("book")] public class Book { [PrimaryKey, AutoIncrement] [Column("id")] [NotNull] public int Id{ get; set; } [Column("name")] [NotNull] public string Name { get; set; } = ""; public int AuthorId{get; set; } } }
なお、「SQLiteAsyncConnection」を使う場合、テーブル生成もすべて非同期となります。
DbContext.cs
public class DbContext: IDisposable { private readonly SQLiteAsyncConnection db; public DbContext() { ... } public async Task InitAsync() { await db.CreateTableAsync<Book>(); await db.CreateTableAsync<Author>(); } } }
コンストラクタで呼べないので、「Program.cs」から実行することにします。
Program.cs
... class Program { static async Task Main(string[] args) { var servicesProvider = BuildDi(); using (servicesProvider as IDisposable) { var dbContext = servicesProvider.GetRequiredService<DbContext>(); await dbContext.InitAsync(); var mainController = servicesProvider.GetRequiredService<MainController>(); await mainController.StartAsync(); } } ...
マイグレーションの注意点
カラムの追加については問題なく行えるのですが、リネーム、削除については注意が必要です。
というのも、リネームすると別カラムとして追加され、1.カラムにデータ追加 -> 2.削除 -> 3.同じ名前でカラム追加 とすると1.のデータが復活します。
ということで、基本的にリネームや削除が必要な場合、中のデータを引き上げて一旦削除 -> 再作成して登録、とする必要がありそうです。
外部キーの設定
結果から言うと、少なくとも .NET 5 環境 (sqlite-net-pcl 環境?)で外部キーを扱うのは難しそうです。
もともと sqlite-net-pcl は該当の機能を持っておらず、「OneToMany」や「ManyToOne」を追加できる「SQLiteNetExtensions」を試したところ、ビルド時にエラーとなりました。
まぁそもそも PostgreSQL などで行うような複雑な構造(いやそれが PostgreSQL なら良いかは別として)を SQLite で持つべきではない気がするので、下手に頑張るよりシンプルさを心がける方が良いかもしれません。
テーブルにアクセスする
EntityFramework Core ぽく扱えるようにするため、 DbContext クラスにもう少し追加します。
DbContext.cs (Full)
using System; using System.IO; using System.Threading.Tasks; using SQLite; namespace SqliteSample.Models { public class DbContext: IDisposable { private readonly SQLiteAsyncConnection db; public DbContext() { var path = Path.Combine(Directory.GetCurrentDirectory(), "Databases/memory.db"); // if the database file isn't exist, a new database file will be created. this.db = new SQLiteAsyncConnection(path, SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex | SQLiteOpenFlags.ReadWrite ); } public async Task InitAsync() { await db.CreateTableAsync<Book>(); await db.CreateTableAsync<Author>(); } public async void Dispose() { await db.CloseAsync(); } // TransactionやCreate, Update, Delete はこちらを使う public SQLiteAsyncConnection Database => db; // Readはこちらから public AsyncTableQuery<Book> Books => db.Table<Book>(); public AsyncTableQuery<Author> Authors => db.Table<Author>(); } }
。。。ほとんど「SQLiteAsyncConnection」を渡して何とかするみたいになってしまいますが、まぁ雰囲気で。
とにあれデータを追加して検索してみます。
MainController.cs (Full)
using System; using System.Threading.Tasks; using SqliteSample.Models; namespace SqliteSample.Controllers { public class MainController { private readonly DbContext db; public MainController(DbContext db) { this.db = db; } public async Task StartAsync() { await AddSamplesAsync(); // maybe I can't get the result as IEnumerable<Book> foreach(var book in (await db.Books.Where(b => b.Id > 0).ToArrayAsync())) { Console.WriteLine($"Book ID: {book.Id} Name: {book.Name} AuthorId: {book.AuthorId}"); } } private async Task AddSamplesAsync() { var author = new Author { Name = "Ian Griffiths" }; await db.Database.RunInTransactionAsync(connection => { connection.Insert(author); connection.Insert(new Book { Name = "Programming C# 8.0", AuthorId = author.Id, }); }); } } }
おわりに
多少制限はあるものの、想像以上に便利に使えるライブラリが存在してくれていることに感謝。
特に Unity でもクラス一つ追加するだけで使えるというのは本当にありがたい。
あえて難点を挙げるとすればググりにくいことw
ともあれ、今後も状況に合わせて Entity Framework Core と使い分けていきたいと思います。
明日は hiro_matsuno2 さんです。
よろしくお願いいたします(..)_。
【PostgreSQL】【EntityFramework Core】SQL クエリのデバッグ 1
はじめに
作ったアプリにパフォーマンス上の問題が発生している場合、ボトルネックを探索する必要があるわけなんですが、今回は Entity Framework Core の処理を計測してみたいと思います。
Environments
- .NET ver.5.0.100-rc.1.20452.10
- Microsoft.EntityFrameworkCore ver.5.0.0-rc.1.20451.13
- Npgsql.EntityFrameworkCore.PostgreSQL ver.5.0.0-rc1
- NLog.Web.AspNetCore ver.4.9.3
- Microsoft.AspNetCore.Mvc.NewtonsoftJson ver.5.0.0-rc.1.20451.17
Base Project
Company.cs
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace BookStoreSample.Models { public class Company { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } [Required] public string Name { get; set; } public List<Book> Books { get; set; } = new List<Book>(); } }
Genre.cs
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace BookStoreSample.Models { public class Genre { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } [Required] public string Name { get; set; } } }
Book.cs
using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace BookStoreSample.Models { public class Book { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } [Required] public string Name { get; set; } [Column(TypeName = "timestamp with time zone")] public DateTime? PublishDate { get; set; } [ForeignKey(nameof(Company))] public int CompanyId { get; set; } [ForeignKey(nameof(Genre))] public int GenreId { get; set; } public Company Company { get; set; } public Genre Genre { get; set; } } }
BookStoreContext.cs
using Microsoft.EntityFrameworkCore; namespace BookStoreSample.Models { public class BookStoreContext: DbContext { public BookStoreContext(DbContextOptions<BookStoreContext> options) : base(options) { } public DbSet<Company> Companies => Set<Company>(); public DbSet<Genre> Genres => Set<Genre>(); public DbSet<Book> Books => Set<Book>(); } }
とりあえずサンプルのデータを生成する
ISampleCreator.cs
using System.Threading.Tasks; namespace BookStoreSample.Samples { public interface ISampleCreator { Task CreateAsync(); } }
SampleCreator.cs
using System; using System.Threading.Tasks; using BookStoreSample.Models; namespace BookStoreSample.Samples { public class SampleCreator: ISampleCreator { private readonly BookStoreContext _context; public SampleCreator(BookStoreContext context) { _context = context; } public async Task CreateAsync() { using(var transaction = _context.Database.BeginTransaction()) { try { for(var i = 0; i < 1000; i++) { _context.Companies.Add(new Company { Name = $"Company: {i}", }); } for(var i = 0; i < 1000; i++) { _context.Genres.Add(new Genre { Name = $"Genre: {i}", }); } await _context.SaveChangesAsync(); var random = new Random(); for(var i = 0; i < 1000000; i++) { _context.Books.Add(new Book { Name = $"Book: {i}", PublishDate = DateTime.Now, CompanyId = random.Next(999) + 1, GenreId = random.Next(999) + 1, Price = 600, }); } await _context.SaveChangesAsync(); transaction.Commit(); } catch(Exception ex) { transaction.Rollback(); throw ex; } } } } }
HomeController.cs
using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using BookStoreSample.Samples; namespace BookStoreSample.Controllers { public class HomeController: Controller { private readonly ILogger<HomeController> _logger; private readonly ISampleCreator _sample; public HomeController(ILogger<HomeController> logger, ISampleCreator sample) { _logger = logger; _sample = sample; } [Route("Sample")] public async Task CreateSamples() { await _sample.CreateAsync(); } } }
生成された SQL クエリをログ出力する
EntityFramework Core は内部的に C# のコードから SQL クエリを生成するわけですが、実際どのようなものが生成されているのかが知りたいところ。
そのために「EnableSensitiveDataLogging」を使います。
Startup.cs
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; ... namespace BookStoreSample { public class Startup { private readonly IConfiguration configuration; public Startup(IConfiguration config) { configuration = config; } public void ConfigureServices(IServiceCollection services) { services.AddControllers() .AddNewtonsoftJson(options => options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore); services.AddDbContext<BookStoreContext>(options => { options.EnableSensitiveDataLogging(); options.UseNpgsql(configuration["ConnectionStrings"]); }); ... } ...
この SQL クエリ出力のログレベルは Information なので、 Microsoft のログレベルを Information 以下にしておく必要があります(デフォルトだと Warning )。
appsettings.Development.json
{ "Logging": { "LogLevel": { "Default": "Debug", "Microsoft": "Information", "Microsoft.Hosting.Lifetime": "Information" } } }
で、例えばこのようなコードを実行すると。。。
public async Task<List<SearchedCompany>> SearchCompaniesAsync() { return await _context.Companies .ToListAsync(); }
こんなログが出力されるわけです。
... 2020-10-06 18:20:17.1528|20101|INFO|Microsoft.EntityFrameworkCore.Database.Command|Executed DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT c."Id", c."Name" FROM "Companies" AS c |url: http://localhost/Company/Search|action: SearchCompany ...
EXPLAIN, ANALYZE
出力された SQL クエリのパフォーマンスを見たい場合に使えるのが「EXPLAIN」と「ANALYZE」。
EXPLAIN ANALYZE SELECT c."Id", c."Name" FROM "Companies" AS c
SQL クエリの前につけて(例えば) PgAdmin4 で実行してやると下記のような結果が得られます。
実行時間やどのキーを使って検索しているかなどの情報が得られます(今回は触れませんが)。
実行例
試しにいくつか実行してみます。
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>
テストプロジェクトを追加する
まずはテスト用のプロジェクトを追加するわけですが、既存のプロジェクト以下に作ってしまうとエラーが発生します。
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
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」コマンドでテスト結果が得られます。
Visual Studio Code でテストを実行する
「dotnet test」コマンドでも問題はないのですが、やっぱり下記のような見た目が欲しいところ。
ということで、 VSCode で拡張機能を追加してみます。
( Visual Studio でやればもっと簡単なのでしょうが、 VSCode が好きなので。。。)
「.NET Core Test Explorer」という名前の拡張機能は二つあるのですが、テスト前にビルドやテストの更新などをしなくてよい、という点でこちらの方が好きですね。
Resources
- Home > xUnit.net
- Getting started: .NET Core with command line > xUnit.net
- Moq : Mocking Framework for .NET - Qiita
- GitHub - moq/moq4: Repo for managing Moq 4.x
- dotnet テストと xUnit を使用した .NET Core での単体テスト C# コード - .NET Core | Microsoft Docs
- ASP.NET Core のコントローラーのロジックをテストする | Microsoft Docs
- Moq : Mocking Framework for .NET - Qiita
- c# - What's the idiomatic way to verify collection size in xUnit? - Stack Overflow
- xUnit.net でユニットテストを始める - Qiita
【.NET Framework】Back to ASP.NET Framework 1
はじめに
これまでの通り、普段プライベートのプログラミングでは .NET Core を使っているわけですが、今日は .NET Framework に挑戦しますよ。
理由はまぁ、お察しください。
Environments
- Visual Studio 2019 Community
- .NET Framework ver.4.8
Base Project
Empty テンプレートでプロジェクトを作ります。
すると名前に恥じない Empty っぷりで、そのまま実行するとエラーになります(localhost:59522)。
ルーティング
まずルーティングですが、 「RouteConfig.cs」で定義します。
とその前に、「ASP.NET MVC」を NuGet でインストールしておきます。
- ASP.NET MVC ver.5.2.7
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 を使ってログ出力しますよ。
- NLog
- GitHub - NLog/NLog.Web: NLog integration for ASP.NET & ASP.NET Core 1-3
- Tutorial · NLog/NLog Wiki · GitHub
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」で表示できます。
- ASP.NET Core Blazor のルーティング | Microsoft Docs
- ASP.NET Core Razor コンポーネントの作成と使用 | Microsoft Docs
- アプリのプロジェクト構造 Blazor | Microsoft Docs
_Layout.cshtml
現状 SearchPage の構造はこんな感じになってます。
ただ、他に 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 ではコンポーネントの中に子供のコンポーネントを持たせることができます。
では 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 でアップデートがたくさん入ったっぽいこともあり、試してみることにしました。
- ASP.NET Core Updates in .NET 5 Preview 7 | ASP.NET Blog
- Blazor | Build client web apps with C# | .NET
実際使うとすると、既存のプロジェクトに追加(置き換え)していくことになると思うので、 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
ボタンをクリックすると div 要素の値が変わります。
IE11
いい加減フロントエンド関連のことを試すとき、まず IE を考えるのをやめたいところですが。。。
ともかく、 IE11 で開くと、 Blazor の要素は表示されるものの、ボタンをクリックしても動作しません。
対策は色々あると思いますが、その一つである Blazor.Polyfill を試します。
- GitHub - Daddoon/Blazor.Polyfill: Polyfills for Blazor (for Internet Explorer 11 support and some other browsers)
- ASP.NET Core Blazor supported platforms | Microsoft Docs
_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
- Microsoft.Extensions.Configuration ver.5.0.0-preview.7.20364.11
- Microsoft.Extensions.Configuration.FileExtensions ver.5.0.0-preview.7.20364.11
- Microsoft.Extensions.Hosting ver.5.0.0-preview.7.20364.11
- Microsoft.Extensions.Configuration.Json ver.5.0.0-preview.7.20364.11
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(); } } }
- ConfigurationBuilder クラス (Microsoft.Extensions.Configuration) | Microsoft Docs
- FileConfigurationExtensions.SetBasePath(IConfigurationBuilder, String) メソッド (Microsoft.Extensions.Configuration) | Microsoft Docs
なお 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
- Microsoft.EntityFrameworkCore ver.5.0.0-preview.7.20365.15
- Microsoft.EntityFrameworkCore.Relational ver.5.0.0-preview.7.20365.15
- Microsoft.EntityFrameworkCore.Abstractions ver.5.0.0-preview.7.20365.15
- Npgsql.EntityFrameworkCore.PostgreSQL ver.5.0.0-preview7-ci.20200722t163648
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 などは相当大きいとは思いますが)が組み合わさって作られているのだ、というまぁ当たり前といえば当たり前のことに気づいたというか。
今後それぞれもう少し突っ込んでみていきたいところ。