20200810のC#に関する記事は7件です。

【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その4~

前回

【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その2~
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その3~

の続きです。

前回は、Cognitoを使用して登録されているユーザーでないとログインできないようにしました。
これだとまだとある問題を抱えていますが、その前に今回はログイン情報の保持をさせるようにしたいと思います。

ログイン情報の保持

多くのサービスは以下の様な処理でログイン情報を保持しているかと思います。
今回はこの方式に則って、機能追加をしていこうと思います。
image.png

出典:セキュリティ対策はばっちり? セッションとCookieの違いとPHPでの使い方をご紹介!

Blazorによるクッキーへの読み書き

結構調べてはみたんですが、今の所クッキーを扱う情報について見当たりませんでした。

BlazorではHTTPContextは扱えない(?)

.NET MVC CoreなどではHTTPContextを通すことによりセッションやクッキーを扱う事が出来ましたが、Controllerクラスからでないと扱う事が出来ません。それはBlazorでも同じなようですが、今のソリューションにControllerクラスを追加するというのはいささか変(というか、それってBlazorにとってどういう意味があるの?)と思ったので別の方法を考えてみました。

JavaScriptでクッキーを扱う

別の方法としては、JavaScriptでクッキーを扱うしかないと思いました。Blazorのよさを損なうような感じがしますがここはしょうがない。実装してみましょう。

呼び出したいJSの処理を追加する

_Host.cshtmlファイルWriteCookieメソッドReadCookieメソッドを追加します。

_Host.cshtml
<script>
    window.blazorExtensions = {
        WriteCookie: function(name, value) {
            var maxAge = "max-age=3600; ";
            var path = "path=/; ";
            document.cookie = name + "=" + value + "; " + maxAge + path;
        },

        ReadCookie: function () {
            return document.cookie;
        },
    }
</script>

ハマりポイント

ここでReadCookieメソッドはクッキー全体ではなく連想配列などで返したかったんですが、配列を生成するだけでUnHundled Errorとなってしまったので泣く泣くこうしています。配列を返す方法があれば教えてください。

C#からJavaScriptの処理を呼ぶ

新しく、ServiceフォルダCookieServiceクラスを追加しました。
というのも、クッキー操作をしたいのは特定のページだけでない可能性があると考えたからです。複数回インスタンス生成するようなクラスでもないですし、DIでどのページでも使いまわせるようにしましょう、という粋な計らいです。
image.png

コードは以下の通りです。

CookieService.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.JSInterop;

namespace LoginTest.Service
{
    public class CookieService
    {
        #region フィールド
        private readonly IJSRuntime _jsRuntime;
        #endregion

        #region コンストラクタ
        public CookieService(IJSRuntime jsRuntime)
        {
            _jsRuntime = jsRuntime;
        }
        #endregion

        #region メソッド
        /// <summary>
        /// クッキーへの書き込み
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <returns></returns>
        public async Task WriteCookieAsync(string key, string value)
        {
            await _jsRuntime.InvokeVoidAsync("blazorExtensions.WriteCookie", key, value).ConfigureAwait(false);
        }
        /// <summary>
        /// クッキーの読み込み
        /// </summary>
        /// <returns></returns>
        public async Task<Dictionary<string, string>> ReadCookieAsync()
        {
            var cookieDictionary = new Dictionary<string, string>();
            var cookie = await _jsRuntime.InvokeAsync<string>("blazorExtensions.ReadCookie").ConfigureAwait(false);

            var cookieSplit = cookie.Split(";");
            for (var i = 0; i < cookieSplit.Length; ++i)
            {
                var cookieKeyValue = cookie.Split("=");
                cookieDictionary.TryAdd(cookieKeyValue[0], cookieKeyValue[1]);
            }

            return cookieDictionary;
        }
        #endregion
    }
}

IJSRuntimeクラスInvokeAsyncメソッドに対して、先ほど宣言したJavaScriptのメソッド名や引数を渡してあげるだけで実行ができます。かんたんかんたん。

あとはDIできるように、StartupクラスConfigureServicesメソッドCookieServiceクラスScopedで追加してあげるだけでおkです。

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddSignalR();
    services.AddRazorPages();
    services.AddServerSideBlazor();
    services.AddResponseCompression(opts =>
    {
        opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
            new[] { "application/octet-stream" });
    });
    services.AddBlazoredLocalStorage();
    services.AddScoped<CookieService>(); //★この行を追加★
}

ハマりポイント

と、実装は非常に簡単に見えますが、この処理に辿り着くまでにかなり時間が掛かりました。ハマりポイントは2点あります。

①サービスクラスへのDIはコンストラクタインジェクションしかできない
これまでは何となくInject属性を付けていればサービスに対してDI出来ていましたが、それはrazorファイルrazor.csファイルだったから、だそうです。

それについては以下のページに記載があります。
サービスでDIを使用する

なのでこのCookieServiceクラスではコンストラクタインジェクションを行ってIJSRuntimeクラスへの注入をしています。仮に、プロパティでIJSRuntimeクラスを宣言しInject属性を付けたとしてもnullのままで値は何も入ってきません。


②サービスクラスでデフォルトサービスをDIさせたい場合はデフォルトサービスの有効期間に合わせてサービスを追加する
ちょっと何を言っているか分からない人が多くなってきてそうなので丁寧めに説明します。

サービスクラスというのは、今回作ろうとしているCookieServiceクラスのことです。
デフォルトサービスというのは、以下の3つのサービスの事を指します。最初から使えるサービス達ですね。

image.png

今回、私はサーバーサイドでソリューションを作成しています。
また、デフォルトサービスであるIJSRuntimeクラスを使用したいと考えています。
上記の画像を見てみればわかりますが、サーバーサイドではIJSRuntimeクラスの有効期間は「スコープ」となっています。
なので以下の様に、CookieServiceクラスを「スコープ」で追加しています。

Startup.cs
services.AddScoped<CookieService>(); //★この行を追加★

ここを合わせる必要があると分かっていなかったので最初はシングルトンで追加していました。そうすると以下のようなエラーが出力されました。

キャプチャ.JPG

InvalidOperationException: Cannot consume scoped service 'Microsoft.JSInterop.IJSRuntime' from singleton 'LoginTest.Service.CookieService'.
(InvalidOperationException:シングルトン「LoginTest.Service.CookieService」からスコープサービス「Microsoft.JSInterop.IJSRuntime」を使用できません。)

サービスの有効期間についての説明はいかの通りです。スコープはサーバーサイドにしかない有効期間なのですね。
image.png

ログイン情報を使いまわす

クッキーやローカルストレージを利用して、ログイン情報を保持します。また。ログイン情報が保存されているのであれば自動的にチャットページへルーティングしてあげましょう。

まずはNuGetからBlazored.LocalStorageをインストールします。
image.png

このライブラリを使用することにより、非常に簡単にローカルストレージを使用することができます。ローカルストレージであればブラウザを閉じたり別タブでも保存している情報が引き継げるので今回はローカルストレージを使用します。
image.png
出典:JavaScriptのsessionStorageの使い方を現役エンジニアが解説【初心者向け】


改めて、以下の図を思い出してみましょう。
image.png

ユーザーがページに訪れた際に行うべき処理は次の通りです。

  1. ログインページへアクセス
  2. ユーザーのクッキーに保存されているセッションIDを取得
  3. クッキーのセッションIDと一致するローカルストレージに保持されているセッションIDを見つける
  4. ローカルストレージ内に保持している前回ログイン情報を取得
  5. チャットページへルーティングする

クッキーにセッションIDが保存されていなければ作成し、ログイン成功時にローカルストレージへセッションIDをキーとしてログイン情報を保持させるようにすればよい、ということになります。


HTTPContextが使えないので、セッションIDNewGuidで代用しています。
以下の処理で上記の1~5の処理が実装出来ました。

Index.razor.cs
protected override async Task OnAfterRenderAsync(bool firstRender)
{
    //IsPostbackプロパティのような感じ?
    if (firstRender)
    {
        //セッションIDを持っていなければクッキーに埋め込む
        var newSessionID = Guid.NewGuid().ToString();
        var cookie = await CookieService.ReadCookieAsync().ConfigureAwait(false);
        if (cookie == null)
        {
            await CookieService.WriteCookieAsync(SessinID, newSessionID).ConfigureAwait(false);
        }
        else
        {
            var sessionID = cookie.FirstOrDefault(x => x.Key == SessinID);
            if (sessionID.Key == null)
            {
                //セッションIDを持っていなければクッキーに埋め込む
                await CookieService.WriteCookieAsync(SessinID, newSessionID).ConfigureAwait(false);
            }
            else
            {
                //セッションIDを持っておりローカルストレージにもログイン情報がある場合
                var loginData = await LocalStorage.GetItemAsync<LoginData>(sessionID.Value).ConfigureAwait(false);
                if (loginData != null)
                {
                    Navigation.NavigateTo("Chat", false);
                }
            }
        }
    }
}

また、Validationのチェックにかからず正しいユーザーでログインできた場合にはローカルストレージへログイン情報を追加してあげれば完了です。この間にクッキーが削除されている可能性があるので、念の為にクッキーへの書き込み処理を追加しています。

Index.razor.cs
public async Task OnValidSubmit(EditContext context)
{
    Console.WriteLine($"OnValidSubmit()");

    var errorMessage = await SignInUserAsync().ConfigureAwait(false);
    if (string.IsNullOrEmpty(errorMessage))
    {
        var cookie = await CookieService.ReadCookieAsync();
        var sessionID = cookie.FirstOrDefault(x => x.Key == SessinID);
        if (sessionID.Key == null)
        {
            //セッションIDを持っていなければクッキーに埋め込む
            var newSessionID = Guid.NewGuid().ToString();
            await CookieService.WriteCookieAsync(SessinID, newSessionID).ConfigureAwait(false);
            sessionID = cookie.Single(x => x.Key == SessinID);
        }
        await LocalStorage.SetItemAsync(sessionID.Value, LoginData).ConfigureAwait(false);

        Navigation.NavigateTo("Chat", false);
    }
    else
    {
        LoginErrorMessage = errorMessage;
    }
}

実行してみる

ログイン画面がちらっと見えてしまいますが、すぐにチャットページへルーティングされることが確認できます。
Counter.gif

ログイン画面が見えてしまうのは恐らく、OnAfterRenderAsyncメソッドでルーティングしているせいだと考えています。しかし、OnBeforeRenderメソッドはありません。

ただプルリクエストがあったので、今後は使えるようになることでしょう。
OnBeforeRender #1716

まとめ

これまでの記事ではフルC#でしたが、クッキーを扱う為にJavaScriptを使うハメになってしまいました。恐らく今後は、こういったC#以外の部分はライブラリが出てくると思うのでそれに期待したいと思います。

次回は、認証をしなくてもチャットページへルーティング出来てしまう問題を解決したいと思います。

