vaguely

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

"他人"は見た! 雪餅ののめゲーム配信 2

はじめに

今回も直近で見た雪餅ののめさんのゲーム配信について、つらつらと書き残していくことにしますよ。

夜廻三

www.youtube.com

#2の続きです。

前回からちょっと間が空いたということもあってこれまでのあらすじ…に混じった「わ~」からスタートw

ホラゲー(正確にはホラゲーではないらしいですが)でも、作品によって悲鳴や喋りの入り方も違ってくるのが面白いなぁと思ったりw

大ピンチで逃げまとう中リクエストしてくる曲のチョイスと、ネタバレはダメといいつつ天敵のヤツが出てくる場所だけは躊躇なくコメント欄に聞いていく様子は流石に笑わざるを得ないww

男女や動物のお化けが登場しますが、それぞれ辛い過去が示唆されますが、主人公の思い出を取り戻すきっかけになることとは関係があるのでしょうか…?

とか考えていたら、ボス戦の興奮っぷりにどうでも良くなってきましたww

楽しそうなのは良いとして、それまでの流れは吹っ飛びましたねw

最後のお姉さんとの記憶で深まる謎が解ける日は来るのか…まだまだ続きが気になりますね。

アンリアルライフ

www.youtube.com

前回の続きから。

たどたどしいあらすじと、床屋さんの前にあるアレの名前をものすごく嬉しそうに教えてくれるところからスタートw

ふんわりとした情報を元に幻想図書館を目指すハルと195。

レベルの上がった謎解きに心折れそうになるなど、様々な困難が襲い掛かります…(諦めないでくれてよかったw)。

なお前回大活躍だったエビの衝撃の事実が判明。そりゃみんな夢中になるはずだよ...w

図書館の最後で明らかになったいくつかの事柄や、ハルが新たにできるようになったこと、消えた本などまた気になるラスト…。

それはそれとして、図書館で見つかりまくり、悲鳴をあげまくるのめさんには大変申し訳ないと思いつつ笑ってしまいましたwww

あと、途中に出てくる友達の定義により、改めて私に友達がいないことが明ら(消滅)

Poppy Playtime

3Dサバイバルホラーゲームだそうです(詳しくは下記リンク参照)。 2022年6月現在Chapter2まで配信されており、今後続きが配信され次第(Chapter1からChapter2が出るまでの期間は半年くらいとのこと)、続きも配信されることになるハズ。

Chapter1

www.youtube.com

配信当時はのめさんがプレイしたい、という話だけで微塵も調べてなかった+日本語版無し+英語字幕も無し、ということで、 とりあえず古びたおもちゃ工場から逃げようとする、というくらいしかストーリーがわからないまま見てましたwww

珍しくのめさんが英語を話す(読む)様子も見られます( ˘ω˘ )

謎解きをしながら進めていくのですが、大まかな概要はともかく、ストーリーをそこまで深く理解はしていなさそうながら割とサクッとクリアしていくのはすごいです。

あと、現在進行中のゲーム配信中、ダントツににぎやかで、ボス戦とかこんなに楽しそうにプレイするものだったのかwww、というのも見どころ。

ひどい目に遭い勝ちなブギーボット君は、今後何かのキーを握ることになるのか、単に生贄になってるだけなのかw

Chapter2

www.youtube.com

相変わらずにぎやかで楽しそうです。…ホラゲですがw

Chapter1よりレベルアップした謎解きですが、サクッと解いていてすごいのは変わらず。 (上手くいかなかったときに出る「は?」好き)

中ボス?的なステージがいくつかあり、覚えるのが大変、敵が見えづらいなどのハードルにより苦戦する場面も…。

とはいえ最後にはバッチリクリアして次の場所へ。

途中で助けてくれたおもちゃは今後どう関わってくるのか、最後(次のChapterへの伏線)も何がどうなったのか気になって夜しか眠られませんね。

あと、ほぼゲーム自体には関係ありませんが、序盤に出てくるもふもふへのこだわりも聞きどころ…かもしれないw

マリオカート

のめさんの配信では珍しい?、視聴者参加型の配信です。

それが原因なのか、いろんなものを賭けたり大物?が参加したり地獄が生まれがちなところが魅力ですねw

[マリオカート8DX]クリスマス、リア充爆破してみたくないですか?[新人VTuber]

www.youtube.com

クリスマスなのでリア充を爆破する配信です。

何を言っているのかわからねーと思うがry

YouTubeアーカイブでは最初のマリカー配信がこれ、というのがらしいというかなんというかw

