C#: Statelessライブラリを使う

先日、図のような状態遷移を扱う設計だったところに、if文やswitch文を多用して状態遷移を実現しているコードに出会いました。

今回議論の対象にする状態遷移

こういうのは、古からの知恵としてステートマシンを使って実現すると、スッキリして保守や拡張に強いコードになることが知られています。(参考:Stateパターン

自前で実現しても大したことはない内容なので、自作のライブラリに自前実装のステートマシンを持っていて、それを利用している人も多いのではないでしょうか。

.NETな環境で開発する場合、Statelessというライブラリが非常に軽く、取り回しも良く、個人的には好きなのですが、これを同僚に紹介したところ、日本語情報の少なさに難色を示されました。

そこで今日は、Statelessの日本語記事を自分で書くことで日本語情報の少なさをカバーしようという目論見です。

ちなみに、日本語情報などなくとも、StatelessのGitHubページにあるExampleフォルダ配下に優良なサンプルが用意されていますので、コードを読むのに抵抗が無い方はそちらを参照するのが良いと思います。

冒頭の図をStatlessを使って実現してみます。

適当なコンソールプロジェクトを用意し、プロジェクトの依存関係にStatelessを追加しておきます。コマンドからやる場合はこんな具合です。

dotnet new console -lang C#
dotnet add package Stateless

今回は、投稿を扱うプログラムなので状態遷移に則って動いていくクラスとして、Postクラスを用意します。

using Stateless;

namespace StateMachineExample.Posts;
public class Post
{
    // 投稿ステータス
    private enum PostStatus
    {
        Draft, // 下書き
        Publishing, // 公開中
        Published,  // 公開済み
        PublishError // 公開エラー
    };

    // 投稿に関するイベント
    private enum PostEventTrigger
    {
        SaveAsDraft, // 下書き保存
        ToPublish, // 公開中
        PublishSucceed, // 公開成功
        PublishFailed  // 公開失敗
    };

    // 今回のキモ。状態遷移マシン
    private readonly StateMachine<PostStatus, PostEventTrigger> _machine;
    // 投稿の成功状態を判定するダミーメソッド
    private static bool PublishResult() => true;
    // 状態遷移時に行いたい処理がある場合、対応するためのメソッドを用意する
    private static void SaveAsDraftEvent() => Console.WriteLine("再び下書きとして保存されました。");
    private static void PublishSucceedEvent() => Console.WriteLine("投稿公開に成功しました");
    private static void PublishFailedEvent() => Console.WriteLine("投稿公開に失敗しました");
    private void PublishEvent()
    {
        Console.WriteLine("投稿公開処理が開始されました");

        if (PublishResult())
        {
            // 投稿成功イベントを発火する
            _machine.Fire(PostEventTrigger.PublishSucceed);
        }
        else
        {
            // 投稿失敗イベントを発火する
            _machine.Fire(PostEventTrigger.PublishFailed);
        }
    }

    // コンストラクタ
    public Post()
    {
        // 初期状態は下書きにしておく
        _machine = new StateMachine<PostStatus, PostEventTrigger>(PostStatus.Draft);

        // 下書き状態に関する設定を行う
        // - 下書き状態のときに、「下書き保存」で再び下書き状態に遷移することを許可する
        // - 「公開」で公開中状態に遷移する
        // - 下書き状態から、もう一度下書き保存された時だけ、SaveAsDraftEventを実行する
        _ = _machine.Configure(PostStatus.Draft)
                    .PermitReentry(PostEventTrigger.SaveAsDraft)
                    .Permit(PostEventTrigger.ToPublish, PostStatus.Publishing)
                    .OnEntryFrom(PostEventTrigger.SaveAsDraft, SaveAsDraftEvent);

        // 公開中状態に関する設定を行う
        // - 公開成功で、公開済み状態に遷移する
        // - 公開失敗で、公開エラー状態に遷移する
        // - 公開中に遷移したら、PublishEventを実行する
        _ = _machine.Configure(PostStatus.Publishing)
                    .Permit(PostEventTrigger.PublishSucceed, PostStatus.Published)
                    .Permit(PostEventTrigger.PublishFailed, PostStatus.PublishError)
                    .OnEntry(PublishEvent);

        // 公開済み状態に関する設定を行う
        // - 公開済みに遷移したら、PublishSucceedEventを実行する
        _ = _machine.Configure(PostStatus.Published)
                    .OnEntry(PublishSucceedEvent);

        // 公開エラー状態に関する設定を行う
        // - 公開エラーに遷移したら、PublishFailedEventを実行する
        _ = _machine.Configure(PostStatus.PublishError)
                    .OnEntry(PublishFailedEvent);
    }


    // 外から投稿を操作するためのメソッド群
    public void SaveAsDraft() =>
        _machine.Fire(PostEventTrigger.SaveAsDraft); // 下書き保存イベントを発火する

    public void Publish() =>
        _machine.Fire(PostEventTrigger.ToPublish); // 公開イベントを発火する
}

やるべき事はほぼコード中にコメントで書いてある通りなのですが

  • Configureでどの状態に対する状態設定をするか指示する
  • Permit系のメソッドを使って、どのイベントでどの状態に遷移するかを定義する
  • OnEntryやOnExit等、On系のメソッドを使って状態に入った時や状態から出たとき等、処理実行したいタイミングに合わせて実行するメソッドを設定する
  • Fireメソッドでイベントを発火して、状態を遷移させる

という操作になります。例えば、用意したPostクラスはこのようにしてメインプログラムから呼び出して使うことができます。

using StateMachineExample.Posts;

// 初期状態の投稿を作成
var post = new Post();

// 投稿を公開する
post.Publish();

これを実行すると、コンソールにメッセージが出力されます。

投稿公開処理が開始されました
投稿公開に成功しました

ここまでで記述した内容は、コードをたどると次のような動きになっています。
思ったよりも複雑な処理が、スッキリと書けている事に気づくのではないでしょうか。

  • コンストラクタによって初期状態(Draft)として投稿が作成されるが、初めての作成で、以前の状態は無いのでSaveAsDraftEvent処理は行われない
  • post.Publish()でToPublishイベントが発火する。Draft状態のときにToPublishイベントが発生したら、Publishing状態に遷移する。
  • Publishing状態に遷移したら、PublishEvent処理が開始され、「投稿公開処理が開始されました」メッセージがコンソールに表示される
  • PublishEvent処理の中で、(今回は常に処理成功にしているので)PublishSucceedイベントを発火する。
  • Publishing状態のときにPublishSucceedイベントが発生したら、Published状態に遷移する
  • Published状態に遷移したら、PublishSucceedEvent処理が開始され「投稿公開に成功しました」メッセージがコンソールに表示される

Statelessでは、Permit系メソッドで許可されていない状態遷移はエラーになりますので、Program.csを次のように書き換えると、「投稿公開に成功しました」メッセージが表示された後に例外が発生します。

using StateMachineExample.Posts;

// 初期状態の投稿を作成
var post = new Post();

// 投稿を公開する
post.Publish();

// 一回公開すると、下書き保存はできない
post.SaveAsDraft();
投稿公開処理が開始されました
投稿公開に成功しました
Unhandled exception. System.InvalidOperationException: No valid leaving transitions are permitted from state 'Published' for trigger 'SaveAsDraft'. Consider ignoring the trigger.
   at Stateless.StateMachine`2.DefaultUnhandledTriggerAction(TState state, TTrigger trigger, ICollection`1 unmetGuardConditions)
   at Stateless.StateMachine`2.UnhandledTriggerAction.Sync.Execute(TState state, TTrigger trigger, ICollection`1 unmetGuards)