参考にさせていただいたページ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityでFPSゲームの入力をハンドリングする

FPS、TPSゲームの視点操作や移動をunityでどう実現しているのかを知りたかったので、tutorialのコードを読んで勉強した。

FPS Microgame /| Templates | Unity Asset Store

versionは1.1.2
unityのバージョンは2019.4.7f1

入力の受け取り部分は、Scripts/PlayerInputHandler.csが担っている。

Start()

    private void Start()
    {
        m_PlayerCharacterController = GetComponent<PlayerCharacterController>();
        DebugUtility.HandleErrorIfNullGetComponent<PlayerCharacterController, PlayerInputHandler>(m_PlayerCharacterController, this, gameObject);
        m_GameFlowManager = FindObjectOfType<GameFlowManager>();
        DebugUtility.HandleErrorIfNullFindObject<GameFlowManager, PlayerInputHandler>(m_GameFlowManager, this);

        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
    }

ここで
m_PlayerCharacterControllerが主人公を操作するcontrollerである。

m_GameFlowManagerはゲーム全体を管理するcontroller。

GetMoveInput()

これはキャラクターの移動に関する関数である。

    public Vector3 GetMoveInput()
    {
        if (CanProcessInput())
        {
            Vector3 move = new Vector3(Input.GetAxisRaw(GameConstants.k_AxisNameHorizontal), 0f, Input.GetAxisRaw(GameConstants.k_AxisNameVertical));

            // constrain move input to a maximum magnitude of 1, otherwise diagonal movement might exceed the max move speed defined
            move = Vector3.ClampMagnitude(move, 1);

            return move;
        }

        return Vector3.zero;
    }

順番に見ていく。

CanProcessInput()

このゲームではescを押すことでマウスのロックを解除できる。
ロックを解除している場合は、キャラクターの操作(移動、方向転換)はできない。

中のソースコードを確認すると、

    public bool CanProcessInput()
    {
        return Cursor.lockState == CursorLockMode.Locked && !m_GameFlowManager.gameIsEnding;
    }

つまり、カーソルがロックされているかつ、ゲームがエンディングになっていない場合に入力が可能になる。

Input.GetAxisRaw(string axisName)

Input-GetAxisRaw - Unity スクリプトリファレンス

axisName で識別される仮想軸の平滑化フィルターが適用されていない値を返します
The value will be in the range -1...1 for keyboard and joystick input. Since input is not smoothed, keyboard input will always be either -1, 0 or 1. This is useful if you want to do all smoothing of keyboard input processing yourself.

これだけではよくわからないが、ゲームパッドの入力や、キーボード、マウスの入力を軸ごとに[-1, 1]の範囲で正規化して返してくれる。
つまり、複数の入力デバイスに対して正規化された数値で方向を返してくれる便利な関数。

ちなみにInput.GetAxisRawはキーボードの入力に対しては-1, 1のどちらかを返すが、Input.GetAxisは平滑化された値が返る。
リアルな挙動を再現したい場合は、Input.GetAxisの方が適している?
ref: http://albatrus.com/main/unity/7209

Vector3.ClampMagnitude(Vector3 vector, float maxLength)

vectorの大きさがmaxLengthでclampされるようにする関数である。

GetMoveInput()

はじめに戻って、GetMoveInput()について。
これはつまり、

入力が可能であれば、入力された値の大きさが1以内に収まるように方向ベクトルを返し、そうでなければ0ベクトルを返す関数

であることがわかる。

GetLookInputsHorizontal() / GetLookInputsVertical()

視点の回転に関する関数である。

軸が異なるだけなので、Horizontalのみ見ていく。

    public float GetLookInputsHorizontal()
    {
        return GetMouseOrStickLookAxis(GameConstants.k_MouseAxisNameHorizontal, GameConstants.k_AxisNameJoystickLookHorizontal);
    }

GetMouseOrStickLookAxisを呼んでいるだけであることがわかる。

GetMouseOrStickLookAxis(string mouseInputName, string stickInputName)

    float GetMouseOrStickLookAxis(string mouseInputName, string stickInputName)
    {
        if (CanProcessInput())
        {
            // Check if this look input is coming from the mouse
            bool isGamepad = Input.GetAxis(stickInputName) != 0f;
            float i = isGamepad ? Input.GetAxis(stickInputName) : Input.GetAxisRaw(mouseInputName);

            // handle inverting vertical input
            if (invertYAxis)
                i *= -1f;

            // apply sensitivity multiplier
            i *= lookSensitivity;

            if (isGamepad)
            {
                // since mouse input is already deltaTime-dependant, only scale input with frame time if it's coming from sticks
                i *= Time.deltaTime;
            }
            else
            {
                // reduce mouse input amount to be equivalent to stick movement
                i *= 0.01f;
#if UNITY_WEBGL
                // Mouse tends to be even more sensitive in WebGL due to mouse acceleration, so reduce it even more
                i *= webglLookSensitivityMultiplier;
#endif
            }

            return i;
        }

        return 0f;
    }

少し複雑だが、順を追ってみていく。

            // Check if this look input is coming from the mouse
            bool isGamepad = Input.GetAxis(stickInputName) != 0f;
            float i = isGamepad ? Input.GetAxis(stickInputName) : Input.GetAxisRaw(mouseInputName);

ここでは、gamepadの入力を使うか、mouseの入力を使うかを決定している。
Input.GetAxis(), Input.GetAxisRaw()の説明はInput.GetAxisRaw()を参照
gamepadの入力があればその入力を使い、そうでなければmouseの入力を使用する。

mouseはInput.GetAxisRawを使っているが、gamepadはInput.GetAxisを使っているのは興味深い。UXの問題?

            // handle inverting vertical input
            if (invertYAxis)
                i *= -1f;

invertAxisはエディタから定義できる定数。
y軸を反転させたいときは、invertYAxis = trueにすれば反転できる…が、X軸とY軸の判定をこの関数ではしていないので、invertYAxis = trueにするとx軸の操作も逆になる(v1.1.2)

次に感度の設定

            // apply sensitivity multiplier
            i *= lookSensitivity;

その後にgamepadとmouseのscaleを揃えている

            if (isGamepad)
            {
                // since mouse input is already deltaTime-dependant, only scale input with frame time if it's coming from sticks
                i *= Time.deltaTime;
            }
            else
            {
                // reduce mouse input amount to be equivalent to stick movement
                i *= 0.01f;
#if UNITY_WEBGL
                // Mouse tends to be even more sensitive in WebGL due to mouse acceleration, so reduce it even more
                i *= webglLookSensitivityMultiplier;
#endif
            }

mouseがフレーム単位の移動量なので、gamepadもそれに揃えるためにTime.deltaTimeをかけている(?)。ここの辺りはよくわからなかったが、とりあえずscaleを揃えているだけという認識で良さそう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

System.Text.Json を使ってアプリの設定を Json ファイルにシリアライズ/デシリアライズする

WinForms のフォームの位置とサイズを Json ファイルにシリアライズ/デシリアライズしてみた。
フォーム起動時に読み出し、フォームを閉じる時に保存する。

シリアライズ機能を持つクラス
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Test
{
    public class Setting
    {
        //外部に公開する為の static プロパティ
        public static Setting Values { get; private set;}

        //シリアライズするプロパティは public にして getter と setter が必要。
        //ウィンドウが最小化された状態で閉じられると Location.X や Location.Y がとんでもない画面外になるが、対処は簡単なので省略。
        // シリアライズ時に public な getter が必須。 デシリアライズ時に public な setter が必須。
        public int FormLocationX { get; set;}
        public int FormLocationY { get; set;}
        public int FormWidth { get; set;}
        public int FormHeight { get; set;}

        //public コンストラクタは JsonSerializer に必須な模様。
        public Setting(){}

        //保存するメソッド
        public static void Save()
        {
            if(Values == null)
              Values = new Setting();
            File.WriteAllText("settings.json", JsonSerializer.Serialize(Values, new JsonSerializerOptions() { WriteIndented = true }), Encoding.UTF8);
        }

        //読み出して Setting クラスのインスタンスを生成して Values プロパティに割り当てるメソッド
        public static void Load()
        {
            if (!File.Exists("settings.json"))
                Save();
            Values = JsonSerializer.Deserialize<Setting>(File.ReadAllText(ConfigFileName, Encoding.UTF8));
        }
    }
}
使用する側のクラス
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;

namespace Test
{
    public class MyForm : Form
    {
        public static void Main(string[] args)
        {
            Application.Run(new MyForm());
        }

        public MyForm()
        {
            StartPosition = FormStartPosition.Manual;
            //読み出し
            Setting.Load();
            Bounds = new Rectangle(Setting.Values.FormLocationX, Setting.Values.FormLocationY, Setting.Values.FormWidth, Setting.Values.FormHeight);
        }