ボム兵でオンライン対戦に集まってきたリア充?を爆破しまくるはずが…。

意図せず大物(大体偽物)コラボも実現したりとリアルかどうかは置いといて、楽しそうなクリスマスですね( ˘ω˘ )

[マリカ8DX]7位以下で黒歴史暴露!?地獄のマリオカート[新人VTuber]

www.youtube.com

あえて言おう。どうしてこうなったww

とにかく必死感がすごく、2022年6月時点で残っているマリカーアーカイブ中ダントツなのではw

果たして暴露された黒歴史とは…?

なお、個人的にはその内容自体より、のめさんの反応が良かったです( ˘ω˘ )

[マリオカート8DX]七位以下で即終了!ま、一時間は余裕かな~~~![新人VTuber]

www.youtube.com

タイトルと配信時間がすべてを物語っていますねwww

[マリオカート8DX]リベンジじゃなくて普通に走るか…[新人VTuber]

www.youtube.com

さっきのは夢だったんだ…。

ということで、タイトル通り即終了ではなく落ち着いて雑談しながらみんなでマリカー

てぇてぇ哲学とちょいちょい出る言い訳のめさんが個人的見どころだと思います( ˘ω˘ )

【参加型/マリカ8DX】初見さんOK!みんなで楽しく殴り合い【VTuber/ユキノメ】

www.youtube.com

久しぶりだったようで、操作の仕方を思い出すところからスタートw

今回は特に何かを賭けたりせず、みんなで仲良くバチボコ殴りあっています(๑•̀ㅂ•́)و✧

やっぱり「わ゛~」好き。

【視聴者参加型|マリカ8DX】追加コース第一弾!ここで王になる【雪餅ののめ/VTuber

www.youtube.com

「雪餅ののめ」になって初のマリカー配信。

と同時に、Live2D解禁の条件であった、チャンネル登録者数2500人突破を達成した記念の配信でもあります。

が、マリカー自体は特に忖度されることもなく、操作方法を覚えていない、とある意味いつも通りなのは流石というべきかw

ユキノメ時代との配信スタイルの違いもちょっとした見どころかもしれませんね。

あとコメント見ながら+喋りながらマリカーとか、ツイートしながらマリカーとか、常人離れ技も流石ですww

みんなのリズム天国

みんなのリズム天国】ノれるやつだけ着いてきな!!!【VTuber

youtu.be

みんなのリズム天国シリーズ。

ひたすらリズムをとるのめさんがかわいい配信。 ねじ締めの時の「ヨッシャ」とか、南極の揺れ感とか好き。

あと、工場ではほんのりブラックな香りも漂ってきて、やはりゲームの中でも…(;゚д゚)ゴクリ… となったりならなかったりw

過去作もやりこんでいる(らしい)だけあって、基本ハイレベルでサクサク進んでいきます(すごい)。 が、やっぱりヤツの登場で一気に(気持ち的に)ピンチになるのは何ともwww

みんなのリズム天国】サクッとヘブンにGO【VTuber

youtu.be

前回の続きである 8th ~ 10th に挑戦。

タイトル通りサクッと終わりすぎて本人もびっくりw

とはいえ、流石に前回より難易度は上がっている、ということで、ク〇がよ~も飛び出したりしますw

【はじめての方向け】リストにも含まれている配信ですが、続きだけあってリズム天国自体の説明や練習部分は省かれています(8th ~ 10th には基本的に練習がない)。

ということで、私のようにリズム天国をあまり知らない方は、最初から見る方が良いかもしれません。

逆に、リズム天国は知っていて、サクッとのめさんのプレイを見たい方はこちらをおススメしておきます。

みんなのリズム天国】目指せオールパーフェクト!!!【VTuber

www.youtube.com

All perfect 耐久!ということで全50ステージをノーミスでクリアしていく、という配信。

流石の長時間ではありますが、実は大部分は一発クリアしてたりしてすごい。

ねじ締めの時の「ヨッシャ」「ヤッタ」は相変わらず好きなのですが、本人も得意なのかサッとクリアされてしまうので、ちょっと寂しくはありますw

しかしながら、やはり10th Remixは鬼門…。

休憩がてら告知ツイートの文面をどうするかの話をしたりするあたり、バックステージを覗いている感じで楽しいですね。

みんなのリズム天国】のんびりまったりノリノリお遊び【VTuber

www.youtube.com

無事APクリア!ということで、クリア後に解禁されるミニゲーム?をまったり遊ぶ配信。