... 以下略

エラーメッセージに書いてある通りで、Publishedに対してSaveAdDraftのトリガーによる状態遷移が許可されていないことが分かります。

ifやswitchを多用して管理していると、予期せぬ状態遷移が発生してしまったりして不具合の原因になることが多いのですがステートマシンを上手に使って管理すれば、宣言的に状態を管理できるようになるので開発がかなり楽になると思います。

Selenium + WebDriverをさわる

先日から空き時間を見つけてちょこちょこと作っているツール、FSharp.DataのHttpクライアントを使ったPOST処理では、外部からの不正なリクエストとみなされてしまう状況が改善しないようなので、WebDriver + Seleniumを使ってアクセスをしてみることにしました。

そこで、まずはSeleniumの使い方を調べます。

Microsoft Edge は Selenium 向けのドライバを用意してくれていて、これを利用するのが良さそうです。チュートリアルページには(少し古いのでそのままでは動きませんでしたが)Bing にリクエストを送る簡単なサンプルがあったので、それを基にして、検索結果のページのタイトル一覧を取るプログラムを書いてみました。

EdgeのWebDriverをダウンロードする

こちらのページから自分の環境に合ったものをダウンロードしてきます。
(今回は、C\bin\WebDriver配下に配置しました。)

プロジェクトを作成して、Seleniumを使えるようにする