        protected override void OnClosing(CancelEventArgs e)
        {
            //保存
            Setting.Values.FormLocationX = Location.X;
            Setting.Values.FormLocationY = Location.Y;
            Setting.Values.FormWidth = Width;
            Setting.Values.FormHeight = Height;
            Setting.Save();
        }
    }
}
出来上がるのはこんなJsonファイル(settings.json)
{
  "FormLocationX": 866,
  "FormLocationY": 254,
  "FormWidth": 636,
  "FormHeight": 471
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【続】 mp3のタグ(ID3v1, ID3v2)を読み込む(ライブラリ不使用) + DataGridViewで一覧表示する

DataGridViewを使って一覧表示できるようにしてみました。保存機能(ファイルへの反映処理)は未対応。

参考サイト

mp3のID3v1, ID3v2タグ

前記事の参考サイト

DataGridView

画面キャプチャ

image.png

ソースコード ID3Util.cs

ID3Util.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

namespace ID3Util
{
    public class ID3v1
    {
        const int ID3v1Size = 128;
        //static readonly int CodePage_Shift_JIS = 932;

        public static readonly string[] Genres = {
            "Blues","ClassicRock","Country","Dance",
            "Disco","Funk","Grunge","Hip-Hop",
            "Jazz","Metal","NewAge","Oldies",
            "Other","Pop","R&B","Rap",
            "Reggae","Rock","Techno","Industrial",
            "Alternative","Ska","DeathMetal","Pranks",
            "Soundtrack","Euro-Techno","Ambient","Trip-Hop",
            "Vocal","Jazz+Funk","Fusion","Trance",
            "Classical","Instrumental","Acid","House",
            "Game","SoundClip","Gospel","Noise",
            "Alt.Rock","Bass","Soul","Punk",
            "Space","Meditative","InstrumentalPop","InstrumentalRock",
            "Ethnic","Gothic","Darkwave","Techno-Industrial",
            "Electronic","Pop-Folk","Eurodance","Dream",
            "SouthernRock","Comedy","Cult","Gangsta",
            "Top40","ChristianRap","Pop/Funk","Jungle",
            "NativeAmerican","Cabaret","NewWave","Psychadelic",
            "Rave","Showtunes","Trailer","Lo-Fi",
            "Tribal","AcidPunk","AcidJazz","Polka",
            "Retro","Musical","Rock&Roll","HardRock",
            "Folk","Folk/Rock","NationalFolk","Swing",
            "Fusion","Bebob","Latin","Revival",
            "Celtic","Bluegrass","Avantgarde","GothicRock",
            "ProgressiveRock","PsychedelicRock","SymphonicRock","SlowRock",
            "BigBand","Chorus","EasyListening","Acoustic",
            "Humour","Speech","Chanson","Opera",
            "ChamberMusic","Sonata","Symphony","BootyBass",
            "Primus","PornGroove","Satire","SlowJam",
            "Club","Tango","Samba","Folklore",
            "Ballad","Power Ballad","Rhytmic Soul","Freestyle",
            "Duet","Punk Rock","Drum Solo","Acapella",
            "Euro-House","Dance Hall","Goa","Drum & Bass",
            "Club-House","Hardcore","Terror","Indie",
            "BritPop","Negerpunk","Polsk Punk","Beat",
            "Christian Gangsta Rap","Heavy Metal","Black Metal","Crossover",
            "Contemporary Christian","Christian Rock","Merengue","Salsa",
            "Trash Metal","Anime","JPop","SynthPop",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "(reserved)","(reserved)","(reserved)","(reserved)",
            "Sacred","Northern Europe","Irish & Scottish","Scotland",
            "Ethnic Europe","Enka","Children's Song","(reserved)",
            "Heavy Rock(J)","Doom Rock(J)","J-POP(J)","Seiyu(J)",
            "Tecno Ambient(J)","Moemoe(J)","Tokusatsu(J)","Anime(J)", // 255 ("Anime(J)") は本来「不使用」がアサインされている(?)
        };

        Encoding _enc;
        byte[] TitleInBytes;
        byte[] ArtistInBytes;
        byte[] AlbumInBytes;
        byte[] YearInByte;
        byte[] CommnetInByte;
        public byte GenreNumber; // ジャンル
        byte track;

        public int Year {
            get { 
                try {
                    return Convert.ToInt32(Encoding.ASCII.GetString(YearInByte));
                }
                catch(Exception){return 0;}
            }
            set {
                if ( value < 0 ) {
                    value = 0;
                }
                if ( value > 9999 ) {
                    value = 9999;
                }
                YearInByte[0] = (byte)(0x30 + (value/1000));
                YearInByte[1] = (byte)(0x30 + (value/ 100)%10);
                YearInByte[2] = (byte)(0x30 + (value/  10)%10);
                YearInByte[3] = (byte)(0x30 +  value      %10);
            }
        }

        string TryToGetString(byte[] t)
        {
            try {
                return _enc.GetString(t).TrimEnd(new char[]{'\0',' '});
            }
            catch(Exception){return "";}
        }

        byte[] TryToGetBytes(string s, int length)
        {
            byte[] b = new byte[length];
            int n = _enc.GetByteCount(s);
            if ( n <= length ) {
                byte[] tmp = _enc.GetBytes(s);
                Array.Copy(tmp, 0, b, 0, n);
            }
            else {
                // over時
                // lengthにおさまる切り詰めた文字列を返したい。
                // 多バイト文字の一部バイトが残るのは避けたい。
                // 面倒なので空列相当のall 0x00を返す。
            }
            return b;
            // GetBytes (string s, int charIndex, int charCount, byte[] bytes, int byteIndex);
            // https://docs.microsoft.com/ja-jp/dotnet/api/system.text.encoding.getbytes?view=netcore-3.1#System_Text_Encoding_GetBytes_System_String_System_Int32_System_Int32_System_Byte___System_Int32_
        }

        public string Title   { get { return TryToGetString(TitleInBytes);  } set { TitleInBytes  = TryToGetBytes(value, 30); } }
        public string Artist  { get { return TryToGetString(ArtistInBytes); } set { ArtistInBytes = TryToGetBytes(value, 30); } }
        public string Album   { get { return TryToGetString(AlbumInBytes);  } set { AlbumInBytes  = TryToGetBytes(value, 30); } }
        public string Comment { get { return TryToGetString(CommnetInByte); } set { CommnetInByte = TryToGetBytes(value, 30); } }
        public string Genre {
            get {return Genres[GenreNumber];}
        }
        public int Track {
            get { return track; }
            set {
                if ( value < 0   ) {value =   0;}
                if ( value > 255 ) {value = 255;}
                track = (byte)value;
            }
        }

        public static ID3v1 CreateDefault(Encoding enc)
        {
            ID3v1 ret = new ID3v1();
            ret._enc = enc;
            ret.TitleInBytes = new byte[30];
            ret.ArtistInBytes = new byte[30];
            ret.AlbumInBytes = new byte[30];
            ret.YearInByte = new byte[4];
            ret.CommnetInByte = new byte[30];
            ret.GenreNumber = 255;
            ret.track = 0;
            return ret;
        }

        public static ID3v1 ParseFromFile(string fileName, Encoding enc)
        {
            using (var fs = new FileStream(fileName, FileMode.Open, FileAccess.Read)) {
                if ( fs.Length < ID3v1Size ) {
                    return null;
                }
                fs.Seek(-ID3v1Size, SeekOrigin.End);
                byte[] buffer = new byte[ID3v1Size];
                fs.Read(buffer,0,ID3v1Size);
                return ID3v1.Parse(buffer, 0, enc);
            }
        }

        public static ID3v1 ParseFromFoot(byte[] buffer, Encoding enc)
        {
            return Parse(buffer, buffer.Length-ID3v1Size, enc);
        }

        public static ID3v1 Parse(byte[] buffer, int offset, Encoding enc)
        {
            ID3v1 ret = new ID3v1();

            if ( offset < 0 ) {
                return null;
            }
            if (buffer.Length < offset + ID3v1Size ) {
                return null;
            }

            if ( enc == null ) {
                enc = Encoding.GetEncoding(932); // Shift_JIS の code page = 932;
            }
            ret._enc = enc;

            // ヘッダチェック ("TAG")
            if ( buffer[offset] != 0x54 || buffer[offset+1] != 0x41 || buffer[offset+2] != 0x47) {
                return null;
            }
            ret.TitleInBytes = new byte[30];
            ret.ArtistInBytes = new byte[30];
            ret.AlbumInBytes = new byte[30];
            ret.YearInByte = new byte[4];
            Array.Copy(buffer, offset+ 3, ret.TitleInBytes,  0, 30);
            Array.Copy(buffer, offset+33, ret.ArtistInBytes, 0, 30);
            Array.Copy(buffer, offset+63, ret.AlbumInBytes,  0, 30);
            Array.Copy(buffer, offset+93, ret.YearInByte,    0,  4);
            if ( buffer[offset+125] == 0x00 ) { // Track情報あり
                ret.CommnetInByte = new byte[30];
                Array.Copy(buffer, offset+97, ret.CommnetInByte, 0, 28);
                ret.CommnetInByte[28] = 0;
                ret.CommnetInByte[29] = 0;
                ret.Track = buffer[offset+126];
            }
            else {
                ret.CommnetInByte = new byte[30];
                Array.Copy(buffer, offset+97, ret.CommnetInByte, 0, 30);
                ret.Track = 0;
            }
            ret.GenreNumber = buffer[offset+127];

            return ret;
        }

        public byte[] ToByteArray(out bool commentErrorFlag)
        {
            byte[] a = new byte[128];
            commentErrorFlag = false;

            a[0] = 0x54;
            a[1] = 0x41;
            a[2] = 0x47;
            Array.Copy(TitleInBytes,  0, a,  3, 30);
            Array.Copy(ArtistInBytes, 0, a, 33, 30);
            Array.Copy(AlbumInBytes,  0, a, 63, 30);
            Array.Copy(YearInByte,    0, a, 93,  4);
            if ( Track != 0 ) { // Track情報あり
                if ( CommnetInByte[28] != 0 || CommnetInByte[29] != 0 ) { // Trackありの場合に使用不可能な長さのコメントが使用されている
                    commentErrorFlag = true;
                    // copyしない(0x00のまま)
                }
                else {
                    Array.Copy(CommnetInByte, 0, a, 97, 28);
                }
                a[125] = 0;
                a[126] = (byte)Track;
            }
            else {
                Array.Copy(CommnetInByte, 0, a, 97, 30);
            }
            a[127] = GenreNumber;

            return a;
        }
    }

    // -------------------------------------------------------------------

    public class ID3v2
    {
        Encoding _enc;
        //const int CodePage_Shift_JIS = 932;

        static int SynchsafeIntFrom4Bytes(byte[] buffer, int offset) {
            return ((buffer[offset  ]&0x7F)<<21) |
                ((buffer[offset+1]&0x7F)<<14) | 
                ((buffer[offset+2]&0x7F)<< 7) | 
                ((buffer[offset+3]&0x7F)    ) ;
        }

        public class Frame
        {
            public string ID{get;private set;}
            int MajorVer;
            int Size;
            int EncodingID;
            byte Flags1;
            byte Flags2;
            byte[] Data; // EncodingIDを除く

            public bool PreferedToDiscardWhenTagChanged  { get{return ((Flags1&0x40)!=0);} }
            public bool PreferedToDiscardWhenFileChanged { get{return ((Flags1&0x20)!=0);} }
            public bool PreferedToBeReadOnly             { get{return ((Flags1&0x10)!=0);} }

            public bool RelatedWithOtherFrame            { get{return ((Flags2&0x40)!=0);} }
            public bool Compressed                       { get{return ((Flags2&0x08)!=0);} }
            public bool Encrypted                        { get{return ((Flags2&0x04)!=0);} }
            public bool AsyncFlag                        { get{return ((Flags2&0x02)!=0);} }
            public bool DataLengthFlag                   { get{return ((Flags2&0x01)!=0);} }

            public bool UnknownFlagsAreSet               { get{return ((Flags1&0x8F)!=0 || (Flags2&0xB0)!=0) ;} }

            public string ToString(Encoding defaultEncoding)
            {
                int offset = 0;

                if ( ID == "PIC" || ID == "APIC" ) {
                    return "";
                }

                if ( ID == "COM" || ID == "COMM" ) {
                    return ""; // 未実装
                    /*
                    if ( Data.Length >= 3+1 && 'a' <= Data[0] && Data[0] <= 'z' ) {
                        // 国別コードらしきものがある場合
                        offset = 3;
                        if ( EncodingID == 1 ) {
                            // BOM 2byteすてて 2byteずつサーチしてNULL終端0x00 00を探す
                        }
                        else if ( EncodingID == 2 ) {
                            // 2byteずつサーチしてNULL終端0x00 00を探す
                        }
                        else if ( EncodingID == 0 || EncodingID == 3 ) {
                            // NULL終端0x00を探す
                        }
                    }
                    else {
                        return ""; // 不正なフォーマット
                    }
                    */
                }

                try {
                    if ( EncodingID == 3 ) {
                        // UTF-8 BOMなし
                        return (new System.Text.UTF8Encoding(false)).GetString(Data, offset, Data.Length-offset);
                    }
                    else if ( EncodingID == 2 ) {
                        // UTF-16BE BOMなし
                        return (new System.Text.UnicodeEncoding(true,false)).GetString(Data, offset, Data.Length-offset);
                    }
                    else if ( EncodingID == 1 ) {
                        // UTF-16 BOMあり
                        return (new System.Text.UnicodeEncoding()).GetString(Data, offset, Data.Length-offset);
                    }
                    else if ( EncodingID == 0 ) {
                        if ( defaultEncoding == null ) {
                            // MPEGの規格上は
                            //   ISO-8859-1 (CodePage=28591 西ヨーロッパ言語 (ISO))
                            return Encoding.GetEncoding(28591).GetString(Data, offset, Data.Length-offset);
                        }
                        else {
                            // 日本ではShift_JISが横行しているらしい(?)
                            //   shift_jis(CodePage=932)
                            // return Encoding.GetEncoding(CodePage_Shift_JIS).GetString(Data, offset, Data.Length-offset);
                            return defaultEncoding.GetString(Data, offset, Data.Length-offset);
                        }
                    }
                    else {
                        // unknown
                        return "";
                    }
                }
                catch ( DecoderFallbackException ) {
                    // 読み取り不能
                    return "";
                }
            }

            public static Frame Parse(int majorVer, byte[] buffer, ref int pos, int endPos)
            {
                Frame ret = new Frame();

                if ( endPos > buffer.Length ){ return null; }

                if ( endPos < pos+6 ) { return null; }
                if ( majorVer >= 3 && endPos < pos+10 ) { return null; }

                ret.MajorVer = majorVer;
                try {
                    ret.ID = Encoding.ASCII.GetString(buffer, pos, (majorVer<=2)?3:4 );
                }
                catch ( DecoderFallbackException ) {
                    // 読み取り不能
                    Console.WriteLine("Failed to parse at address 0x" + pos.ToString("X"));
                    return null;
                }

                if ( majorVer <= 2 ) {
                    ret.Size = (buffer[pos+3]<<16) | (buffer[pos+4]<<8) | (buffer[pos+5]);
                    pos += 6;
                }
                else {
                    if ( majorVer <= 3 ) {
                        ret.Size = (buffer[pos+4]<<24) |
                                   (buffer[pos+5]<<16) | 
                                   (buffer[pos+6]<<8) |
                                   (buffer[pos+7]);
                    }
                    else {
                        ret.Size = SynchsafeIntFrom4Bytes(buffer, pos+4);
                    }
                    ret.Flags1 = buffer[pos+8];
                    ret.Flags2 = buffer[pos+9];
                    pos += 10;
                }
                if ( endPos < pos + ret.Size ) {
                    Console.WriteLine("Failed to parse at address 0x" + pos.ToString("X"));
                    Console.WriteLine("Address over");
                    return null;
                }
                if ( ret.Size > 0 ) {
                    ret.Data = new byte[ret.Size-1];
                    ret.EncodingID = buffer[pos];
                    Array.Copy(buffer, pos+1, ret.Data, 0, ret.Size-1);
                }
                else {
                    ret.Data = new byte[0];
                    ret.EncodingID = 0;
                }
                pos += ret.Size;

                return ret;
            }
        }

        public int TagVerMajor{get;private set;}
        public int TagVerMinor{get;private set;}
        public int Flags{get;private set;}
        public int TagSize{get;private set;}
        public int ExtSize{get;private set;}

        public bool HasExtendedHeader{get{return ((Flags&0x40)!=0);}}
        public bool HasFooter{get{return ((Flags&0x10)!=0);}}

        List<Frame> Frames;

        int FindFirstFrameByID(string FrameID)
        {
            for ( int i=0 ; i<Frames.Count ; i++ ) {
                if ( Frames[i].ID == FrameID ) {
                    return i;
                }
            }
            return -1;
        }

        public string Artist  { get { return GetStringByID("TP1","TPE1"); } }
        public string Title   { get { return GetStringByID("TT2","TIT2"); } }
        public string Album   { get { return GetStringByID("TAL","TALB"); } }
        public string Track   { get { return GetStringByID("TRK","TRCK"); } }
        public string Year    { get { return GetStringByID("TYE","TYER"); } }
        public string Genre   { get { return GetStringByID("TCO","TCON"); } }
        public string Comment { get { return GetStringByID("COM","COMM"); } }
        // public Bitmap Jacket { get { return GetJacketByID("PIC","APIC"); } }

        string GetStringByID(string idForV3p2, string idForV3p3)
        {
            string id = (TagVerMajor<=2)?idForV3p2:idForV3p3;
            if ( id == null ) { return ""; }
            int index = FindFirstFrameByID(id);
            if ( index < 0 ) { return ""; }
            return Frames[index].ToString(_enc);
        }

        public static ID3v2 CreateDefault(Encoding enc)
        {
            ID3v2 ret = new ID3v2();
            ret._enc = enc;

            ret.Frames = new List<Frame>();
            ret.TagVerMajor = 4;
            ret.TagVerMinor = 0;
            ret.Flags   = 0;
            ret.TagSize = 0; // ダミー
            ret.ExtSize = 0; // ダミー

            return ret;
        }

        public static ID3v2 ParseFromFile(string fileName, Encoding enc)
        {
            using (var fs = new FileStream(fileName, FileMode.Open, FileAccess.Read)) {

                byte[] buffer = new byte[10];
                fs.Read(buffer,0,10); // サイズが分かるところまで読み込む

                // ヘッダチェック "ID3"
                if ( buffer[0] != 0x49 || buffer[1] != 0x44 || buffer[2] != 0x33) {
                    return null;
                }

                int size = 10 + SynchsafeIntFrom4Bytes(buffer,6);
                Array.Resize(ref buffer, size);
                fs.Read(buffer,10,size-10);

                return ID3v2.Parse(buffer, 0, enc);
            }
        }


        // enc未実装
        public static ID3v2 Parse(byte[] buffer, int offset, Encoding enc)
        {
            ID3v2 ret = new ID3v2();

            if ( offset < 0 ) {
                return null;
            }
            // ID3v2は最低でも10byte以上なので10byte以上であることをチェックする
            if (buffer.Length < offset + 10 ) {
                return null;
            }

            // ヘッダチェック "ID3"
            if ( buffer[offset] != 0x49 || buffer[offset+1] != 0x44 || buffer[offset+2] != 0x33) {
                return null;
            }

            ret._enc = enc;
            ret.TagVerMajor = buffer[offset+3];
            ret.TagVerMinor = buffer[offset+4];
            ret.Flags = buffer[offset+5];
            ret.TagSize = SynchsafeIntFrom4Bytes(buffer, offset+6);

            int pos = offset+10;
            int endPos = pos + ret.TagSize;
            if ( endPos > buffer.Length ) {
                return null;
            }
            if ( ret.HasFooter ) {
                endPos -= 10; // Footer(10byte)分末尾位置を手前にセットする
            }

            if ( ret.HasExtendedHeader ) {
                // 最小6byteある
                if ( buffer.Length < pos+6 ) {
                    return null;
                }
                if ( ret.TagVerMajor <= 3 ) {
                    // IDv2.3.x以下
                    ret.ExtSize = (buffer[pos  ]<<24) |
                                  (buffer[pos+1]<<16) |
                                  (buffer[pos+2]<< 8) |
                                  (buffer[pos+3]    );
                }
                else {
                    ret.ExtSize = SynchsafeIntFrom4Bytes(buffer, pos);
                }
                pos += 4 + ret.ExtSize;
            }

            // parsing Frame
            ret.Frames = new List<Frame>();
            while ( pos < endPos ) {
                if ( buffer[pos] == 0 ) {
                    break;
                }
                Frame t = Frame.Parse(ret.TagVerMajor, buffer, ref pos, endPos);
                if ( t == null ) {
                    Console.WriteLine("Failed to parse at address 0x" + pos.ToString("X"));
                    return null;
                }
                else {
                    ret.Frames.Add(t);
                }
            }
            return ret;
        }
    }

}

ソースコード

using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

using ID3Util;


class MainForm : Form
{
    DataGridView dgv;
    BindingList<Mp3Item> items;
    BindingSource wrapper;


    // DataGridViewに表示する項目
    public class Mp3Item : INotifyPropertyChanged
    {
        static readonly string TextForEdited = "変更";

        // DataSourceの対象の中身のデータを変更しても更新されない場合がある対策としてINotifyPropertyChangedを実装
        public event PropertyChangedEventHandler PropertyChanged;

        private void NotifyPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] String propertyName = "")
        {
            if (PropertyChanged != null) {
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
            }
        }

        // フィールドは表示されない。プロパティにする必要がある。
        public string FolderPath     {get{return _folderPath;     } set{if(value != _folderPath     ){                          _folderPath     =value;NotifyPropertyChanged();}}}
        public string FileNameWoExt  {get{return _fileNameWoExt;  } set{if(value != _fileNameWoExt  ){                          _fileNameWoExt  =value;NotifyPropertyChanged();}}}

        public string ID3v1Edited    {get{return _id3v1edited;    } set{if(value != _id3v1edited    ){                          _id3v1edited    =value;NotifyPropertyChanged();}}}
        public bool   ID3v1Enabled   {get{return _id3v1enabled;   } set{if(value != _id3v1enabled   ){ID3v1Edited=TextForEdited;_id3v1enabled   =value;NotifyPropertyChanged();}}}
        public string V1Artist       {get{return _v1artist;       } set{if(value != _v1artist       ){ID3v1Edited=TextForEdited;_v1artist       =value;NotifyPropertyChanged();}}}
        public string V1Album        {get{return _v1album;        } set{if(value != _v1album        ){ID3v1Edited=TextForEdited;_v1album        =value;NotifyPropertyChanged();}}}
        public string V1Title        {get{return _v1title;        } set{if(value != _v1title        ){ID3v1Edited=TextForEdited;_v1title        =value;NotifyPropertyChanged();}}}
        public string V1Track        {get{return _v1track;        } set{if(value != _v1track        ){ID3v1Edited=TextForEdited;_v1track        =value;NotifyPropertyChanged();}}}
        public string V1Year         {get{return _v1year;         } set{if(value != _v1year         ){ID3v1Edited=TextForEdited;_v1year         =value;NotifyPropertyChanged();}}}
        public string V1Genre        {get{return _v1genre;        } set{if(value != _v1genre        ){ID3v1Edited=TextForEdited;_v1genre        =value;NotifyPropertyChanged();}}}
        public string V1Comment      {get{return _v1comment;      } set{if(value != _v1comment      ){ID3v1Edited=TextForEdited;_v1comment      =value;NotifyPropertyChanged();}}}

        public string ID3v2Edited    {get{return _id3v2edited;    } set{if(value != _id3v2edited    ){                          _id3v2edited    =value;NotifyPropertyChanged();}}}
        public bool   ID3v2Enabled   {get{return _id3v2enabled;   } set{if(value != _id3v2enabled   ){ID3v2Edited=TextForEdited;_id3v2enabled   =value;NotifyPropertyChanged();}}}
        public string V2Artist       {get{return _v2artist;       } set{if(value != _v2artist       ){ID3v2Edited=TextForEdited;_v2artist       =value;NotifyPropertyChanged();}}}
        public string V2Album        {get{return _v2album;        } set{if(value != _v2album        ){ID3v2Edited=TextForEdited;_v2album        =value;NotifyPropertyChanged();}}}
        public string V2Title        {get{return _v2title;        } set{if(value != _v2title        ){ID3v2Edited=TextForEdited;_v2title        =value;NotifyPropertyChanged();}}}
        public string V2Track        {get{return _v2track;        } set{if(value != _v2track        ){ID3v2Edited=TextForEdited;_v2track        =value;NotifyPropertyChanged();}}}
        public string V2Year         {get{return _v2year;         } set{if(value != _v2year         ){ID3v2Edited=TextForEdited;_v2year         =value;NotifyPropertyChanged();}}}
        public string V2Genre        {get{return _v2genre;        } set{if(value != _v2genre        ){ID3v2Edited=TextForEdited;_v2genre        =value;NotifyPropertyChanged();}}}


        string _folderPath;
        string _fileNameWoExt;// フォルダと拡張子除く

        string _id3v1edited;
        bool   _id3v1enabled;
        string _v1artist;
        string _v1album;
        string _v1title;
        string _v1track;
        string _v1year;
        string _v1genre;
        string _v1comment;

        string _id3v2edited;
        bool   _id3v2enabled;
        string _v2artist;
        string _v2album;
        string _v2title;
        string _v2track;
        string _v2year;
        string _v2genre;


        public static Mp3Item ConvertToMp3Item(string mp3FileName)
        {
            bool _tmp_id3v1enabled = true;
            bool _tmp_id3v2enabled = true;

            if ( ! ( File.Exists(mp3FileName) && mp3FileName.EndsWith(".mp3", true, null) ) ) {// Note: EndsWithの第2引数はignoreCase
                return null;
            }

            Encoding enc = Encoding.GetEncoding(CodePage_Shift_JIS);

            ID3v1 id3v1 = ID3v1.ParseFromFile(mp3FileName, enc);
            if ( id3v1 == null ) {
                _tmp_id3v1enabled = false;
                id3v1 = ID3v1.CreateDefault(enc);
            }

            ID3v2 id3v2 = ID3v2.ParseFromFile(mp3FileName, enc);
            if ( id3v2 == null ) {
                _tmp_id3v2enabled = false;
                id3v2 = ID3v2.CreateDefault(enc);
            }

            var item = new Mp3Item(){
                FolderPath = Path.GetDirectoryName(Path.GetFullPath(mp3FileName)),
                FileNameWoExt = Path.GetFileNameWithoutExtension(mp3FileName),

                ID3v1Enabled = _tmp_id3v1enabled,
                V1Artist  = id3v1.Artist,
                V1Album   = id3v1.Album,
                V1Title   = id3v1.Title,
                V1Track   = id3v1.Track.ToString(),
                V1Year    = id3v1.Year.ToString(),
                V1Genre   = id3v1.Genre,
                V1Comment = id3v1.Comment,

                ID3v2Enabled = _tmp_id3v2enabled,
                V2Artist  = id3v2.Artist,
                V2Album   = id3v2.Album,
                V2Title   = id3v2.Title,
                V2Track   = id3v2.Track,
                V2Year    = id3v2.Year,
                V2Genre   = id3v2.Genre,
            };
            item.ID3v1Edited = "";
            item.ID3v2Edited = "";

            return item;
        }
    }

    const int CodePage_Shift_JIS = 932;

    MainForm(string filePath)
    {
        items = new BindingList<Mp3Item>();

        Text = "Mp3TagViewer";
        ClientSize = new Size(840,450);


        var menuStrip1 = new MenuStrip(); // https://dobon.net/vb/dotnet/control/menustrip.html

        SuspendLayout();
        menuStrip1.SuspendLayout();

        var fileMenuItem = new ToolStripMenuItem(){ Text = "ファイル(&F)"};
        var editMenuItem = new ToolStripMenuItem(){ Text = "編集(&E)"};
        menuStrip1.Items.Add(fileMenuItem);
        menuStrip1.Items.Add(editMenuItem);

//        fileMenuItem.DropDownItems.Add( new ToolStripMenuItem("開く(&O)...", null, (s,e)=>{OpenTemplateWithDialog();}, Keys.Control | Keys.O) );
//        fileMenuItem.DropDownItems.Add( new ToolStripMenuItem("保存(&S)...", null, (s,e)=>{SaveTemplateWithDialog();}, Keys.Control | Keys.S) );

//        editMenuItem.DropDownItems.Add( new ToolStripMenuItem("アイコン(.ico)として保存(&I)...", null, (s,e)=>{SaveImageWithDialog("ico");}, Keys.Control | Keys.I) );
//        editMenuItem.DropDownItems.Add( new ToolStripMenuItem("画像(.png)として保存(&P)...",     null, (s,e)=>{SaveImageWithDialog("png");}, Keys.Control | Keys.P) );



        Controls.Add(
            dgv = new DataGridView() {
                //Location = new Point(0, 0),
                //Size = new Size(800, 400),
                Dock = DockStyle.Fill,
                AllowUserToAddRows = false,
                AutoGenerateColumns = false,
                AllowDrop = true,
            }
        );

        //https://dobon.net/vb/dotnet/datagridview/addcolumn.html

        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width = 100, DataPropertyName = "FolderPath",    Name = "FolderPath",    HeaderText = "場所",       ReadOnly=true});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width = 110, DataPropertyName = "FileNameWoExt", Name = "FileNameWoExt", HeaderText = "ファイル名", ReadOnly=true});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  30, DataPropertyName = "ID3v1Edited",   Name = "ID3v1Edited",   HeaderText = "v1編集状態", ReadOnly=true});
        dgv.Columns.Add(new DataGridViewCheckBoxColumn(){Width=  50, DataPropertyName = "ID3v1Enabled",  Name = "ID3v1Enabled",  HeaderText = "ID3v1"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  80, DataPropertyName = "V1Artist",  Name = "V1Artist",  HeaderText = "[v1]アーティスト"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  80, DataPropertyName = "V1Album",   Name = "V1Album",   HeaderText = "[v1]アルバム"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width = 110, DataPropertyName = "V1Title",   Name = "V1Title",   HeaderText = "[v1]曲名"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  35, DataPropertyName = "V1Track",   Name = "V1Track",   HeaderText = "[v1]トラック"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  45, DataPropertyName = "V1Year",    Name = "V1Year",    HeaderText = "[v1]年"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  60, DataPropertyName = "V1Genre",   Name = "V1Genre",   HeaderText = "[v1]ジャンル"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  50, DataPropertyName = "V1Comment", Name = "V1Comment", HeaderText = "[v1]コメント"});

        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  30, DataPropertyName = "ID3v2Edited",   Name = "ID3v2Edited",   HeaderText = "v2編集状態", ReadOnly=true});
        dgv.Columns.Add(new DataGridViewCheckBoxColumn(){Width=  50, DataPropertyName = "ID3v2Enabled",  Name = "ID3v2Enabled",  HeaderText = "ID3v2"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  80, DataPropertyName = "V2Artist",  Name = "V2Artist",  HeaderText = "[v2]アーティスト"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  80, DataPropertyName = "V2Album",   Name = "V2Album",   HeaderText = "[v2]アルバム"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width = 110, DataPropertyName = "V2Title",   Name = "V2Title",   HeaderText = "[v2]曲名"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  35, DataPropertyName = "V2Track",   Name = "V2Track",   HeaderText = "[v2]トラック"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  45, DataPropertyName = "V2Year",    Name = "V2Year",    HeaderText = "[v2]年"});
        dgv.Columns.Add(new DataGridViewTextBoxColumn(){Width =  60, DataPropertyName = "V2Genre",   Name = "V2Genre",   HeaderText = "[v2]ジャンル"});

        wrapper = new BindingSource() {
            DataSource = items
        };

        dgv.DataSource = wrapper;

        dgv.DragEnter += Control_DragEnter;
        dgv.DragDrop += Control_DragDrop;


        this.AllowDrop = true;
        this.DragEnter += Control_DragEnter;
        this.DragDrop += Control_DragDrop;


        Controls.Add(menuStrip1);
        MainMenuStrip = menuStrip1;
        menuStrip1.ResumeLayout(false);
        menuStrip1.PerformLayout();
        ResumeLayout(false);
        PerformLayout();


        if ( filePath != null ) {
            RegisterID3FromFile(filePath);
        }
    }

    void Control_DragEnter(Object sender, DragEventArgs e)
    {
        if (e.Data.GetDataPresent(DataFormats.FileDrop)) {
            e.Effect = DragDropEffects.Copy;
        }
        else {
            e.Effect = DragDropEffects.None;
        }
    }

    void Control_DragDrop(Object sender, DragEventArgs e)
    {
        var fileNames = (string[])e.Data.GetData(DataFormats.FileDrop, false);
        if ( fileNames != null && fileNames.Length >= 1 ) {
            foreach ( var s in fileNames ) {
                RegisterID3FromFile(s);
            }
        }
    }

    void RegisterID3FromFile(string filePath)
    {
        var item = Mp3Item.ConvertToMp3Item(filePath);
        if ( item != null ) {
            wrapper.Add(item);
        }
    }



    [STAThread]
    static void Main(string[] args)
    {
        Application.Run(new MainForm((args.Length==1)?args[0]:null));
    }
}

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その3~