数年越しの謎が解けたり、説明もなくよくわからないままクリアしてみたり、耐久配信の打ち上げのような雰囲気がありますね。

"他人"は見た! 雪餅ののめゲーム配信 1

はじめに

北の狼VTuberこと雪餅ののめさんの配信を見始めて早一か月。

ユキノメ時代も含めたアーカイブをいくつか見たので、思ったこと(感想)をつらつら書いていくことにします。 (Twitterに書こうかと思ったけど書ききれんしスレッドにしていくのも面倒なんよね。。。)

本当は見たことない人のために、「~な方におススメです」みたいなことを書きたいけれどもそれは他の方にお任せします( ˘ω˘)スヤァ

www.youtube.com

note.com

なお雑談に定評のあるのめさんですが、ゲーム配信の同接も増えるといいよね、ということでゲーム配信を取りあげることにします。

あと完結してシリーズとしてまとめられているものは一括で書く予定で、書く順番はこの一か月で見たものになるのでバラバラです(あとで見直して個別で書いたりはするかもしれませんが)。

ちなみに家〇婦は見た!は一度も見たことありません( ˘ω˘)スヤァ

アンリアルライフ

youtu.be

感想は一部Twitterに書いたのでそちらを貼り付け。

あと、ドット絵ながら(だから?)世界がとてもきれいで、途中に出てくる海の景色などとても良い。

あの青色が良いんだよなぁ~(語彙力)。

まだ先日始まったばかりなので、ここから合流するのは良いかもですね。

夜廻三

こちらも現在進行形の配信。

#1

www.youtube.com

陰鬱としたいじめの現場から始まる…のですが、すずき(主人公)が強すぎて、もしもっと自由に行動させられるシステムなら格ゲー始まっちゃう…(´・ω・`) という心配からスタートwww

お化けに対する独特な評価があったり、すずきが小学校?中学校?の女の子なためかおかん餅あるいはお姉餅があったり。

あとびっくりはしたけどガチ悲鳴ではないくらいのときの「わ~~~~」好き。

ついでに本編には一切関係ないけど、ゲーム配信としては貴重な?ハリボテ時代の配信。

#2

www.youtube.com

Live 2D解禁ということもあって、主人公の名前を忘れたり、ロリボイスを披露してみたり、青ざめてみたりと小ネタがw

最初のボス戦を経たためか基本のプレイはスムーズになったものの、難易度が上がってきた印象。

しかし、まさかお化けに(びっくりはしても)動じないのに、驚かしと思われるヤツが出た途端にこの2回で最大の悲鳴+テンション激下がりになるとはゲーム制作側も思ってなかったでしょうねwww

あとリズム刻んでるっぽい音がするたびに乗っていくの好き。

少しずつ集まる思い出はどういう形になっていくのか、お姉さんと主人公はそれぞれどうなっていくのか。 今後も気になりますね。

空気読み3

www.youtube.com

特大級のフラグを立てて始まった気がする空気読み。

空気読むどころか状況がわからずいつの間にか終わってたり、空気を読んだ上であえて無視したり、煽り餅さんとか本人の意向はともかくカオスで大変素晴らしいw

あとニュートン的な人の時の、フリフリ餅さんは空気読めてたかどうかはさておいて100点です(๑•̀ㅂ•́)و✧

ポケットモンスター シャイニングパール

www.youtube.com

リメイク前のダイパをプレイしたことがある、ということで、ネタバレどころかリスナーより豊富な知識でサクサク進む。
以前ダイパ自体が初めて、という方の配信も見たことがあったのですが、どちらもそれぞれ良いですね。

自他ともに評価の高い?ネーミングだったり、他のソフトのプレイデータがあると発生するイベントをチート呼ばわりしたりwww
ある意味衝撃のラストまで、見どころが多いです(๑•̀ㅂ•́)و✧

あと時々暴発するポケモン愛は、アルセウスをプレイしたときどうなってしまうのか…。
ふぁ…大変楽しみですね。

Detroit:Become Human

www.youtube.com

おススメの配信、ということでよく名前にあがる作品。

技術が進歩して、人間そっくりのアンドロイドが人に代わって働かされている世界を舞台に、激動の時を3人の主人公を軸にプレイしていく。

重いテーマながら、プレイする当人はそんなでもなさそうだったり、百合展開にワクついたり、終盤の拾ったチケットの扱いを即決するところなど、(私の思う)のめさんらしいと感じました。

ゲームはマルチエンディングではありますが、グラフィックがきれいで映画みたいなこともあって、他の分岐を見るためにやり直したくはない、という気持ちはよくわかります。

あと登場人物としてはやっぱりコナーとハンクのコンビが素晴らしい。
ベタかもしれないけど、最後の展開は熱い!

おわりに

この方に限らないですが、ゲーム配信の魅力はゲーム自体はもちろん、プレイ中の選択や話される内容にプレイヤー(配信者)が表されるところだと思います。

雑談も良いのだけど、ゲームの世界だからこそ見える側面があるかと。

ということで、今後も雑談、ゲーム配信のどちらも期待しています(`・ω・´)ゞ