コンソールプログラムで作るので、コンソールのプロジェクトを作っておいてから、Selenium.WebDriver をプロジェクトに追加します。

dotnet new console -lang F# 
dotnet add package Selenium.WebDriver

Bingにリクエストを送って、検索結果ページのタイトル一覧を列挙する

open OpenQA.Selenium
open OpenQA.Selenium.Edge

let driverPath = @"C:\bin\WebDriver"
let service = EdgeDriverService.CreateDefaultService driverPath

// ヘッドレスモードで起動
let option = new EdgeOptions()
option.AddArgument("headless")

let driver = new EdgeDriver(service, option)

try
    driver.Url <- "https://bing.com"
    System.Threading.Thread.Sleep(3000)
    let input = driver.FindElement(By.Id("sb_form_q"))
    input.SendKeys("Microsoft Docs F#")
    input.Submit()

    System.Threading.Thread.Sleep(3000)

    //検索ページのタイトルを取得してコンソールに表示
    driver
        .FindElement(By.Id("b_results"))
        .FindElements(By.CssSelector("li[class='b_algo']"))
    |> Seq.iter (fun e -> printfn $"""{e.FindElement(By.TagName("h2")).Text}""")

finally
    driver.Quit()

ほとんどC#で書かれているチュートリアルページのコードそのままですが、いまのところのBingの作りでは、(多少のエラーはでるものの)これで期待している動きを実現できそうです。

ミソはUrlを代入した後のスリープ(Webページが読み込まれるまで待つ感じ)と、Submit後のスリープかなと思います。(どちらも無いと、ネットワークアクセスが早すぎてすぐ終わってしまいます。)

一旦、これでWebフォームへのアクセス手段を手に入れたので、ぼちぼちツールへの適用を進めていこうと思います。

Vivaldiのキャンペーンで「リンゴ」の詰め合わせをいただきました。

私はVivaldiブラウザが大好きで、PCは勿論、Android上でも、iOS上でもVivaldiを使っています(いっとき、iOSにVivaldiが無いのでiPhoneをやめようかと本気で考えたほどです。)

今回、iOS版のVivaldiの正式リリースに伴って、ギフトキャンペーンがあったので、軽い気持ちで応募してみました。

vivaldiのポスト
応募したキャンペーン

Vivaldiユーザの中には、日本地域だけで行われるギフトキャンペーンであることに違和感を感じた方も居たようですが、賛否あるユーザコミュニティのゆるい盛り上がりもVivaldiの魅力だと思うので、多くの方にVivaldiを使ってもらいたいです。

さて、実は応募したことも忘れていたのですが、今日キャンペーンの当選品が届きました!!

キャンペーンでいただいたりんごグッズ
キャンペーンでいただいたギフト

さっそくVivaldiのステッカーをPCにはりつけ、きになるリンゴをほおばりながらこの記事を書いています。

すばらしいギフトをありがとうございました!

Vivaldiは、Webブラウザだけではなく、メーラやRSSリーダが統合されているほか、自前の翻訳インフラももっていて、このブラウザやマストドンベースのソーシャルネットワークサービスも提供しています。

これからどんどん面白くなっていくブラウザコミュニティだと思いますので、ぜひ多くの方にVivaldiブラウザを使っていただいて、多くの人と関われるのを楽しみにしています。

意図しないリクエストとされて悪戦苦闘

「ブラウザ操作の自動化をしたい」訳なのだけれども、ツール化すると不正なリクエストとして処理されてしまう問題に悩まされている。

こういうのは、いっそPowerAutomateとかSelenium的なツールを使って自動化した方が良いのだろうか?

今回ターゲットにしているサイトでは、よくあるXSRFヘッダの検証や、CSRFトークン検証だけではない、外部ツールを防ぐ仕組みが構築されているようで、ここを突破するのに苦心している。

週末もあるし、もう少しじっくり取り組んでみても良いと思うが、ブラウザからの操作では問題なく通っているので、リクエストの投げ方とか、ヘッダとかクッキーとか、どこかのお作法に問題があるのだろうと考えているが、原因の特定に至っていない。

さてさて。定石があれば知りたいところだ。

昇進試験にチャレンジしてみる

社会人生活10年以上になる私ですが、これまで昇進というものを避けて通ってきました。

昇給を伴う等級アップ等は経験してきているものの、「部下が付く」役職を提示されると、その都度逃げるようにして転職してきた経緯があります。