前回

【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その2~
の続きです。

前回までは、チャットページでSignalRを使用してブラウザ間で文字のやり取りが見れる所までやりました。
今回はログイン機能の見直しをしたいと思います。

現在のログイン画面の問題点

前にもちらっと書きましたが、今のままだとユーザーIDとパスワードに何かしらの文字さえ入れておけば認証が通る仕組みです。
なので、事前に登録したユーザーID・パスワードを使用してログイン出来るようにしてみましょう。

どのようにログイン情報を保持するか

ぱっと思いつく方法だと、自前のDBにユーザー情報を登録しておき、ログインボタン押下時にユーザーIDとパスワードでレコードが抽出できるか、というのが思い浮かびました。

ただそれだと面白くないというか、この記事を見ている人がパッと試しにくくなりますしそれよりもよい方法があるのではという漠然とした考えがあったので調べてみました。

IDaaS

イマドキの流行りはこのIDaaSを使用しているみたいですね。

IDaaS(Identity as a Service)の略です。読み方は「アイディーアース」または「アイダース」と呼びます。SSO(シングルサインオン)等のID認証をクラウド経由で提供するサービスです。最近はWebサービスやスマートフォン以外の機器が増えてきました。それぞれ、ID認証する必要が出てきており複雑化してきています。

image.png