【ASP.NET Core】【EntityFramework Core】【C#】 .NET 5 から .NET 6 に更新する

はじめに

これは C# Advent Calendar 2021 カレンダー2の15日目の記事です。

.NET 6 リリース👏 ということで、手元にあった ASP.NET Core + EntityFramework Core(DB は PostgreSQL) のプロジェクトを更新してみました。

環境(更新後)

  • .NET ver.6.0.101
  • NLog.Web.AspNetCore ver.4.14.0
  • Npgsql.EntityFrameworkCore.PostgreSQL ver.6.0.1
  • Microsoft.EntityFrameworkCore ver.6.0.1
  • Microsoft.EntityFrameworkCore.Design ver.6.0.1
  • Newtonsoft.Json ver.13.0.1
  • Microsoft.AspNetCore.Mvc.NewtonsoftJson ver.6.0.1
  • Microsoft.AspNetCore.Identity.EntityFrameworkCore ver.6.0.1

ASP.NET Core を更新する

必須の作業

global.json (ある場合) と .csproj のバージョンを更新するだけです。

ApprovementWorkflowSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="NLog.Web.AspNetCore" Version="4.14.0"/>
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.1"/>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1"/>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1"/>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.1"/>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1"/>
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.1"/>
  </ItemGroup>
</Project>

ついでに「ImplicitUsings」を有効にしておくと、「using System;」などを省略できるようになります。

.NET 6 っぽくする(任意)

ASP.NET Core ではこれまで Program.cs(メインクラス)、 Startup.cs(ミドルウェアの設定など) を中心に処理が書かれていました。 .NET 6 で新規プロジェクトを作ると、 C# 9 で導入された トップレベステートメントが適用され、下記のような Program.cs が作成され、 Startup.cs の内容が統合されます。

Program.cs(新規プロジェクト)

var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();

これに倣って、 Program.cs に統合してみました。

Before

Program.cs

using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NLog.Web;

namespace ApprovementWorkflowSample
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var logger = NLogBuilder.ConfigureNLog("Nlog.config").GetCurrentClassLogger();
            try 
            {
                CreateHostBuilder(args).Build().Run();
            }
            catch (Exception ex) {
                logger.Error(ex, "Stopped program because of exception");
                throw;
            }
            finally {
                NLog.LogManager.Shutdown();
            }
        }
        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                })
                .ConfigureLogging((hostingContext, logging) =>
                {
                    logging.ClearProviders();
                    logging.SetMinimumLevel(LogLevel.Trace);
                })
                .UseNLog();
    }
}

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ApprovementWorkflowSample.Applications;
using ApprovementWorkflowSample.Models;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using ApprovementWorkflowSample.Approvements;

namespace ApprovementWorkflowSample
{
    public class Startup
    {
        private readonly IConfiguration configuration;
        public Startup(IConfiguration configuration)
        {
            this.configuration = configuration;
        }
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddHttpClient();

