- 投稿日:2020-08-10T18:44:50+09:00
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その4~
前回
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その2~
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その3~の続きです。
前回は、
Cognito
を使用して登録されているユーザーでないとログインできないようにしました。
これだとまだとある問題を抱えていますが、その前に今回はログイン情報の保持をさせるようにしたいと思います。ログイン情報の保持
多くのサービスは以下の様な処理でログイン情報を保持しているかと思います。
今回はこの方式に則って、機能追加をしていこうと思います。
出典:セキュリティ対策はばっちり? セッションと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
でどのページでも使いまわせるようにしましょう、という粋な計らいです。
コードは以下の通りです。
CookieService.csusing 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.cspublic 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つのサービスの事を指します。最初から使えるサービス達ですね。今回、私はサーバーサイドでソリューションを作成しています。
また、デフォルトサービスであるIJSRuntimeクラス
を使用したいと考えています。
上記の画像を見てみればわかりますが、サーバーサイドではIJSRuntimeクラス
の有効期間は「スコープ」となっています。
なので以下の様に、CookieServiceクラス
を「スコープ」で追加しています。Startup.csservices.AddScoped<CookieService>(); //★この行を追加★ここを合わせる必要があると分かっていなかったので最初はシングルトンで追加していました。そうすると以下のようなエラーが出力されました。
InvalidOperationException: Cannot consume scoped service 'Microsoft.JSInterop.IJSRuntime' from singleton 'LoginTest.Service.CookieService'.
(InvalidOperationException:シングルトン「LoginTest.Service.CookieService」からスコープサービス「Microsoft.JSInterop.IJSRuntime」を使用できません。)サービスの有効期間についての説明はいかの通りです。スコープはサーバーサイドにしかない有効期間なのですね。
ログイン情報を使いまわす
クッキーやローカルストレージを利用して、ログイン情報を保持します。また。ログイン情報が保存されているのであれば自動的にチャットページへルーティングしてあげましょう。
まずは
NuGet
からBlazored.LocalStorage
をインストールします。
このライブラリを使用することにより、非常に簡単にローカルストレージを使用することができます。ローカルストレージであればブラウザを閉じたり別タブでも保存している情報が引き継げるので今回はローカルストレージを使用します。
出典:JavaScriptのsessionStorageの使い方を現役エンジニアが解説【初心者向け】
ユーザーがページに訪れた際に行うべき処理は次の通りです。
- ログインページへアクセス
- ユーザーのクッキーに保存されているセッションIDを取得
- クッキーのセッションIDと一致するローカルストレージに保持されているセッションIDを見つける
- ローカルストレージ内に保持している前回ログイン情報を取得
- チャットページへルーティングする
クッキーにセッションIDが保存されていなければ作成し、ログイン成功時にローカルストレージへセッションIDをキーとしてログイン情報を保持させるようにすればよい、ということになります。
HTTPContext
が使えないので、セッションID
はNewGuid
で代用しています。
以下の処理で上記の1~5の処理が実装出来ました。Index.razor.csprotected 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.cspublic 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; } }実行してみる
ログイン画面がちらっと見えてしまいますが、すぐにチャットページへルーティングされることが確認できます。
ログイン画面が見えてしまうのは恐らく、
OnAfterRenderAsyncメソッド
でルーティングしているせいだと考えています。しかし、OnBeforeRenderメソッド
はありません。ただプルリクエストがあったので、今後は使えるようになることでしょう。
OnBeforeRender #1716まとめ
これまでの記事では
フルC#
でしたが、クッキーを扱う為にJavaScript
を使うハメになってしまいました。恐らく今後は、こういったC#
以外の部分はライブラリが出てくると思うのでそれに期待したいと思います。次回は、認証をしなくてもチャットページへルーティング出来てしまう問題を解決したいと思います。
参考にさせていただいたページ
- セキュリティ対策はばっちり? セッションとCookieの違いとPHPでの使い方をご紹介!
- ASP.NET Core Blazor 依存関係の挿入
- BlazorでSPAするぞ!(7) - DI(Dependency Injection) -正式版対応済
- InvalidOperationException: Cannot consume scoped service 'Microsoft.JSInterop.IJSRuntime' from singleton '…IAuthentication' in Blazor
- BlazorアプリケーションでC#のコードからJavaScriptを呼び出す
- ASP.NET Core Blazor で .NET メソッドから JavaScript 関数を呼び出す
- cookieをjavascriptで設定、取得、削除する簡単な方法
- ASP.NET Core Blazor 状態管理
- 「Cookie」と「セッション」と「セッションCookie」の違い
- 投稿日:2020-08-10T09:53:18+09:00
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/7209Vector3.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を揃えているだけという認識で良さそう。
- 投稿日:2020-08-10T07:48:09+09:00
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 }
- 投稿日:2020-08-10T07:35:41+09:00
【続】 mp3のタグ(ID3v1, ID3v2)を読み込む(ライブラリ不使用) + DataGridViewで一覧表示する
DataGridViewを使って一覧表示できるようにしてみました。保存機能(ファイルへの反映処理)は未対応。
参考サイト
mp3のID3v1, ID3v2タグ
前記事の参考サイト
DataGridView
- https://garafu.blogspot.com/2016/09/cs-datagridview-customdata.html
- https://qiita.com/lusf/items/dcce573787e808ccb0ea
- propertyを変更したら自動でコントロールに通知する方法
画面キャプチャ
ソースコード ID3Util.cs
ID3Util.csusing 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)); } }
- 投稿日:2020-08-10T00:08:31+09:00
【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認証する必要が出てきており複雑化してきています。
出典:認証サービスiDaaSのFirebaseとAuth0 機能比較簡単に纏めると、
昔はDBにIDとパスワードを保存してたけどサービスが多くなりすぎてそれを制御する開発者側が辛い、消費者側も覚えとくの辛い。
んじゃ一個どれか覚えておけば全部アクセスできるようにすればいいじゃん!ということを叶えてくれる仕組みなわけですね。具体例を挙げると、
何かしらのサービスにログインする場合(ex.ニコニコ動画 とか)に、
ツイッター、グーグル、フェイスブック、アップル、それかユーザーIDとパスワード、どれか持ってればログインできるよ?って聞かれた方が使う側としてもログインが楽だよねって事です。
じゃあどういう
IDaaS
があるんでしょうか。Amazon Cognito
Amazon Cognito(コグニート)という
IdP(Identity Provider)
がAWS
で提供されているということを知りました。
しかもなんと、5万回の認証まで無料(!!!)だそうです。これはよい。でも、
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 Authentication
単体でのサービス提供はしていなさそうです。選ばれたのはCognitoでした
機能面で言えば、恐らくは
Auth0
の方がよさげですが今回はお財布に優しいCognito
で実装してみたいと思います。Cognitoの実装前の準備
まずは チュートリアル: ユーザープールの作成 に沿って、ユーザープールの作成をしてください。
この時に生成されるPoolID
はメモしておいてください。ユーザーを作成する際に面倒なので、ポリシーからパスワードの強度を最大まで下げておくと楽です。
そしてこれが最重要かつ壮大な罠。
アプリクライアント作成時に「クライアントシークレットを生成」のチェックを外して登録してください。シークレットを生成すると認証が上手くいきません。
これはやらなくてもよいですが、ユーザーの作成をしておくと後々の説明が頭に入ってきやすくなるとおもいます。このユーザーは
Cognito
の認証を許可されたユーザーとなります。
ここまで出来たら準備完了です。
Cognitoの実装
ブログや
Qiita
で実装している記事がちらほらあったので余裕じゃん!と思ったら案外上手くいきませんでした。半ば心折れそうになってましたが無事に実装出来たので安心してください。一番参考になったのは以下の動画です。
AWS Cognito C# example
NuGet
から以下二つのライブラリをインストールしてください。
- Amazon.AspNetCore.Identity.Cognito
- Amazon.Extensions.CognitoAuthentication
Index.razor.cs
を以下の様にします。これで認証が通るようになります。Index.razor.csusing 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メソッド
からユーザーを作成した場合、ユーザーの確認をしてやる必要があります。これをしないと認証が通りませんでした。コード上からアカウントのステータスを更新できる方法も恐らくあるんでしょうけど、ぱっとは分かりませんでした。知っていれば教えて欲しいです。
また、
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・パスワードであれば認証が通り、晴れてチャット画面へ遷移できることが確認できました。
しかし、これでもまだ不完全です。
それはどこでしょうか?次回の記事をお楽しみに!まとめ
実装量としては全然大したことありませんでしたが、ドキュメントが少なさ過ぎてかなりてこずってしまいました。
Cognito
を使ってみたことがなかったので一つ使える知識が増えたのかなと思います。参考にさせて頂いたページ
- 認証サービスiDaaSのFirebaseとAuth0 機能比較
- Amazon Cognito(コグニート)
- Auth0
- Firebase Authentication
- AWS Cognito C# example
- C#でCognitoを利用し、ユーザー登録→認証を実装する。
- Windowsフォームアプリの認証にCognitoを使ってみた
- Amazon CognitoAuthentication 拡張ライブラリの例
- プログラミングせずにCognitoで新規ユーザー登録&サインインを試してみる
- AWS Cognito User Pools のサインアップ時に NotAuthorizedException が出る (JavaScript)
- UnityでCognito UserPoolsを使ってサインアップ・サインインを実現する
- チュートリアル: ユーザープールの作成
- 投稿日:2020-08-10T00:06:45+09:00
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その2~
前回
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~
の続きです。前回までは、ログイン画面をそれとなく作って、チャットページに遷移するまで作りました。
今回は実際にチャットができる所までやってみたいと思います。チャット画面の作成
そこまで難しくないので頑張っていきましょー
Chat.razor Chat.razor.csの作成
新たに、
Chat.razorファイル
・Char.razor.csファイル
を作りましょう。
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.csusing 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
が未実装だからです。
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
ではなく。)あとは、
Chat.razor.cs
を以下のように書き換えてください。Chat.razor.csusing 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
を追加。
メッセージを受け取ったら、全クライアントにメッセージを流す処理を追加する。
ChatHub.csusing 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.csusing 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
の実装ができました。実際に動作を見てみましょう。
SignalRの動作について
どういう順序で処理が発生しているのかを追ってみましょう。
①まず最初に、[送信]ボタンを押す所から処理がスタートするので、
Chat.razor
を見ます。Chat.razor<button class="btn btn-primary" @onclick="SendAsync">送信</button>②送信ボタン押下時に発火するのは
SendAsyncメソッド
であり、この処理が紐づいているのはコードビハインドとなります。Chat.razor.csprivate async Task SendAsync() { await _hubConnection.SendAsync("SendMessageClientsAll", _messageInput); _messageInput = string.Empty; }③
SendAsyncメソッド
では定義したChatHub
のSendMessageClientsAllメソッド
を呼んでいます。ChatHub.cspublic async Task SendMessageClientsAll(string message) { await Clients.All.SendAsync("ReceiveMessage", message); }④
SendMessageClientsAllメソッド
内でReceiveMessage
が呼ばれます。これはChat.razor.cs
のOnInitializedAsyncメソッド
でハブを定義した際に作った処理です。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.csprivate 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)); } }しかし実際には、サーバー側が復帰してもクライアント側は自動接続されず、リロードが求められてしまいます。
Reconnectedイベント
にStartAsyncメソッド
を実装しているのでリロードを必要とせず復帰するかと思いきやそんなことはなかった。何か思い違いをしているのでしょうか。。。まとめ
SignalR
を使った場合でもBlazor
であればフルC#
で書けることが分かりました。
Enter押下時
の処理などもJavaScript
でif(keycode == '13'){}
などのような記述をしていたりしましたが、@onkeydown
と書くことにより処理をC#
に任せられるというのはかなりよいですね。次回は正しいユーザーでログインした場合にログインを許可するような実装を行います。
参考にさせて頂いたページ
- 投稿日:2020-08-10T00:05:58+09:00
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~
はじめに
Blazor
がリリースされてから1年程経ったのでしょうか。
ボチボチとドキュメントとか記事とかが上がってきているみたいなので重い腰をあげてやってみることにしました。
Blazor
やってみたいけどどうすりゃいいんだろ、って人多いと思う(?)のでその手助けとなる記事になればと。環境
- VS2019
- .NET Core SDK 3.1
情報収集
とりあえず色々とよくわかんないのでBlazor Advent Calendar 2019を一通り読んでみました。
分かった事としてはこう。
Razor記法
MVC時代
にrazor記法
という、HTML
とC#
を悪魔合体させられる記述が可能となった。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++; } }
フルC#
により処理が完結させられるのは興奮しました。懸念
.razorファイル
にUI
やらロジックやらを書いていけばいいのはわかった。十分に理解できた。ただ気になった事として、.razorファイル
がめちゃめちゃfatになりませんか?ってのが非常に気になりました。でも大丈夫っぽさそうで、
Blazorでコードビハインドでロジックとビューを分離して記述するに解決策が書いてあり、どうやらちゃんと分けて書くことができるようで安心しました。今回は
Partial Class
でコードを書きます。ViewModel
を使ってもよかったですが、DI
をわざわざ書いてやる必要があったのでパス。最終成果物
あとでGIFとソースコードを載せます。
アプリケーションの作成
さて、これから実際に作っていきます。
画面遷移図
特に要らないとも思いましたが、やったことないことをちゃんとやっていこうと思ったので簡易的ですが、LucidChartというサイトで作ってみました。
環境の構築
私の場合は色々と環境が整っていたのでやったのは
.NET Core 3.1のSDKをインストール
するぐらい。
Download .NET Core 3.1
Visual Studio
が入ってないとかBlazor
のテンプレートがない場合は以下の記事を参考にするとよいと思います。
C#でSPAを作るBlazorの、開発環境の作り方 - Visual Studio編アプリケーションの作成
Blazor をお勧めできる人は誰か?を読んだんですが、サーバーとクライアントのどちらをどういう場合に使い分けるのかがよく分からなかったので取り急ぎはサーバーの方で作成します。
実行
↓↓↓↓↓↓↓ あなたの記事の内容
とりあえず実行してみて動くっていうのは初学者からすると物凄くハードルが下がるので非常にありがたいです。ということで作りたてほやほやのソリューションをデバッグしてみるとこうなります。
───────
とりあえず実行してみて動くっていうのは初学者からすると物凄く分かりやすいので非常にありがたいです。ということで作りたてほやほやのソリューションをデバッグしてみるとこうなります。
↑↑↑↑↑↑↑ 編集リクエストの内容
色合いがすげーイイ感じです。
では早速、このイイ感じの画面をぶっ壊していきます。ログイン画面の生成
ログイン画面を生成していきましょう。
MainLayout.razorの修正
MainLayout.razor
をBody
だけにします。
左のナビゲーションからページ遷移をさせたくないので。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フォルダ
直下に作成してみてください。コードビハインドはこうなります。
Index.razor.csusing 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
を作成してください。
LoginData.cs
を以下の様なモデルを作成してやればある程度完成です!LoginData.csusing 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"
だけ記述されていれば遷移出来るはずです。
テキストボックスへ未入力時にアノテーションが効いててイイ感じです。
ただ今の状態だと、何かしらの値がIDとパスワードに入ってさえいればログインできてしまうのでこの点は後で実装します。まとめ
Blazor
だとクライアント処理もC#
で書ける反面、全ての処理をrazorファイル
に記述しないといけないと思っていましたが、ちゃんとそこら辺を考えてあって分離できるのはよい機構だなと思いました。次の記事では、遷移先のチャットページを作成してみたいと思います。
参考にさせて頂いたページ
- Download .NET Core 3.1
- C#でSPAを作るBlazorの、開発環境の作り方 - Visual Studio編
- Blazor をお勧めできる人は誰か?
- Blazor Advent Calendar 2019
- Blazorでコードビハインドでロジックとビューを分離して記述する
- Blazorアプリケーションでボタンやリンクのクリックの処理で別のページに遷移する
- Blazorにおけるフォームバリデーション手法のまとめ
- BlazorでSPAするぞ!(8) - Validation -正式版対応済
- Using EditForm | Part - 29 | Using ASP.Net Blazor for Absolute Beginners