image.png
出典:認証サービスiDaaSのFirebaseとAuth0 機能比較

簡単に纏めると、
昔はDBにIDとパスワードを保存してたけどサービスが多くなりすぎてそれを制御する開発者側が辛い、消費者側も覚えとくの辛い。
んじゃ一個どれか覚えておけば全部アクセスできるようにすればいいじゃん!ということを叶えてくれる仕組みなわけですね。

具体例を挙げると、
何かしらのサービスにログインする場合(ex.ニコニコ動画 とか)に、
ツイッター、グーグル、フェイスブック、アップル、それかユーザーIDとパスワード、どれか持ってればログインできるよ?って聞かれた方が使う側としてもログインが楽だよねって事です。
image.png

じゃあどういうIDaaSがあるんでしょうか。

Amazon Cognito

Amazon Cognito(コグニート)というIdP(Identity Provider)AWSで提供されているということを知りました。
しかもなんと、5万回の認証まで無料(!!!)だそうです。これはよい。

でも、TwitterInstagramなどの認証には対応していないようです。
image.png

Auth0

Auth0というのもあるようです。

  • OIDC/OAuth2を利用した認証・認可が可能
  • 画面はAuth0側で自由に作り込みが可能(アプリ内に画面を自分で作り込んで持つことも可能)
  • ソーシャル連携が可能(ボタンをオン・オフすると、標準画面にソーシャルログインボタンが出現します)
  • MFA(Multi-Factor Authentication : 多要素認証)が可能
  • パイプライン・HOOK機能で、サインアップ・サインイン等、特定のアクションにlambda的なロジックの挟み込むことが出来る