            services.AddControllers()
                .AddNewtonsoftJson();
            services.AddDbContext<ApprovementWorkflowContext>(options =>
                options.UseNpgsql(configuration["DbConnection"]));
            services.AddIdentity<ApplicationUser, IdentityRole<int>>()
                .AddUserStore<ApplicationUserStore>()
                .AddEntityFrameworkStores<ApprovementWorkflowContext>()
                .AddDefaultTokenProviders();
            services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(sp =>
                (ServerAuthenticationStateProvider) sp.GetRequiredService<AuthenticationStateProvider>()
            );
            services.AddScoped<IApplicationUsers, ApplicationUsers>();
            services.AddScoped<IWorkflows, Workflows>();
            services.AddScoped<IApplicationUserService, ApplicationUserService>();
            services.AddScoped<IApprovementService, ApprovementService>();
        }
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseStaticFiles();
            app.UseRouting();
            app.UseAuthentication();
            app.UseAuthorization();

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

After

Program.cs

using ApprovementWorkflowSample.Models;
using ApprovementWorkflowSample.Applications;
using ApprovementWorkflowSample.Approvements;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using NLog.Web;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

var logger = NLogBuilder.ConfigureNLog("Nlog.config").GetCurrentClassLogger();
try 
{
    var builder = WebApplication.CreateBuilder(args);
    builder.Host.ConfigureLogging(logging =>
    {
        logging.ClearProviders();
        logging.AddConsole();
    })
    .UseNLog();
    builder.Services.AddRazorPages();
    builder.Services.AddServerSideBlazor();
    builder.Services.AddHttpClient();
    builder.Services.AddControllers()
                .AddNewtonsoftJson();
    builder.Services.AddDbContext<ApprovementWorkflowContext>(options =>
                options.UseNpgsql(builder.Configuration["DbConnection"]));
    builder.Services.AddIdentity<ApplicationUser, IdentityRole<int>>()
                .AddUserStore<ApplicationUserStore>()
                .AddEntityFrameworkStores<ApprovementWorkflowContext>()
                .AddDefaultTokenProviders();
    builder.Services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(sp =>
                (ServerAuthenticationStateProvider) sp.GetRequiredService<AuthenticationStateProvider>()
            );
    builder.Services.AddScoped<IApplicationUsers, ApplicationUsers>();
    builder.Services.AddScoped<IWorkflows, Workflows>();
    builder.Services.AddScoped<IApplicationUserService, ApplicationUserService>();
    builder.Services.AddScoped<IApprovementService, ApprovementService>();

    // DI の設定など、 builder.Services への処理が終わったあとに実行する必要がある.
    var app = builder.Build();
    if (app.Environment.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }    
    app.UseStaticFiles();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapBlazorHub();
        endpoints.MapControllers();
    });
    app.Run();
}
catch (Exception ex) {
    logger.Error(ex, "Stopped program because of exception");
}
finally {
    NLog.LogManager.Shutdown();
}
  • これまで DI で取得していた、「IConfiguration」などは、「WebApplicationBuilder」から取得できます。
  • コマンドライン引数は「args」で取得できます。
  • DI の設定など「builder.Services」に対する設定は、「builder.Build();」実行より前に行う必要があり、逆にすると実行時に例外が発生します。

コマンドライン引数など、暗黙の部分があったりする部分は気になるものの、全体を見るとシンプルになった気がします。

EntityFramework Core を更新する

以前もそうだった気がしますが、更新の影響は EntityFramework Core の方が大きい気がします。

今回は Npgsql で日付型の扱いが変わった、というのが特に大きそうです。

ApplicationUser.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.AspNetCore.Identity;

namespace ApprovementWorkflowSample.Applications
{
    public class ApplicationUser: IdentityUser<int>
    {
...
        [Required]
        [Column("last_update_date", TypeName = "timestamp with time zone")]
        public DateTime LastUpdateDate { get; set; }
...
        public void Update(string userName, string? organization,
            string email, string password)
        {
            UserName = userName;
            Organization = organization;
            Email = email;
            // ハッシュ化されたパスワードを設定する.
            PasswordHash = new PasswordHasher<ApplicationUser>()
                .HashPassword(this, password);
            // このあと context.SaveChangesAsync() を実行すると例外発生
            LastUpdateDate = DateTime.Now;
        }
...
    }
}

例外

InvalidCastException: Cannot write DateTime with Kind=Local to PostgreSQL type 'timestamp with time zone', only UTC is supported. Note that it's not possible to mix DateTimes with different Kinds in an array/range. See the Npgsql.EnableLegacyTimestampBehavior AppContext switch to enable legacy behavior.

TimeStamp はタイムゾーン有・無にかかわらず C# 側は DateTime で良いのですが、UT でなければ例外が発生します。

ApplicationUser.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.AspNetCore.Identity;

namespace ApprovementWorkflowSample.Applications
{
    public class ApplicationUser: IdentityUser<int>
    {
...
        [Required]
        [Column("last_update_date", TypeName = "timestamp with time zone")]
        public DateTime LastUpdateDate { get; set; }
...
        public void Update(string userName, string? organization,
            string email, string password)
        {
...
            // OK
            LastUpdateDate = DateTime.Now.ToUniversalTime();
        }
...
    }
}

今回更新したプロジェクトでは使っていませんでしたが、 PostgreSQL 側が 「date」 の場合は新たに追加された 「DateOnly」 型になる、といった点が主な変更となりました。