このままずっとそんな調子で会社員生活を続けていっても良いんですが、人生で一度ぐらいは経験しておくべきかと思い、昇進試験を受けてみることにしました。

今現在勤めている会社では

  1. 昇進の意思があることを伝える
  2. 昇進要件(検定系の資格や勤続年数、外国語試験のスコア等)を満たしているか確認される
  3. 要件が満たされていれば、昇進試験を受けるチャンスが与えられる
  4. 半年程度(業務時間外で)昇進のための準備をして、試験に臨む
    (試験はプレゼンテーション+口頭試問だそうです)

という大まかに4ステップの試験を進む必要があり、試験の突破率は5割程度だそうです。

どんな内容の試験が課されるのか、試験のテーマについては様々探りを入れていますが、まだこれから全容が見えてくる段階にあるようで、久々の試験らしい試験に不安が半分、期待が半分といった所です。

さてさて、春にはサクラサクとなるでしょうか。

PowerShellスクリプトで日本語を扱う

先日、ちょっとした作業をPowerShellでスクリプト化する仕事をしました。

いつもの調子でUTF-8(BOMなし)で書いてチームメンバーに共有したところ、相手の環境では文字化けが発生してしまってまともに動かない状態に。文字化けの状況からSJISで保存すれば動きそうだなと判断して、SJISで保存し直してもらってスクリプト自体は無事に動いたのですが、手元ではUTF-8で動いているし、なんだか納得いかないので調べてみることにしました。

結論としては、古いPowerShell(powershell.exe)と新しいPowerShell(pwsh.exe)で文字の扱いが違う事によるのが今回の件の原因だったのですが、後学のために少しまとめておきます。

PowerShellにはマジックコメントの類は無い

マジックコメントとは、こういうやつで、スクリプトファイル自体の文字コードを処理系やエディタに伝える役割を果たすコメントの事です。

# -*- coding: utf-8 -*-

PowerShellにはこの類の機能は無く、また文字コードについて探してみても、スクリプトファイル自体の文字コードの扱いに関する情報はあまり出てきません。(多くがファイルから入力する際のエンコーディングの議論になっているようです)

BOM付UTF-8か、SJISで書いておくのが無難