出典:Auth0 導入編

結構、高機能っぽいですね。
特に、画面が作れる所やダッシュボードからサンプルコードを落とせる所が凄くよいなと思いました。

しかし、最低でも月$15…。(開発アカウントだけなら無料)

Firebase Authentication

最後に、Firebase Authenticationを紹介。

Firebase Authenticationには、バックエンドサービス、使いやすいSDK、アプリでのユーザー認証に使用できる UI ライブラリが用意されています。Firebase Authentication では、パスワード、電話番号、一般的なフェデレーション ID プロバイダ(Google、Facebook、Twitter)などを使用した認証を行うことができます。

出典:Firebase - ドキュメント

こちらも機能が充実してそうですね。
調べたんですがイマイチ料金体系がよくわかりませんでした。
恐らく、Firebase Authentication単体でのサービス提供はしていなさそうです。

選ばれたのはCognitoでした

機能面で言えば、恐らくはAuth0の方がよさげですが今回はお財布に優しいCognitoで実装してみたいと思います。

Cognitoの実装前の準備

まずは チュートリアル: ユーザープールの作成 に沿って、ユーザープールの作成をしてください。
この時に生成されるPoolIDはメモしておいてください。

次に、ドメイン名を適当に登録する必要があるらしいです。
image.png

ユーザーを作成する際に面倒なので、ポリシーからパスワードの強度を最大まで下げておくと楽です。
image.png

そしてこれが最重要かつ壮大な罠。
アプリクライアント作成時に「クライアントシークレットを生成」のチェックを外して登録してください。シークレットを生成すると認証が上手くいきません。
image.png

これはやらなくてもよいですが、ユーザーの作成をしておくと後々の説明が頭に入ってきやすくなるとおもいます。このユーザーはCognitoの認証を許可されたユーザーとなります。
image.png

ここまで出来たら準備完了です。

Cognitoの実装

ブログやQiitaで実装している記事がちらほらあったので余裕じゃん!と思ったら案外上手くいきませんでした。半ば心折れそうになってましたが無事に実装出来たので安心してください。

一番参考になったのは以下の動画です。
AWS Cognito C# example

NuGetから以下二つのライブラリをインストールしてください。

  • Amazon.AspNetCore.Identity.Cognito
  • Amazon.Extensions.CognitoAuthentication

image.png

Index.razor.csを以下の様にします。これで認証が通るようになります。

Index.razor.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Amazon;
using Amazon.CognitoIdentityProvider;
using Amazon.CognitoIdentityProvider.Model;
using Amazon.Extensions.CognitoAuthentication;
using Amazon.Runtime;
using LoginTest.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;

namespace LoginTest.Pages
{
    public partial class Index
    {
        #region 定数
        private const string PoolID = "us-east-1_hogehoge";
        private const string ClientID = "hogehogehogehoge";
        private RegionEndpoint Region = RegionEndpoint.USEast1;
        #endregion

        #region フィールド
        private string _loginErrorMessage;
        #endregion

        #region プロパティ
        /// <summary>
        /// Inject属性を指定することで、NavigationManagerのサービスの依存関係を挿入します。
        /// </summary>
        [Inject]
        public NavigationManager Navigation { get; set; }
        /// <summary>
        /// ログイン情報を保持
        /// </summary>
        public LoginData LoginData { get; set; }
        #endregion

        #region コンストラクタ
        public Index()
        {
            //Index.razorから参照するのでインスタンス生成をしておかないとエラーとなる
            LoginData = new LoginData();
        }
        #endregion

        #region メソッド
        /// <summary>
        /// Validate処理成功時に処理
        /// </summary>
        /// <param name="context"></param>
        public async Task OnValidSubmit(EditContext context)
        {
            Console.WriteLine($"OnValidSubmit()");

            var errorMessage = await SignInUserAsync();
            if (string.IsNullOrEmpty(errorMessage))
            {
                Navigation.NavigateTo("Chat", false);
            }
            else
            {
                _loginErrorMessage = errorMessage;
            }
        }
        /// <summary>
        /// Validate処理失敗時に処理
        /// </summary>
        /// <param name="context"></param>
        public void OnInvalidSubmit(EditContext context)
        {
            Console.WriteLine($"OnInvalidSubmit()");
        }
        /// <summary>
        /// Cognitoで使用できるユーザーを作成する
        /// </summary>
        /// <returns></returns>
        public async Task<string> SignUpUserAsync()
        {
            //Regionが「USEast1」なのは作成したPoolIDのプレフィックスと同じにしている為
            using var provider = new AmazonCognitoIdentityProviderClient(new AnonymousAWSCredentials(), Region);
            var signUpRequest = new SignUpRequest
            {
                ClientId = ClientID,
                Username = LoginData.UserID,
                Password = LoginData.Password,

                UserAttributes = new List<AttributeType>
                {
                    new AttributeType{Name = "email", Value="hogehoge@gmail.com"},
                },
            };

            try
            {
                var result = await provider.SignUpAsync(signUpRequest).ConfigureAwait(false);
                return string.Empty;
            }
            catch (Exception e)
            {
                return e.Message;
            }
        }
        /// <summary>
        /// Cognitoで登録したユーザかを判別する
        /// </summary>
        /// <returns></returns>
        private async Task<string> SignInUserAsync()
        {
            using var provider = new AmazonCognitoIdentityProviderClient(new AnonymousAWSCredentials(), RegionEndpoint.USEast1);

            var userPool = new CognitoUserPool(PoolID, ClientID, provider);
            var cognitoUser = new CognitoUser(LoginData.UserID, ClientID, userPool, provider);
            var authRequest = new InitiateSrpAuthRequest
            {
                Password = LoginData.Password,
            };

            try
            {
                var authResponse = await cognitoUser.StartWithSrpAuthAsync(authRequest).ConfigureAwait(false);
                var userRequest = new GetUserRequest
                {
                    AccessToken = authResponse.AuthenticationResult.AccessToken,
                };

                await provider.GetUserAsync(userRequest).ConfigureAwait(false);
                return string.Empty;
            }
            catch (Exception e)
            {
                return e.Message;
            }
        }
        #endregion
    }
}

最初にログイン画面を作った記事から追加しているのはSignUpUserAsyncメソッドSignInUserAsyncメソッドです。

SignUpUserAsyncメソッドに関しては、先ほどの手順で示したCognitoへのユーザー追加をコード上から行っているだけです。ただ一つ注意点として、SignUpUserAsyncメソッドからユーザーを作成した場合、ユーザーの確認をしてやる必要があります。これをしないと認証が通りませんでした。

コード上からアカウントのステータスを更新できる方法も恐らくあるんでしょうけど、ぱっとは分かりませんでした。知っていれば教えて欲しいです。
キャプチャ.JPG

また、SignInUserAsyncメソッドではCognitoに登録したユーザーが存在するかを取得しています。いずれのメソッドも、エラーメッセージを返すようにしており、問題がなければ空文字が返ってきます。

Cognitoでハマった所

①謎のRegion
AmazonCognitoIdentityProviderClientクラスの第二引数にRegionという謎のパラメーターを渡さないといけません。よくわかりませんが、発行されたPoolIDの接頭辞に付いているものと同じRegionEndpointを渡せば良いようです。

②謎のエラー
認証時に、以下のエラーメッセージが返ってきていました。

「Unable to verify secret hash for client」
クライアントの秘密ハッシュを確認できません

これを読んで、確かにシークレットハッシュ渡してないなと思って渡すようにしたんですが、それでも解消されませんでした。調べてみたところ
AWS Cognito User Pools のサインアップ時に NotAuthorizedException が出る (JavaScript)
という記事の中で言及されていますが、上記の手順で示した通り「クライアントシークレットを生成」のチェックを外す必要があったようです。

この記事が無ければ、無限にハマるところでした。

③パスワードのエラー
認証時に「validation errors detected: Value at 'password' failed to satisfy constraint: Member must have length greater than or equal to 6; Value at 'password' failed to satisfy constraint: Member must satisfy regular expression pattern: ^[\S]+.*[\S]+$"}」というエラーが出て何事かと思ったんですが、ユーザー登録時のパスワードは6桁以上にしようね、と怒られていました。

これも上記で設定した通りのポリシーに沿ってユーザー登録時にエラーを出してくれているみたいです。すごい。

あとはログイン認証失敗時のエラーを出す場所を追加で用意してあげます。
なんか他にいい書き方があれば教えてください。

Index.razor
@if (!string.IsNullOrEmpty(_loginErrorMessage))
{
    <div class="form-group">
        <p style="color: red; font-weight: bold;">@_loginErrorMessage</p>
    </div>
}                                                          

これで実行してみます。