なお、上記の例外は DB にデータをインサートする、アップデートする際に発生するため、参照だけなら(それでも変更はしておいた方が良いと思いますが)そのままでも何とかなりそうです。

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

はじめに

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

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

Environments

  • .NET Core ver.5.0.102

Samples

過去記事

サインイン

SignInManager でサインイン(失敗)

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

ApplicationUserService.cs

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

SignIn.razor

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

SignIn.razor.cs

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

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

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

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

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

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

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

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

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

UserController.cs

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

SignInValue.cs

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

SignIn.razor.cs

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

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

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

EditWorkflow.razor

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

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

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

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

ClaimsPrincipal 、ClaimsIdentity 、AuthenticationState を使う(OK)

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

Startup.cs

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

SignIn.razor.cs

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

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

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

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

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

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

では Blazor は?

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

App.razor

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

RedirectToSignIn.razor.cs

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

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

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

参考

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

はじめに

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

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

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

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

Cell.razor

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

Cell.razor.cs

using Microsoft.AspNetCore.Components;

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

Cell.razor.css

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

DisplayGridPage.razor

@page "/"

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

結果

f:id:mslGt:20201222060717j:plain

コンポーネントCSS

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

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

BlazorSample.styles.css

/* _content/BlazorSample/Views/Components/Cell.razor.rz.scp.css */
.cell_frame[b-64je09gotq]{
    background-color: gray;
    border: 1px solid black;
    color: goldenrod;
}
/* _content/BlazorSample/Views/Components/SheetSelector.razor.rz.scp.css */
/* _content/BlazorSample/Views/DisplayGridPage.razor.rz.scp.css */
#sheet_area[b-p832tuedyv]
{
    height: 30vh;
    width: 30vw;
    background-color: aqua;
    display: grid;
}
h1[b-p832tuedyv]{
    color: blue;
}
/* _content/BlazorSample/Views/Shared/MainLayout.razor.rz.scp.css */
h1[b-m6a6nzx0h4]{
    color: red;
}
header[b-m6a6nzx0h4]{
    background-color: rosybrown;
}

データのやりとり

Parameter

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

Cell.razor.cs

using System;
using Microsoft.AspNetCore.Components;

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

Cell.razor

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

DisplayGridPage.razor.cs

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

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

DisplayGridPage.razor

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

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

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

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

Cell.razor.cs

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

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

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

二回ロードされる?

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

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

_Host.cshtml

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

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

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

状態管理

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

EventCallback

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

Cell.razor.cs

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

DisplayGridPage.razor

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

data-binding

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

Cell.razor.cs

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

DisplayGridPage.razor

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

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

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

Rx で別ルート

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

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

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

WorkSheet.cs

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

WorkSheetStore.cs

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

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

WorkSheetReducer.cs

using System;

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

WorkSheetEffect.cs

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

WorkSheetAction.cs

using System.Threading.Tasks;

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

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

Start.cs

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

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

Cell.razor.cs

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

DisplayGridPage.razor.cs

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

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

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

おわりに

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

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

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

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

はじめに

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

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

元のプロジェクト

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

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

HomeController.cs

using System;
using Microsoft.AspNetCore.Mvc;

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

_Layout.cshtml

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

_Host.cshtml

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

App.razor

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

MainLayout.razor

@inherits LayoutComponentBase
@Body

DisplayGridPage.razor

@page "/"

<h1>Hello, world!</h1>

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

CSS isolation

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

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

_Layout.cshtml

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

DisplayGridPage.razor.css

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

失敗

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

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


※2020-12-21 更新

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

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

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


HomeController.cs

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

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

生成される CSS

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

BlazorSample.styles.css()

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

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

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

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

【C#】Play records

はじめに

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

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

Environments

  • .NET ver.5.0.100

変換で使用

まずはおさらい

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

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

Book.cs

public record Book(int Id, string Name);

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

Book.cs

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

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

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

Program.cs

using System;

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

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

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

ISomething.cs

public interface ISomething
{
    void Message();
}

Book.cs

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

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

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

Book.cs

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

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

比較

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

ClassSample.cs

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

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

Program.cs

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

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

With

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

Program.cs

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

中を見てみる

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

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

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

    public int Id
    {
        get;
        set;
    }

    public string Name
    {
        get;
        set;
    }

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

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

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

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

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

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

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

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

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

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

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

Wao!

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

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

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

変換

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

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

Entity Framework Core

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

Book.cs

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

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

JSON も問題なしですね。

MainController.cs

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

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

Result

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

おわりに

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

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

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