「無難」という意味では、BOM付のUTF-8かSJISで書いておくのが良さそうです。(参考

BOMがあれば文字コードの自動判別を行ってくれるようなので、そのほかの選択肢もありそうですが、チーム内でスクリプトを共有するとなると(GitHub的な)中央集権型の管理が行われるでしょうから、そこで問題になりづらいエンコードを選択しておくのが良さそうです。

設定変数を介して制御する方法もある

$PSDefaultEncoding変数であったり、$OutputEncoding変数であったりを介して出力する文字列のエンコーディングを制御する事で、文字化けによるスクリプトのクラッシュを防ぐ方法もあるようです。

どの方法が良いかは、環境次第という感じですが、powershell.exeとpwsh.exeの両方で動かす必要のあるスクリプトについては、私はしばらく無難な方法を選択していこうかなと思っています。

FSharp.Dataライブラリをさわる(その2)

今日も昨日に引き続いて自作ツールのためにFSharp.Dataライブラリをさわっている。

最近のWebサービスのformはVue.jsなどを使って動的に生成されることが多く、さらにCSRF対策がされているため、自動化を進めるためにはいくらかの工夫が必要になる。

例えば、example.comといサイトにcreate-new-formというタグであらわされる動的生成のフォームがあったとして、そのタグが持っている:csrf属性に付与されているトークンをCSRFトークンとして送信するような作りになっていたとすると、フォームの自動入力に相当する操作に先んじてこのトークンを取得してくる必要がある。

<div id="mainArea" class="create-form-div">
<create-new-form
             name=""
            :errors='[]'
            locale="ja"
            :csrf='"9AWGbY8aAOq1gWcmD92ejrOSXBx9yEYo7BkafQ7T"'
        ></create-new-event-form>
</div>

こういう操作をするには、例えばPythonであればBeautiful Soupという著名なライブラリがあり、それを活用することでデータを取得することができる。

では、F#ではどうするか?――実は、この用途にもFSharp.Dataライブラリが活用できる。

具体的には、こんなコードを書く

open FSharp.Data

let results = HtmlDocument.Load("https://example.com")

let form =
    results.Descendants [ "create-new-form" ]
    |> Seq.choose (fun x -> x.TryGetAttribute(":csrf") |> Option.map (fun a -> a.Value()))
    |> Seq.toList

printfn $"{form}"

※ この例では、最後にSeq.toListを実行しているので、formはstring listになる。

こうしてCSRFトークンを得ることができたので、目指しているフォーム送信処理の自動化もできそうだ。

FSharp.Dataライブラリをさわる

最近、ちょっとした身近な課題を自動化したいので、ごく小さなツールを書いています。

そのなかでHTTP POSTなリクエストを送信する必要があったため、いくつかのライブラリを試してみています。

.NET Coreの標準ライブラリでもある程度の要求をカバーできそうだったのですが、FSharp.Dataというライブラリが便利そうだったので、それを触ってみようと思います。

新しいF#のプロジェクトを作る

dotnet new console -lang F#

適当なディレクトリに移動した後、dotnet newコマンドを使ってコンソールプロジェクトを作っておきます。

そこから次のコマンドで必要なパッケージを追加します。

dotnet add package Fsharp.Data

これで準備が整ったので、このブログのトップページを取得するコードを書いてみます。

open FSharp.Data
let html = Http.RequestString("https://10nin.vivaldi.net/")
printfn $"{html}"

FSharp.Data.HttpにはHTTPリクエストを送るのに便利なメソッドが収録されているので、それを使います。

非同期通信するためには、RequestStringと同様のAsyncRequestStringが用意されているので、こちらを使います。

open FSharp.Data

let access =
    task {
        let! html = Http.AsyncRequestString("https://10nin.vivaldi.net/")
        printfn $"{html}"
    }

printfn $"{access.Wait()}"

ナルホド。良さそうな感じです。

AsyncRequestStringとRequestStringで出来ることは同じようなので、アプリケーションの要件に合わせて使い分ければ良さそうですね。

FSharp.Data.Httpのリファレンスはここにあって、RequestStringにbody等のパラメータを渡してやれば、FormDataを送信するようなPOSTリクエストも簡単に書けそうです。

しばらくいじってみたいと思います。

MOBO Keyboard2でCapsLockとCtrlを入れ替える

昨日届いたMOBO Keyboard2。

良い良いなど記事を書いたわけだが、使っていてさっそく不都合が生じている。

私はEmacs的なキーバインド好んで使っているため、CapsLockキーをCtrlキーとして使うように設定変更をするのが通例なのだが、普段使っているPowerToysで設定変更したところ、どうにもうまくいかない。

具体的にどううまくいかないかというと、設定としてCapsLock→Ctrlへのキーマップを設定しても、キーボードのCapsLockを拾ってくれないのだ。

仕方なしにキー入力でCapsLockを読ませてみると、VK240というキーとして認識されている。

どうやらこれが原因らしいことがわかって、VK240→Ctrlへのリマップを設定して使ってみた。

と、最初はなかなか都合よく動いてくれているように見えたが、ここでも新たな問題が生じる。

CapsLockキーの姿をしたCtrlキーは、一度押すとCtrlが入りっぱなしになってしまう。

Ctrlが入ったままになるため、文字入力はままならないし、まともに使える状態ではなくなってしまうのである。

あれこれ試した結果、Change Keyというフリーウェアを使ってキーリマップを行うことで解決することができた。

非常に快適である。CtlキーがAキーの左隣にいてくれることの安心感と言ったら無い。

惜しむらくはツールの更新が終了しているようで、いつこのテクニックが使えなくなるかわからないところであるが、しばらくはこれで使い続けていきたいと思う。

新しいキーボードを買いました

私は分割キーボード派で、普段はMistelのMD770を使っているのですが、かさばるので持ち歩きに不便であることや、フルワイヤレスではないので旅先に持っていくサブPCと組み合わせて使うのには向いておらず、この問題を解決したいと考えていました。

あれこれ検討した結果、折り畳み式のMOBO Keyboard 2を購入しました。

重視したポイントは、日本語配列のキーボードであることと、特殊なキー配列でないこと(サブPCに付属のキーボードを積極的に使う気が起きないのは、このキー配列の特殊性が大きいので)、さらに十分なキーピッチを有していること、また薄くて持ち歩きが苦でないこと、Bluetooth接続できることなどです。

MOBO Keyboard2はこれらの条件をほぼすべて満たしてくれる上、保護カバーをタブレットスタンド的に使うことができるので、私のサブPCであるOneNetbookと相性が良いと判断して購入を決めました。

このブログ記事もさっそくMOBO Keyboard2で書いてみていますが、普段のキーボードとの多少の違いはあるものの、とても快適に使えていると思います。

使い込んでいくとあれこれ不満が出るかもしれませんが、しばらくはこれで生活してみようと思っています。