最初はパスワードをわざと間違え、エラーを表示させる。
次に、Cognitoに登録しているユーザーID・パスワードであれば認証が通り、晴れてチャット画面へ遷移できることが確認できました。
Counter.gif

しかし、これでもまだ不完全です。
それはどこでしょうか?次回の記事をお楽しみに!

まとめ

実装量としては全然大したことありませんでしたが、ドキュメントが少なさ過ぎてかなりてこずってしまいました。Cognitoを使ってみたことがなかったので一つ使える知識が増えたのかなと思います。

参考にさせて頂いたページ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その2~

前回

【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~
の続きです。

前回までは、ログイン画面をそれとなく作って、チャットページに遷移するまで作りました。
今回は実際にチャットができる所までやってみたいと思います。

チャット画面の作成

そこまで難しくないので頑張っていきましょー

Chat.razor Chat.razor.csの作成

新たに、Chat.razorファイルChar.razor.csファイルを作りましょう。
image.png

Chat.razor
@page "/Chat"

<h1>チャット</h1>

<div align="center">
    <div class="form-group">
        <input @bind="_messageInput" size="50"/>
        <button class="btn btn-primary" @onclick="SendAsync">送信</button>
    </div>

    <hr>

    <div align="left">
        @foreach (var message in _messages)
        {
            @*HTMLのタグをそのまま出力する為にMarkUpString型にキャストする*@
            @((MarkupString)message)<br>
        }
    </div>
</div>
Chat.razor.cs
using System.Collections.Generic;

namespace LoginTest.Pages
{
    public partial class Chat
    {
        #region フィールド
        private List<string> _messages = new List<string>();
        private string _messageInput;
        #endregion

        #region メソッド
        /// <summary>
        /// 送信ボタン押下時に発火
        /// </summary>
        public void SendAsync()
        {
            _messages.Insert(0, _messageInput);
        }
        #endregion
    }
}

これを実行してみると、ブラウザ間の通信がうまくいきません。
それもそのはず、SignalRが未実装だからです。
Counter.gif

SignalR

SignalRとは何ぞや、というのは以下の記事が参考になると思います。
ASP.NET SignalRを知る (1/5)

簡単に言えば、サーバーとクライアント間の非同期通信を簡単に実装できるフレームワークです。
SignalRを使用した典型的な例としてチャットが挙げられるので、今回はチャットを作成します。

SignalRを実装してみる

.NET MVCでもSignalRを実装した事がありますが、当然(?)ながら当時はJavaScriptを使用してクライアントへの画面描画を行っていました。

しかしBlazorではクライアント処理もフルC#で書くことが出来る為煩わしさが若干軽減されます。

NugetからMicrosoft.AspNetCore.SignalR.Clientをインストールします。
(Microsoft.AspNetCore.SignalR.Client.Coreではなく。)

image.png

あとは、Chat.razor.csを以下のように書き換えてください。

Chat.razor.cs
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.SignalR.Client;

namespace LoginTest.Pages
{
    public partial class Chat : ComponentBase, IDisposable
    {
        #region フィールド
        private List<string> _messages = new List<string>();
        private string _messageInput;
        private HubConnection _hubConnection;
        #endregion

        #region プロパティ
        [Inject]
        public NavigationManager Navigation { get; set; }
        public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
        private async Task SendAsync()
        {
            await _hubConnection.SendAsync("SendMessageClientsAll", _messageInput);
            _messageInput = string.Empty;
        }
        #endregion

        #region メソッド
        /// <summary>
        /// 
        /// </summary>
        /// <returns></returns>
        protected override async Task OnInitializedAsync()
        {
            _hubConnection = new HubConnectionBuilder()
                .WithUrl(Navigation.ToAbsoluteUri("/chathub")) //startupのMapHubで指定したHubのURLを指定する.
                .WithAutomaticReconnect(new RandomRetryPolicy()) //自動接続
                .Build();

            _hubConnection.On<string>("ReceiveMessage", (message) =>
            {
                if (string.IsNullOrEmpty(message)) return;

                //入力文字列の中にURLが存在するかどうかを判定だけする
                var urlPattern = new Regex(@"(https?|ftp)(:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+)");
                var urlPatternMatch = urlPattern.Match(message);

                //入力文字列をサニタイズする
                message = HttpUtility.HtmlEncode(message);

                //入力文字列の中にURLが存在する場合はアンカータグに変換する
                if (urlPatternMatch.Success)
                {
                    message = message.Replace(urlPatternMatch.Value, $"<a href=\"{urlPatternMatch}\">{urlPatternMatch}</a>");
                }

                _messages.Insert(0, message);
                StateHasChanged();
            });

            _hubConnection.Reconnected += async connectionId =>
            {
                await _hubConnection.StartAsync();
            };

            //画面の更新を行う
            await _hubConnection.StartAsync();
        }
        /// <summary>
        /// 
        /// </summary>
        public void Dispose()
        {
            _ = _hubConnection.DisposeAsync();
        }
        #endregion

        #region クラス
        public class RandomRetryPolicy : IRetryPolicy
        {
            private readonly Random _random = new Random();

            public TimeSpan? NextRetryDelay(RetryContext retryContext)
            {
                //2~5秒の間でランダムに再接続を試みる
                return TimeSpan.FromSeconds(_random.Next(2, 5));
            }
        }
        #endregion
    }
}

コンストラクタに書いても良かったんですが、OnInitializedAsyncメソッドを使用する為にComponentBaseクラスを継承しています。(razorファイル内でのコンストラクタってどうやって書くんだろう。。。)

ハブの追加は以下のページに詳細に書いてあるので、この手順に沿って追加すればおk。
ASP.NET Core SignalR を Blazor WebAssembly と共に使用する

ソリューションにHubsフォルダChatHub.csを追加。
image.png

メッセージを受け取ったら、全クライアントにメッセージを流す処理を追加する。

ChatHub.cs
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;

namespace LoginTest.Hubs
{
    public class ChatHub : Hub
    {
        public async Task SendMessageClientsAll(string message)
        {
            await Clients.All.SendAsync("ReceiveMessage", message);
        }
    }
}

全部載せる必要もないと思いますが、上記のURL通りにStartup.csを以下の様にSignalRに対応させる記述を書きます。

Startup.cs
using System.Linq;
using LoginTest.Hubs;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace LoginTest
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSignalR();
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddResponseCompression(opts =>
            {
                opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
                    new[] { "application/octet-stream" });
            });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseResponseCompression();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapBlazorHub();
                endpoints.MapHub<ChatHub>("/chathub");
                endpoints.MapFallbackToPage("/_Host");
            });
        }
    }
}

これでSignalRの実装ができました。実際に動作を見てみましょう。
Counter.gif

SignalRの動作について

どういう順序で処理が発生しているのかを追ってみましょう。

①まず最初に、[送信]ボタンを押す所から処理がスタートするので、Chat.razorを見ます。

Chat.razor
<button class="btn btn-primary" @onclick="SendAsync">送信</button>

②送信ボタン押下時に発火するのはSendAsyncメソッドであり、この処理が紐づいているのはコードビハインドとなります。

Chat.razor.cs
private async Task SendAsync()
{
    await _hubConnection.SendAsync("SendMessageClientsAll", _messageInput);
    _messageInput = string.Empty;
}

SendAsyncメソッドでは定義したChatHubSendMessageClientsAllメソッドを呼んでいます。

ChatHub.cs
public async Task SendMessageClientsAll(string message)
{
    await Clients.All.SendAsync("ReceiveMessage", message);
}

SendMessageClientsAllメソッド内でReceiveMessageが呼ばれます。これはChat.razor.csOnInitializedAsyncメソッドでハブを定義した際に作った処理です。

Chat.razor.cs
_hubConnection.On<string>("ReceiveMessage", (message) =>
{
    if (string.IsNullOrEmpty(message)) return;

    //入力文字列の中にURLが存在するかどうかを判定だけする
    var urlPattern = new Regex(@"(https?|ftp)(:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+)");
    var urlPatternMatch = urlPattern.Match(message);

    //入力文字列をサニタイズする
    message = HttpUtility.HtmlEncode(message);

    //入力文字列の中にURLが存在する場合はアンカータグに変換する
    if (urlPatternMatch.Success)
    {
        message = message.Replace(urlPatternMatch.Value, $"<a href=\"{urlPatternMatch}\">{urlPatternMatch}</a>");
    }

    _messages.Insert(0, message);
    StateHasChanged();
});

⑤送信時に入力されているメッセージが_messagesに追加され最後にStateHasChangedメソッドを呼ぶ事でChat.razorの処理を通って画面に反映されます。

Chat.razor
<div align="left">
    @foreach (var message in _messages)
    {
        @*HTMLのタグをそのまま出力する為にMarkUpString型にキャストする*@
        @((MarkupString)message)<br>
    }
</div>

Enter押下時にメッセージを送信する方法

今の記述だと、メッセージを送信する為に毎回ボタンを押す必要があり、少し手間です。
そこでEnter押下時にもメッセージが送信されるように修正してみましょう。

Chat.razor
<div class="form-group">
    @*<input @bind="_messageInput" size="50"/>*@
    <input @bind="_messageInput" @bind:event="oninput" size="50" @onkeydown="KeyDownAsync"/>
    <button class="btn btn-primary" @onclick="SendAsync">送信</button>
</div>

修正方法としては簡単で、@bind:event="oninput"@onkeydown="KeyDownAsync"を付けてください。

@onkeydown="KeyDownAsync"はわかりやすいと思いますが、割り付けている項目に対してキーダウンイベントを発生させるようにします。

@bind:eventにはデフォルトでは@bind:event="onchange"が割付いており、これはフォーカスロストした場合に発火します。つまり文字を入力して次にエンターを押下しても、項目に対してフォーカスが入ったままなのでプロパティに反映されず_messageInputにはnullが入っている事になります。

これを避ける為にも、@bind:event="oninput"とすることにより入力文字が変化する度にプロパティへの値の反映をさせることができます。

あとはKeyDownAsyncメソッドを追加して、Enter押下時にのみSendAsyncメソッドを呼び出すようにしてあげれば完了です。

Chat.razor.cs
private async Task KeyDownAsync(KeyboardEventArgs e)
{
    if (e.Key == "Enter")
    {
        await SendAsync();
    }
}

分からなかった点

もし分かる方がいらっしゃれば教えて頂きたいです。
SignalRの自動再接続についてです。

以下の様にWithAutomaticReconnectメソッドを噛ましており、RandomRetryPolicyクラスを引数として渡しているのでサーバーが落ちた場合に2~5秒おきに無限回の再接続要求がされると考えていました。

次のような想定
1. デバッグでサーバー側を起動
2. 同じURLを指定し、別のタブでチャットを起動
3. デバッグを終了
4. デバッグを再開
5. 2で開いたタブが2~5秒おきに再接続を試みており復帰

Chat.razor.cs
_hubConnection = new HubConnectionBuilder()
    .WithUrl(Navigation.ToAbsoluteUri("/chathub"))
    .WithAutomaticReconnect(new RandomRetryPolicy())
    .Build();

_hubConnection.Reconnected += async connectionId =>
{
    await _hubConnection.StartAsync();
};

public class RandomRetryPolicy : IRetryPolicy
{
    private readonly Random _random = new Random();

    public TimeSpan? NextRetryDelay(RetryContext retryContext)
    {
        //2~5秒の間でランダムに再接続を試みる
        return TimeSpan.FromSeconds(_random.Next(2, 5));
    }
}

しかし実際には、サーバー側が復帰してもクライアント側は自動接続されず、リロードが求められてしまいます。
image.png

ReconnectedイベントStartAsyncメソッドを実装しているのでリロードを必要とせず復帰するかと思いきやそんなことはなかった。何か思い違いをしているのでしょうか。。。

まとめ

SignalRを使った場合でもBlazorであればフルC#で書けることが分かりました。

Enter押下時の処理などもJavaScriptif(keycode == '13'){}などのような記述をしていたりしましたが、@onkeydownと書くことにより処理をC#に任せられるというのはかなりよいですね。

次回は正しいユーザーでログインした場合にログインを許可するような実装を行います。

参考にさせて頂いたページ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~

はじめに

Blazorがリリースされてから1年程経ったのでしょうか。
ボチボチとドキュメントとか記事とかが上がってきているみたいなので重い腰をあげてやってみることにしました。

Blazorやってみたいけどどうすりゃいいんだろ、って人多いと思う(?)のでその手助けとなる記事になればと。

環境

  • VS2019
  • .NET Core SDK 3.1

情報収集

とりあえず色々とよくわかんないのでBlazor Advent Calendar 2019を一通り読んでみました。

分かった事としてはこう。

Razor記法

MVC時代razor記法という、HTMLC#を悪魔合体させられる記述が可能となった。cshtmlという拡張子。

例えば、こんな感じ。

hoge.cshtml
@for (int i = 0; i < 3; i++) {
  <p>for文のループの「@i」回目です。</p>
}

C#の構文と組み合わせる事で同じような記述を排除できるスグレモノ。
ただ、クライアントの処理は当然、JavaScriptで書く必要があった。まーしょうがないよね。

Blazor

そんな時に現れたのがBlazor
コイツはクライアント処理までC#で記述することができます。razorという拡張子。

counter.razor
@page "/counter"

<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

counter.gif

フルC#により処理が完結させられるのは興奮しました。

これをみればある程度は分かる、かも。
キャプチャ.JPG

懸念

.razorファイルUIやらロジックやらを書いていけばいいのはわかった。十分に理解できた。ただ気になった事として、.razorファイルがめちゃめちゃfatになりませんか?ってのが非常に気になりました。

でも大丈夫っぽさそうで、
Blazorでコードビハインドでロジックとビューを分離して記述するに解決策が書いてあり、どうやらちゃんと分けて書くことができるようで安心しました。

今回はPartial Classでコードを書きます。ViewModelを使ってもよかったですが、DIをわざわざ書いてやる必要があったのでパス。

最終成果物

あとでGIFとソースコードを載せます。

アプリケーションの作成

さて、これから実際に作っていきます。

画面遷移図

特に要らないとも思いましたが、やったことないことをちゃんとやっていこうと思ったので簡易的ですが、LucidChartというサイトで作ってみました。
image.png

環境の構築

私の場合は色々と環境が整っていたのでやったのは.NET Core 3.1のSDKをインストールするぐらい。
Download .NET Core 3.1

Visual Studioが入ってないとかBlazorのテンプレートがない場合は以下の記事を参考にするとよいと思います。
C#でSPAを作るBlazorの、開発環境の作り方 - Visual Studio編

アプリケーションの作成

Blazor をお勧めできる人は誰か?を読んだんですが、サーバーとクライアントのどちらをどういう場合に使い分けるのかがよく分からなかったので取り急ぎはサーバーの方で作成します。

サーバー兼クライアントとして動かせるのでまーよいでしょう。
image.png

実行

↓↓↓↓↓↓↓ あなたの記事の内容
とりあえず実行してみて動くっていうのは初学者からすると物凄くハードルが下がるので非常にありがたいです。ということで作りたてほやほやのソリューションをデバッグしてみるとこうなります。
───────
とりあえず実行してみて動くっていうのは初学者からすると物凄く分かりやすいので非常にありがたいです。ということで作りたてほやほやのソリューションをデバッグしてみるとこうなります。
↑↑↑↑↑↑↑ 編集リクエストの内容
image.png

色合いがすげーイイ感じです。
では早速、このイイ感じの画面をぶっ壊していきます。

ログイン画面の生成

ログイン画面を生成していきましょう。

MainLayout.razorの修正

MainLayout.razorBodyだけにします。
左のナビゲーションからページ遷移をさせたくないので。

MainLayout.razor
@inherits LayoutComponentBase

<div class="main">
    <div class="content px-4">
        @Body
    </div>
</div>

Indexページの修正

インデックスページをログイン画面として使います。

当初は、こういう書き方をしていましたが、

Index.razor
<table style="border:none">
    <tr>
        <th>
            <p>ユーザID</p>
        </th>
        <td>
            <input id="txtUserID" type="text" size="24" @bind="@UserID">
        </td>
    </tr>
    <tr>
        <th>
            <p>パスワード</p>
        </th>
        <td>
            <input id="txtPassword" type="password" size="24" @bind="@Password">
        </td>
    </tr>
    <tr>
        <td colspan="2" align="center">
            <input type="submit" value="ログイン">
            <button class="btn btn-primary" @onclick="Login">ログイン</button>
        </td>
    </tr>
</table>

調べてみるとEditFormタグを使えばValidationをイイ感じにやってくれることが分かったので以下の様に変更しました。

Index.razor
@page "/"
@* 本当はこのusingディレクティブが無くても動いてくれるけど、using追加しろと言われ続けてしまうのでしょうがなく追加 *@
@using LoginTest.Models

<div align="center">
    <EditForm Model="@LoginData" OnValidSubmit="@OnValidSubmit" OnInvalidSubmit="@OnInvalidSubmit">
        <DataAnnotationsValidator />
        @* ValidationSummaryを配置した箇所に全てのValidationメッセージが表示される *@
        @*<ValidationSummary />*@

        <div class="form-group">
            <label>ユーザーID</label>
            <InputText id="txtUserID" @bind-Value="LoginData.UserID" />
            <ValidationMessage For="@(() => LoginData.UserID)" />
        </div>

        <div>
            <label>パスワード</label>
            <InputText id="txtPassword" type="password" @bind-Value="LoginData.Password" />
            <ValidationMessage For="@(() => LoginData.Password)" />
        </div>

        <button type="submit" class="btn btn-primary">ログイン</button>
    </EditForm>
</div>

EditFormについては以下の記事を参考にさせていただきました。

Blazorでコードビハインドでロジックとビューを分離して記述するにも書いてありますが、razorファイルに対応するcsファイルを作成することでコードビハインドとして扱ってくれます。今回で言うと、Index.razor.csファイルPagesフォルダ直下に作成してみてください。

すると、勝手に紐づけてくれます。しゅごい。
image.png

コードビハインドはこうなります。

Index.razor.cs
using System;
using LoginTest.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;

namespace LoginTest.Pages
{
    public partial class Index
    {
        #region プロパティ
        /// <summary>
        /// Inject属性を指定することで、NavigationManagerのサービスの依存関係を挿入します。
        /// </summary>
        [Inject]
        public NavigationManager Navigation { get; set; }
        /// <summary>
        /// ログイン情報を保持
        /// </summary>
        public LoginData LoginData { get; set; }
        #endregion

        #region コンストラクタ
        public Index()
        {
            //Index.razorから参照するのでインスタンス生成をしておかないとエラーとなる
            LoginData = new LoginData();
        }
        #endregion

        #region メソッド
        /// <summary>
        /// Validate処理成功時に処理
        /// </summary>
        /// <param name="context"></param>
        public void OnValidSubmit(EditContext context)
        {
            Console.WriteLine($"OnValidSubmit()");
            Navigation.NavigateTo("Chat", false);
        }
        /// <summary>
        /// Validate処理失敗時に処理
        /// </summary>
        /// <param name="context"></param>
        public void OnInvalidSubmit(EditContext context)
        {
            Console.WriteLine($"OnInvalidSubmit()");
        }
        #endregion
    }
}
ハマりの共有

少しハマってしまったことが2点あります。
1点目に、Navigation.NavigateToメソッドで遷移するにあたって、NavigationManagerを、Injectする必要があります。
遷移先のページを保持している情報を取得しなければいけないということですね。


2点目に、OnSubmitイベントOnValidSubmit/OnInvalidSubmitイベントは併用不可であり同時に定義すると実行時エラーが発生してしまいます。

念の為に書いておくと、

  • OnSubmitイベント … Submitボタン押下時に必ず発生する
  • OnValidSubmit/OnInvalidSubmitイベント … Submitボタン押下時、Validationが成功すればOnValidSubmit、失敗すればOnInvalidSubmitが発生する

実用的な使い勝手としては断然、OnValidSubmit/OnInvalidSubmitイベントでしょうね。でも併用できても良いと思うんだけどなー。


Modelの作成

ここまでくれば大体ログイン画面はできました。
Modelsフォルダとその中にLoginData.csを作成してください。
image.png

LoginData.csを以下の様なモデルを作成してやればある程度完成です!

LoginData.cs
using System.ComponentModel.DataAnnotations;

namespace LoginTest.Models
{
    public class LoginData
    {
        [Required(ErrorMessage = "ユーザIDを入力してください。")]
        [StringLength(16, ErrorMessage = "ユーザIDが長すぎます。")]
        public string UserID { get; set; }

        [Required(ErrorMessage = "パスワードを入力してください。")]
        [StringLength(32, ErrorMessage = "パスワードが長すぎます。")]
        public string Password { get; set; }
    }
}

動作確認

チャットページに遷移できるように、Pagesフォルダ以下にChat.razorファイルを追加することで以下のような動きになります。

最低限。@page "/Chat"だけ記述されていれば遷移出来るはずです。
Counter.gif

テキストボックスへ未入力時にアノテーションが効いててイイ感じです。
ただ今の状態だと、何かしらの値がIDとパスワードに入ってさえいればログインできてしまうのでこの点は後で実装します。

まとめ

Blazorだとクライアント処理もC#で書ける反面、全ての処理をrazorファイルに記述しないといけないと思っていましたが、ちゃんとそこら辺を考えてあって分離できるのはよい機構だなと思いました。

次の記事では、遷移先のチャットページを作成してみたいと思います。

参考にさせて頂いたページ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む