2021年を振り返る

例年、30日に投稿しているはずなのですが、今年は、どうしても今年中に作りきりたいという思いでConsoleAppFramework v4のリリースをしてしまったので31日で。今年の後半から道具をガラーッと変えて、それがいい感じに作用していったので、なんか満足した気でいます。

Heyをとにかく薦めたい

今年良かったもの第一位はHeyです。Ruby on Rails作者のDHHがやってる会社(Basecamp)のメールサービスなのですが、これが抜群によく出来てる!今までメール一ヶ月放置は当たり前、未読1万件、みたいな状態だったのですが、After Heyでは未読0。すごい。メーラー変えただけでこんな変わるとは。よく出来たツールは人を変えるね。

どうしてもたまりがちな、スパムではないけど自分にとってはスパムに等しいもの(なんだかんだで送られてくる広告メールとかね)を、実質スパム扱いして、一生このメールは見ないという設定をワンポチでできるのが小気味よい。ワンポチどころか、最初のメール受信時に強制的に決めさせることで(決めないとメールが受信ボックスに入らないので、決めるしかない)、最初に使うときの罪悪感というか、とはいえ見るかもしれないしー、役に立つときもあるかもしれないしー、みたいななんとなくある抵抗感みたいなのを、その手で実行させ続けることにより薄れさせていく手腕は見事というほかない。

メールを3分類、読むものとフィード的に見るもの(メルマガとかGitHubのWatchとか)と、領収書系で分けたというのもセンスを感じる。領収書は、例えばKindleで購入するたびに買いましたメールは、捨てるのもアレだけど別に自明すぎて見たくはない、ものが溜まっていくとメールボックスがウザいことになる、を専用の置き場を用意しました、で解決しているのはなるほどなー、と。フィード的なのは全部連結されているので、スクロールさせてバッと流し見で終わらせられるのも良い。

細かいフィルターはできないけど、そもそも細かいフィルターなんて作るの面倒だしメンテ不能になるだけだから作るんじゃねえ、俺達の考えた最高のRailに乗ってりゃあいいんだよ、という押し付けがましさ全開の思想性溢れるのが、いいですね。そういうの、嫌いじゃないです。DHHの語るプログラミング的な思想も好きですしね、私は。Rubyは使いませんが、DHHの思想には納得できるものがめちゃくちゃ多くて割と好きなので。

メールアドレスという、なんだかんだで変えられない、変えにくいものなのに、 hey.com を使え!というのは中々ハードル高いのですが(基本的に汎用メーラーとしては使えず、専用メールアドレスが必要。GMailのクライアントにもPOP3クライアントにもならない)、今回私は10年以上使ってきたプロバイダのメールアドレスが不慮の事故により完全消滅したので、思い切って乗り換えることができました。結果、良かった。怪我の功名ということで。

iPad miniがとにかく良い

タブレットは、というかiPadはなんだかんだで今まで色々なサイズのものを買ってきました。普通のもAirもPro 13インチも。そしてほとんど全く使わなかったのですが……!なんか面倒くさくてねー、重いしー、と。で、iPhoneがPro Maxで大きいから、そこまでサイズ変わらないしねえと思ってminiだけは手を出さなかったのですが、世間でiPad mini 6があまりにも評判がいいので、じゃあまぁ試してみるかと買ったら、なるほど納得!これは超いい!最高……!

やっぱ重さとサイズ感ですかね。これなら手軽に持ち運べる(今の時期コートとかだったらポケットにすら入る)し、片手で持てるというのが読みやすさにもめっちゃ寄与してる。デカいと手も疲れるし、ちゃっと手にとってソファで読もうとかいう気になれなかったわけですが、このサイズ感は絶妙、でした。大きさ的にも全然iPhone Pro Maxよりは明らかに大きくて、雑誌も十分読めるレベルで、漫画は快適。

iPad miniのサイズのままで高級路線(有機EL積んでもらうとか)して欲しいですね。

で、とても気に入ったので、いい感じのスタンドないかなあと思って選んだのがMOFT X。ちょうどiPad mini 6用のサイズのものが出たのでこれがまた快適。どこでもいつでもさっくりスタンドになるのがこんなに良いとは。厚すぎない/重すぎないので、背中に常時貼り付けている状態でも苦にならないし。なんだったらカメラの出っ張り(うざい!)が相殺されて、平らなところにおいてもガタつかなくなったのが最高。

5K2Kは捗る

家の作業環境に割と不満があって、特にモニタ環境が良くなかった(32インチ4K + WQXGAの組合わせ、別に悪くはないんですけどね)ので、5K2K(5120x2160)モニタに変更。

image

現状だとこの解像度はDell U4021QW一択。40インチは大きいかなーと思ったんですが、横に長いから高さ的には今まで使ってた32インチとそう変わらず、ですかね。デカいっちゃあデカですが、こんなもんかな、という感じでもある。この机も横1200の机でそんあ大きい机じゃないんですが、ジャストサイズぐらいで収まりますし。

人によっては100%サイズで使うのは小さい、と思ってしまいそうなところですが、私的には人体改造済み(ICLというレンズを眼球に埋める手術をしたので)なので問題なし、ということで100%サイズで使っています。

このサイズ、いい感じに視野に全部収まるので、デュアルやトリプルよりも快適さあります。今まで、最大で5画面ぐらいまでモニタ増やしてきたのですが、結局メイン以外のものは首をふるのもダルくてそんなに使える感じではないし、音楽プレイヤーのプレイリストを並べるとかだったら、置いてるiPadで聞いて表示しとけばいいじゃん、ということで、全然問題なし。

ツール補助がないとウィンドウがとっちらかってしまうので、Microsoft PowerToysのFancyZonesを使って割り振ってます。これのグリッド吸着がまた使いやすくて(Shift押しながら移動すると、事前定義したグリッドに張り付く)いいですね。

image

中央を広めに取りつつ(主にVisual Studioか、ブラウザがっつし見たりするときはブラウザを置く)、右にブラウザ、左は半分に割って下にGitKraken、上にExplorerみたいなパターンが多めでしょうか。Visual Studio + Unityとか、作業域的に大きく取りたいものを並べる場合は2分割のグリッドパターンも用意して、切り替えるようにしています。

3分割みたいなのは、5K2Kぐらいの解像度がないと出来ないので、この解像度のモニタ増えてくれーって感じですね。選択肢がDELLしかないのは寂しい。液晶自体の画質もそんな良いわけでもないので、もう少し良いのが欲しい。しっかりしたHDR対応のが欲しい。120Hz出るのが欲しい。とか、思うところはそれなりにあります。とはいえ、それでも大満足です。もうこれ以外の解像度のモニタには戻りたくないなあ。

と、いうわけで、モニタ変更のためにゴミ溜めだった机をキレイにした記念で、来年はずっとすっきりした机をキープするぞ、という強い気持ちがあります!単純に机がキレイになってたほうが作業やりやすいですしねー、やっぱゴミ溜めはダメですよ。

キーボードをREALFORCE R3にしたのですが(前はR2でした)、今回からデフォ無線なんですね。キーボードなんて置きっぱなしだから別に有線でいいだろ、と思ってたんですが、これはこれでアリというか、めっちゃいいじゃん?と。サッとキーボードどかしたりがやりやすくなったのがいいですね、デスクで他の作業がやりやすくて。Bluetoothだから、そのままiPadやiPhoneに繋げてもいいし。いやあ、時代は無線。ケーブルがなくなってすっきりするし。

と、いうわけで、無線環境が気に入ったので、デカい有線ヘッドフォン+ヘッドフォンアンプを使っていたのですが(MDR-Z1R + TA-ZH1ES、合計40万もした)、撤去して、写真には写ってませんがAirPods Max買いました。これも満足。いやー、正直あんま有線ヘッドフォン使ってなかったんですよね、面倒くさくて。電源入れるだけ、ではありんですがやっぱ面倒くさくて。ケーブル太くて邪魔くさいし(無駄にケーブル換装して太いケーブルにしてしまったのも良くなかった)。PC専用になっちゃってiPhoneの音も聞けないし。

AirPods MaxだとiPhone/iPad/PCの切り替えが自由なのが想像の100億倍良いなあ、と。音質面でも悪くないし、Apple Musicの空間オーディオとの相性は抜群でこれはめちゃくちゃいいし、さすがのヘッドフォン型なのでAirPods Proよりも音がいい。デジタルクラウンによる音量調整は最高に便利。ゲーム用にDolby Access入れてDolby Atmos for Headphonesを有効化してますが、これもなかなか良い。

スピーカーも置いてたんですが、撤去しました。簡単なものはモニタ内蔵のしょぼいスピーカーで済ませる。ちゃんと聴きたい場合はAirPods Max。それでいいや、と。割り切ったら、全然それでいいじゃん、という気になりました。そもそもあまりデスクトップのスピーカーを稼働させてないしなあ、というのもありますが。

時代は無線

スピーカー撤去の代わりに、じゃないのですが、今年はホームシアターシステムとしてHT-A9を導入したのですが、これも満足度高い。何がいいって、設置が無線で自由度高い。なんか今年は無線化の年ですね、時代は無線。

昔は9.1chにしてたり5.1chだったりでスピーカー並べてたのですが、諸事情あってここ数年はサウンドバーを使ってたんですが、とにかくその音質には不満だったんですね。かといってリアスピーカー並べる気力も起きずというか、そのスペースも確保できない状態なのでどうしたものか、と思っていたところに出たのが、フル無線4.1chシステムのHT-A9。360 Spatial Sound MappingでDolby Atomos時代なサラウンドにするという謳い文句もいいし、設置レイアウトが自由というのがいい。オフィシャルサイトのこの画像を見て購入を決めました。

image

HT-A9の画像、どれもフロントスピーカーの置き方が「わざとらしく」でたらめなんですよね、高さを絶対揃えない。これは、メーカーが別に高さ揃えなくていいんですよ、と推奨してるということなんですが、実際うちでの設置環境もフロントの高さは揃ってないです。揃えられないので。リアも位置も高さもグチャグチャで、適当における棚に置いてるだけって感じです。フロントもリアも無線スピーカーなので、そういう適当配置がめっちゃやりやすい。

それでもちゃんとサウランドするし、360 Spatialな音は心地良い。ただたんに音楽鳴らすだけにも使ってますね、起動も早いのでSpotify Connectでよく流してます。

アンプのサイズが小さい(Apple TVが一回り大きくなった程度)というのも設置が楽になった要因で、いやほんとよく出来てますね。確かに、全部無線なのでスピーカーを駆動するアンプは各スピーカー内蔵状態だから、本体をAVアンプあるあるなクソデカサイズにしなくてもいいんですね。そういうところにも無線の良さが出てますね。

来年に向けてのC#

こうしてダラダラと文章書いたり、そもそもこの12月はやたら記事量産しているなあ、というのは、ブログ書く環境が変わったからです!10年前のWordPressから自家製サイトジェネレーターへの変更によって快適度上がったからですね。いやあ、書きやすいと書く気になります。環境大事。

そんなわけでCysharpはC#はの環境を良くすることに今年一年もちゃんと務められたんじゃないでしょーか。Cysharpからの新規リリース/大型更新は

MessagePipeが大きめかな?

こうした公開していく姿勢、足を少しでも止めてはダメだという思いがあるので、作ろうと思ったらできるだけ勢い持って作りきるようにしています(もちろん途中で止まってしまったものも幾つかありはしますが)。毎年継続的に、Cysharp全体としても既に20個以上公開していて、ヒット作もそれなりの量を出し続けていられる状態は中々のことだと思います。

と、いうわけで、OSSを通じてCysharpをアピールしていくという方向では、良い点をあげてもいいかな、と思うのですが、反面、他の仕事に集中しきれていないのではないかというのが散見していたのは個人的にはマイナスです。今年は「世の中の開発生産性を革命的に改善するプロダクト」の作成に着手し(構想は前からあったのですがようやく始動)、徐々に人も集めだしてCysharpが割とまともな(?)会社っぽく動き出した頃合いでもあるんですが、私がボトルネックになりがち、な状況になりがちなのが、まぁいくないですねえ、と。これはグラニの頃もそうだったので、なんかもうそういうもの感もあるんですが、今回は私が主導してやってるので尻拭いしてくれる人もいないので純粋に良くない!

というわけで、来年の中旬までにその「世の中の開発生産性を革命的に改善するプロダクト」をリリースするために全力でやっていくぞ、というのが目標です。実際出来上がってきて、かなりいいものになりそうな手応えはあるので、早く世の中に出して評価されたいものです。

まぁ、そんなわけで大きな何かがあったわけではないですが、確実に前進した年だと思うので、来年は爆発させる年にしましょう。

ConsoleAppFramework v4 - Minimal API for CommandLine tool

皆さん .NET 6で追加されたMinimal API使ってみました?最初は別にいらんやろ、とか思ってたんですが、いや、これ正直めっちゃ凄い、いい。まぁDelegateベースで書くかどうかは別として(書かないかなー)、謎Startupを葬り去ってBuilder/Runが素直に繋がった形が美しい。Top level statementとの相性も良いので、もうこっちのAPI以外で作る気しないなあ。

さて、ところでConsoleAppFrameworkです。今までクラスが必要だったんですよね、たった一個のメソッドを実装するにも。それがTop level statementとの相性が悪い。Top level statementだけで完結できるとき、クラスって作りたくないんですよね。と、いうわけで、そろそろ大改修が必要かなーと思っていたところにMinimal APIですよ。特にその場でラムダ式でばしばしAPI作っていくスタイルは、むしろコマンドラインツールのほうがマッチするじゃんどう考えても?

と、いうわけで大改修して、Minimal APIベースになったv4、作りました。何が凄いって、一行でコマンドライン引数をパースしてハンドラー定義できちゃうんですね。

ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

これは嘘偽りなくNuGetからダウンロードしたら、そのままでこう書けます。C# 10.0のglobal using(をNuGetのライブラリ側に埋め込むというEvilな手法を使ってます)と、ラムダ式の推論の向上によって実現しました。内側では、Minimal APIの実現のために Microsoft.Extensions.* 側にもかなり改修が入っていたので、それをそっくりそのまま利用できました。そういう意味で、 .NET 6になった今だからようやく作れた形になりますね。もちろんv1~v3までの蓄積のお陰というところもあります。集大成……!

さて、Runはちょっとウケ狙いなところもあるんですが、それ以外のAPIもBuilderベースになったので、だいぶ様変わりしています。ただし特徴としてGeneric Hostの上に乗っているというのは変わらないので、DbContext埋めたりappconfig.jsonから取ったりというのは、変わらずスムーズにできます。

// You can use full feature of Generic Host(same as ASP.NET Core).

var builder = ConsoleApp.CreateBuilder(args);
builder.ConfigureServices((ctx,services) =>
{
    // Register EntityFramework database context
    services.AddDbContext<MyDbContext>();

    // Register appconfig.json to IOption<MyConfig>
    services.Configure<MyConfig>(ctx.Configuration);

    // Using Cysharp/ZLogger for logging to file
    services.AddLogging(logging =>
    {
        logging.AddZLoggerFile("log.txt");
    });
});

var app = builder.Build();

// setup many command, async, short-name/description option, subcommand, DI
app.AddCommand("calc-sum", (int x, int y) => Console.WriteLine(x + y));
app.AddCommand("sleep", async ([Option("t", "seconds of sleep time.")] int time) =>
{
    await Task.Delay(TimeSpan.FromSeconds(time));
});
app.AddSubCommand("verb", "childverb", () => Console.WriteLine("called via 'verb childverb'"));

// You can insert all public methods as sub command => db select / db insert
// or AddCommand<T>() all public methods as command => select / insert
app.AddSubCommands<DatabaseApp>();

app.Run();

単独のコマンドラインツール用に使ってもいいのですが、ASP.NETのウェブアプリが他にあって、それのバッチを作りたいみたいなときに、こうしたコンフィグの共通化はめっちゃ便利に使えるはずです。ConfigureServicesのコードはまんま一緒にできて、そのままDIできますからね。

また、引き続き AddCommands<T>AddAllCommandType によって、メソッド定義するだけで大量のコマンドを一括追加も可能になっています。

v3 -> v4の破壊的変更

破壊的変更、は沢山あるのですが、基本的に今までの使い方をしている場合は互換オプションで動くようにしたので、アップデートしたから壊れるということはない、はずです。v4からはConsoleApp.Create/CreateBuilder 経由で作るのが基本なのですが、v3は Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<T>() 経由なので、ちょうど互換性オプションを突っ込むのに都合が良かったんですね。なお、RunConsoleAppFrameworkAsyncはエディタから見えないようにしてます。今後は非推奨で、本当に互換のためだけに残してます。

まず変わったところは、デフォルトで長いオプション名が--、短いオプション名が-になりました。v3では-が幾つついていてもいいというゆるふわマッチングだったのですが、(dotonet toolsと同じように)厳格化しています。

また、デフォルトのコマンド/オプション名の変換ルールが単純なlower化から、hoge-hugaというlowerなkebab-caseになりました。これもdotnet tools合わせですね。

また、AddCommands<T>した場合の挙動(v3ではRunConsoleAppFrameworkAsync<T>した場合)が、全てのpublicメソッドをコマンドとして追加するようになりました。デフォルト(ルート)コマンドにしたい場合は[RootCommand]属性を付与してくださいということで。これはAddSubCommand<T>した時と挙動を合わせたかったからです、違うと一貫性がなくて戸惑うので。

と、いうわけで、互換性モードで動かした場合はConsoleAppOptionsは以下のような変更で動くようになっています。よきかなよきかな。(それとargsのコマンド名でHoge.Hugaが来てたらHoge Hugaに分解するのも、この互換性モードだけの挙動です)

options.StrictOption = false;
options.NoAttributeCommandAsImplicitlyDefault = true;
options.NameConverter = x => x.ToLower();
options.ReplaceToUseSimpleConsoleLogger = false;

そうだ、それとCtrl+Cした場合に、正しくCancellationTokenをハンドリングしていない場合でも、タイムアウトをハンドリングしてabortするようになりました。これは、なんか強制終了できなくてウゼーってなりがちというか、私自身よく引っかかってヤバかったので。むしろこれは今までがバグに近くて、正しくHostOptions.ShutdownTimeoutを処理していないせいでした。

ちなみにこのタイムアウト時間はデフォルトは5秒で、ConfigureHostOptions(地味にこれは.NET 6(というかMicrosoft.Extensionsのv6)からの新API)で変更できます。

var app = ConsoleApp.CreateBuilder(args)
    .ConfigureHostOptions(options =>
    {
        // change timeout.
        options.ShutdownTimeout = TimeSpan.FromMinutes(30);
    })
    .Build();

まとめ

無計画にアドホックに作っていったせいで、どうにもクソコードすぎて、改修にめっちゃ手間取ったというか内部的にはほぼ作り直した……。弄るのだるくて嫌だなあと内心実際今まで思ってたんですが、やはりとても嫌なコードであった。v1の時の最初の発想が Class.Method にパラメータ分解してバッチを大量に作りたい(そもそもライブラリ名もMicroBatchFrameworkだったし)というものだけだったのが、徐々に汎用コマンドラインツールに進化していって、都度、適当に追加していった結果ではある。

今回がっつし仕切り直したので、しばらくはメンテが楽になれるかなあ、という感じで、よきかなよきかな。

まぁしかしC# 10.0は地味にヤバいですよ!使えば使うほど味が出てくるというか、最近ようやく手に馴染んで、よくわかってきた感じです。なんというか、とにかく、めっちゃいい。それとC# 10.0 + ConsoleAppFrameworkは全言語見渡しても最強のコマンドラインツール作成ライブラリじゃないです?いや、API自体のできの良さはほとんど ASP .NET CoreのMinimal APIのコピーにすぎないんですが、まぁしかしそれでもやっぱ、これはかなり良い感じじゃないかという手応えがあります。

NativeMemoryArray - .NET 6 APIをフル活用した2GB超えの巨大データを扱うライブラリ

.NET 6 Advent Calendar 2021の12日の代理投稿となります。プレゼント付きですと!?BALMUDA The Brew STARBUCKS RESERVE LIMITED EDITIONが欲しいです!

さて、先程NativeMemoryArrayという新しいライブラリを作成し、公開しました。.NET Standard 2.0でも動作しますが、全体的に .NET 6 の新API群(NativeMemory, Scatter/Gather I/O)を活かすための作りになっていますので、今回のAdvent Calendarにもピッタリ。実用性も、ある……!あります……!もちろんUnity版も用意してあります(NativeArrayと何が違うって?まぁ違うと言えば違います)。

C#には配列、特にbyte[]を扱う上で大きな制約が一つあります。それは、一次元配列の上限値が0x7FFFFFC7(2,147,483,591)ということ。int.MaxValueよりちょっと小さめに設定されていて、ようするにざっくり2GBちょいが限界値になっています。

この限界値は、正確には .NET 6 でひっそり破壊的変更が行われましたので、.NET 6とそれ以外で少し異なります。詳しくは後で述べます。

この2GBという値は、int Lengthの都合上しょうがない(intの限界値に引っ張られている)のですが、昨今は4K/8Kビデオや、ディープラーニングの大容量データセットや、3Dスキャンの巨大点群データなどで、大きな値を扱うことも決して少ないわけではないため、2GB制約は正直厳しいです。そして、この制約はSpan<T>Memory<T>であっても変わりません(Lengthがintのため)。

ちなみにLongLengthは多次元配列における全次元の総数を返すためのAPIのため、一次元配列においては特に意味をなしません。.NET Frameworkの設定であるgcAllowVeryLargeObjectsも、構造体などを入れた場合の大きなサイズを許容するものであり(例えば4バイト構造体の配列ならば、2GB*4のサイズになる)、要素数の限界は超えられないため、byte[]としては2GBが限界であることに変わりはありません。

こうした限界に突き当たった場合は、ストリーミング処理に切り替えるか、またはポインタを使って扱うかになりますが、どちらもあまり処理しやすいとは言えませんし、必ずしもインメモリで行っていた操作が代替できるわけではありません(ポインタなら頑張れば最終的にはなんとでもなりますが)。

そこで、2GB制約を超えつつも、新しいAPI群(Span<T>, IBufferWriter<T>, ReadOnlySequence<T>, RandomAccess.Write/Read, System.IO.Pipelinesなど)と親和性の高いネイティブメモリを裏側に持つ配列(みたいな何か)を作りました。

これによって、例えば巨大データの読み込み/書き込みも、 .NET 6の新Scatter/Gather APIのRandomAccessを用いると、簡単に処理できます。

// for example, load large file.
using var handle = File.OpenHandle("4GBfile.bin", FileMode.Open, FileAccess.Read, options: FileOptions.Asynchronous);
var size = RandomAccess.GetLength(handle);

// via .NET 6 Scatter/Gather API
using var array = new NativeMemoryArray<byte>(size);
await RandomAccess.ReadAsync(handle, array.AsMemoryList(), 0);

// iterate Span<byte> as chunk
foreach (var chunk in array)
{
    Console.WriteLine(chunk.Length);
}

Scatter/Gather APIに馴染みがなくても、IBufferWriter<T>IEnumerable<Memory<T>> を経由してStreamで処理する手法も選べます。

public static async Task ReadFromAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    var writer = buffer.CreateBufferWriter();

    int read;
    while ((read = await stream.ReadAsync(writer.GetMemory(), cancellationToken).ConfigureAwait(false)) != 0)
    {
        writer.Advance(read);
    }
}

public static async Task WriteToAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    foreach (var item in buffer.AsMemorySequence())
    {
        await stream.WriteAsync(item, cancellationToken);
    }
}

あるいはSpan<T>のSliceを取り出して処理してもいいし、ref T this[long index]によるインデクサアクセスやポインタの取り出しもできます。 .NET 6時代に完全にマッチしたAPIを揃えることで、標準の配列と同等、もしくはそれ以上の使い心地に仕上げることによって、C#の限界をまた一つ超える提供できたと思っています。

とはいえもちろん、 .NET Standard 2.0/2.1 にも対応しているので、非 .NET 6なAPIでも大丈夫です、というかScatter/Gather API以外は別に今までもありますし普通に使えますので。

普通の配列的にも使えます。GC避けには、こうした普通のAPIを使っていくのでも便利でしょう、

// call ctor with length, when Dispose free memory.
using var buffer = new NativeMemoryArray<byte>(10);

buffer[0] = 100;
buffer[1] = 100;

// T allows all unmanaged(struct that not includes reference type) type.
using var mesh = new NativeMemoryArray<Vector3>(100);

// AsSpan() can create Span view so you can use all Span APIs(CopyTo/From, Write/Read etc.).
var otherMeshArray = new Vector3[100];
otherMeshArray.CopyTo(mesh.AsSpan());

NativeMemoryArray<T>

NativeMemoryArray<T>はwhere T : unmanagedです。つまり、参照型を含まない構造体にしか使えません。まぁ巨大配列なんて使う場合には参照型含めたものなんて含めてんじゃねーよなので、いいでしょうきっと。巨大配列で使えることを念頭においてはいますが、別に普通のサイズの配列として使っても構いません。ネイティブメモリに確保するので、ヒープを汚さないため、適切な管理が行える箇所では便利に使えるはずです。

Span<T>との違いですが、NativeMemoryArray<T>そのものはクラスなので、フィールドに置けます。Span<T>と違って、ある程度の長寿命の確保が可能ということです。Memory<T>のSliceが作れるため、Async系のメソッドに投げ込むこともできます。また、もちろん、Span<T>の長さの限界はint.MaxValueまで(ざっくり2GB)なので、それ以上の大きさも確保できます。

UnityにおけるNativeArray<T>との違いですが、NativeArray<T>はUnity Engine側との効率的なやりとりのための入れ物なので、あくまでC#側で使うためのNativeMemoryArray<T>とは全然役割が異なります。まぁ、必要に思えない状況ならば、おそらく必要ではありません。

主な長所は、以下になります。

  • ネイティブメモリから確保するためヒープを汚さない
  • 2GBの制限がなく、メモリの許す限り無限大の長さを確保できる
  • IBufferWriter<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonWriter, System.IO.Pipelinesなどから直接読み込み可能
  • ReadOnlySequence<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonReaderなどへ直接データを渡すことが可能
  • IReadOnlyList<Memory<T>>, IReadOnlyList<ReadOnlyMemory<T>> 経由で RandomAccess(Scatter/Gather API)に巨大データを直接渡すことが可能

あまりピンと来ない、かもしれませんが、使ってみてもらえれば分かる、かも。

NativeMemoryArray<T>の全APIは以下のようになっています。

  • NativeMemoryArray(long length, bool skipZeroClear = false, bool addMemoryPressure = false)
  • long Length
  • ref T this[long index]
  • ref T GetPinnableReference()
  • Span<T> AsSpan()
  • Span<T> AsSpan(long start)
  • Span<T> AsSpan(long start, int length)
  • Memory<T> AsMemory()
  • Memory<T> AsMemory(long start)
  • Memory<T> AsMemory(long start, int length)
  • bool TryGetFullSpan(out Span<T> span)
  • IBufferWriter<T> CreateBufferWriter()
  • SpanSequence AsSpanSequence(int chunkSize = int.MaxValue)
  • MemorySequence AsMemorySequence(int chunkSize = int.MaxValue)
  • IReadOnlyList<Memory<T>> AsMemoryList(int chunkSize = int.MaxValue)
  • IReadOnlyList<ReadOnlyMemory<T>> AsReadOnlyMemoryList(int chunkSize = int.MaxValue)
  • ReadOnlySequence<T> AsReadOnlySequence(int chunkSize = int.MaxValue)
  • SpanSequence GetEnumerator()
  • void Dispose()

AsSpan(), AsMemory()はスライスのためのAPIです。取得したSpanやMemoryは書き込みも可能なため、 .NET 5以降に急増したSpan系のAPIに渡せます。SpanやMemoryには最大値(int.MaxValue)の限界があるため、lengthの指定がない場合は、例外が発生する可能性もあります。そこでTryGetFullSpan()を使うと、単一Spanでフル取得が可能かどうか判定できます。また、AsSpanSequence(), AsMemorySequence()でチャンク毎のforeachで全要素を列挙することが可能です。直接foreachした場合は、AsSpanSequence()と同様の結果となります。

long written = 0;
foreach (var chunk in array)
{
    // do anything
    written += chunk.Length;
}

ポインタの取得は、配列とほぼ同様に、そのまま渡せば0から(これはGetPinnableReference()の実装によって実現できます)、インデクサ付きで渡せばそこから取れます。

fixed (byte* p = buffer)
{
}

fixed (byte* p = &buffer[42])
{
}

CreateBufferWriter() によって IBufferWriter<T>を取得できます。これはMessagePackSerializer.Serializeなどに直接渡すこともできるほかに、先の例でも出しましたがStreamからの読み込みのように、先頭からチャンク毎に取得して書き込んでいくようなケースで便利に使えるAPIとなっています。

AsReadOnlySequence() で取得できるReadOnlySequence<T>は、MessagePackSerializer.Deserializeなどに直接渡すこともできるほかに .NET 5から登場した SequenceReaderに通すことで、長大なデータのストリーミング処理をいい具合に行える余地があります。

AsMemoryList(), AsReadOnlySequence()は .NET 6から登場したRandomAccessRead/Writeに渡すのに都合の良いデータ構造です。プリミティブな処理なので使いにくいと思いきや、意外とすっきりと処理できるので、File経由の処理だったらStreamよりもいっそもうこちらのほうがいいかもしれません。

NativeMemory

.NET 6からNativeMemoryというクラスが新たに追加されました。その名の通り、ネイティブメモリを扱いやすくするものです。今までもMarshal.AllocHGlobalといったメソッド経由でネイティブメモリを確保することは可能であったので、何が違うのか、というと、何も違いません。実際NativeMemoryArrayの .NET 6以前版はMarshalを使ってますし。そして .NET 6 では Marshal.AllocHGlobal は NativeMemory.Alloc を呼ぶので、完全に同一です。

ただしもちろん .NET 6 実装時にいい感じに整理された、ということではあるので、NativeMemory、いいですよ。NativeMemory.Allocがmalloc、NativeMemory.AllocZeroedがcalloc、NativeMemory.Freeがfreeと対応。わかりやすいですし。

ちなみにゼロ初期化する NativeMemory.AllocZeroed に相当するものはMarshalにはないので、その点でも良くなったところです。NativeMemoryArray<T>では、コンストラクタのskipZeroClear(public NativeMemoryArray(long length, bool skipZeroClear = false))によってゼロ初期化する/しないを選べます。デフォルトは(危ないので)初期化しています。非.NET 6版では、メモリ確保後にSpan<T>.Clear()経由で初期化処理を入れています。

真のArray.MaxValue

.NET 6以前では、配列の要素数はバイト配列(1バイト構造体の配列)と、それ以外の配列で異なる値がリミットに設定されていました。例えばSystem.Arrayのドキュメントを引いてくると

配列のサイズは、合計で40億の要素に制限され、任意の次元の0X7FEFFFFF の最大インデックス (バイト配列の場合は0X7FFFFFC7、1バイト構造体の配列の場合) に制限されます。

つまり、0X7FFFFFC7の場合と、0X7FEFFFFFの場合がある、と。

と、いうはずだったのですが、.NET 6からArray.MaxLengthというプロパティが新規に追加されて、これは単一の定数を返します。その値は、0X7FFFFFC7です。よって、いつのまにかひっそりと配列の限界値は(ちょびっと大きい方に)大統一されました。

この変更は意外とカジュアルに行われ、まず最大値を取得する、ただし単一じゃないため型によって結果の変わる Array.GetMaxLength<T>() を入れよう、という実装があがってきました。そうしたら、そのPR上での議論で、そもそも当初は最適化を期待したけど別にそんなことなかったし、統一しちゃってよくね?という話になり、そのまま限界値は統一されました。そして新規APIも無事、Array.MaxLengthという定数返しプロパティになりました。

まぁ、シンプルになって良いですけどね。大きい方で統一されたので実害も特にないでしょうし。前述のSystem.Arrayのドキュメントは更新されてないということで、正しくは、.NET 6からは0x7FFFFFC7が限界で、その値はArray.MaxLengthで取れる。ということになります。

Span<T>の限界値はint.MaxValueなので、限界に詰め込んだSpan<T>をそのままToArray()すると死ぬ、という微妙な問題が発生することがあるんですが、まぁそこはしょうがないね。

まとめ

NativeArrayという名前にしたかったのですがUnityと被ってしまうので避けました。しょーがない。

着手当時はマネージド配列のチャンクベースで作っていたのですが(LargeArray.cs)、Sliceが作りづらいし、ネイティブメモリでやったほうが出来ること多くて何もかもが圧倒的にいいじゃん、ということに作業進めている最中に気づいて、破棄しました。参照型の配列が作れるという点で利点はありますが、まぁ参照型で巨大配列なんて作らねーだろ、思うと、わざわざ実装増やして提供するメリットもないかな、とは。

配列はもう昔からあるのでint Lengthなのはしょうがないのですが、Span<T>, Memory<T>のLengthはlongであって欲しかったかなー、とは少し思っています。2016年の段階でのSpanのAPIどうするかドキュメントによると、候補は幾つかあったけど、結果的に配列踏襲のint Lengthになったそうで。2GBでも別に十分だろ、みたいなことも書いてありますが、いや、そうかなー?年にそこそこの回数でたまによく引っかかるんだけどねー?

そして2016年の議論時点ではなかった、C# 9.0でnuint, nuintが追加されたので、nuint Span<T>/Memory<T>.Lengthはありなんじゃないかな、と。

ただNativeMemoryArrayの開発当初はnuint Lengthで作っていたのですが、AsSpan(nuint start, nuint length)みたいなAPIは、カジュアルにintやlongを突っ込めなくて死ぬほど使いづらかったので、最終的にlongで統一することにしました。ので、nuint Lengthは、なしかな。つまり一周回って現状維持。そんなものかー、そんなもんですねー。

.NET 6とAngleSharpによるC#でのスクレイピング技法

C# Advent Calendar 2021の参加記事となっています。去年は2個エントリーしたあげく、1個すっぽかした(!)という有様だったので、今年は反省してちゃんと書きます。

スクレイピングに関しては10年前にC#でスクレイピング:HTMLパース(Linq to Html)のためのSGMLReader利用法という記事でSGMLReaderを使ったやり方を紹介していたのですが、10年前ですよ、10年前!さすがにもう古臭くて、現在ではもっとずっと効率的に簡単にできるようになってます。

今回メインで使うのはAngleSharpというライブラリです。AngleSharp自体は2015年ぐらいからもう既に定番ライブラリとして、日本でも紹介記事が幾つかあります。が、いまいち踏み込んで書かれているものがない気がするので、今回はもう少しがっつりと紹介していきたいと思っています。それと直近Visual StudioのWatchウィンドウの使い方を知らん、みたいな話を聞いたりしたので、デバッグ方法の手順みたいなものを厚めに紹介したいなあ、という気持ちがあります!

AngleSharpの良いところは、まずはHTMLをパースしてCSSセレクターで抽出できるところです。以前はLINQ(to DOM)があればCSSセレクターじゃなくてもいいっす、WhereとSelectManyとDescendantsでやってきますよ、とか言ってましたが、そんなにきちんと構造化されてるわけじゃないHTMLを相手にするのにあたっては、CSSセレクターのほうが100億倍楽!CSSセレクターの文法なんて大したことないので、普通に覚えて使えってやつですね。SQLと正規表現とCSSセレクターは三大言語関係なく覚えておく教養、と。

もう一つは、それ自体でネットワークリクエストが可能なこと。FormへのSubmitなどもサポートして、Cookieも保持し続けるとかが出来るので、ログインして会員ページを弄る、といったようなクローラーが簡単に書けるんですね。この辺非常に良く出来ていて、もう自前クローラーなんて投げ捨てるしかないです。また、JintというPure C#なJavaScriptインタプリタと統合したプラグインも用意されているので、JavaScriptがDOMをガリガリっと弄ってくる今風のサイトにも、すんなり対応できます。

AngleSharpの紹介記事では、よくHttpClientなどで別途HTMLを取ってきたから、それをAngleSharpのHtmlParserに読み込ませる、というやり方が書かれていることが多いのですが、取得も含めて全てAngleSharp上で行ったほうが基本的には良いでしょう。

ここまで来るとPure C#の軽量なヘッドレスブラウザとしても動作する、ということになるので、カジュアルなE2Eテストの実装基盤にもなり得ます。普通のユニットテストと並べて dotnet test だけでその辺もある程度まかなえたら、とても素敵なことですよね?がっつりとしたE2Eテストを書きたい場合はPlaywrightなどを使わなければ、ということになってしまいますが、まずは軽い感じから始めたい、という時にうってつけです。C#で書けるし。いいことです。

BrowingContextとQuerySelectorの基本

まずはシンプルなHTMLのダウンロードと解析を。基本は BrowsingContext を作って、それをひたすら操作していくことになります。

// この辺で色々設定する
var config = Configuration.Default
    .WithDefaultLoader(); // LoaderはデフォではいないのでOpenAsyncする場合につける

// Headless Browser的なものを作る
using var context = BrowsingContext.New(config);

// とりあえずこのサイトの、右のArchivesのリンクを全部取ってみる
var doc = await context.OpenAsync("https://neue.cc");

OpenAsyncで取得できた IDocument をよしなにCSSセレクターで解析していくわけですが、ここで絞り込みクエリー作成に使うのがVisual StudioのWatchウィンドウ。(Chromeのデベロッパーツールなどで機械的に取得したい要素のCSSセレクターを取得できたりしますが、手セレクターのほうがブレなくルールは作りやすいかな、と)。

デバッガーを起動して、とりあえずウォッチウィンドウを開いておもむろに、Nameのところでコードを書きます。

image

ウォッチウィンドウは見たい変数を並べておく、お気に入り的な機能、と思いきや本質的にはそうじゃなくて、式を自由に書いて、結果を保持する、ついでに式自体も保持できるという、実質REPLなのです。代入もラムダ式もLINQも自由に書けるし、入力補完も普通に出てくる。Immediate Windowよりも結果が遥かに見やすいので、Immediate Windowは正直不要です。

デバッガー上で動いているので実データを自由に扱えるというところがいいですね。というわけで、ToHtml()でHTMLを見て、QuerySelectorAllをゆっくり評価しながら書いていきましょう。まずはサイドバーにあるので .side_body を出してみると、あれ、二個あるの?と。

image

中開けてInnerHtml見ると、なるほどProfile部分とArchive部分、と。とりあえず後ろのほうで固定のはずなのでlast-childね、というところで一旦評価して大丈夫なのを確認した後に、あとはa、と。でここまでで期待通りの結果が取れていれば、コピペる。よし。

// 基本、QuerySelectorかQuerySelectorAllでDOMを絞り込む
var anchors = doc.QuerySelectorAll(".side_body:last-child a")
    .Cast<IHtmlAnchorElement>() // AngleSharp.Html.Dom
    .Select(x => x.Href)
    .ToArray();

単一の要素に絞り込んだ場合は、 IHtml*** にキャストしてあげると扱いやすくなります(attributeのhrefのtextを取得、みたいにしなくていい)。頻出パターンなので、QuerySelectorAll<T>でCastもセットになってすっきり。

doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a")

せっかくなので、年に何本記事を書いていたかの集計を出してみたいと思います!URLから正規表現で年と月を取り出すので、とりあえずここでもウォッチウィンドウです。

image

anchrosの[0]を確認して、これをデータソースとしてRegex.Matchを書いて、どのGroupに収まったのかを見ます。この程度だったら特にミスらないでしょー、と思いきや普通に割とミスったりするのが正規表現なので、こういうので確認しながらやっていけるのはいいですね。

後は普通の(?)LINQコード。グルーピングした後に、ひたすら全ページをOpenAsyncしていきます。記事の本数を数えるのはh1の数をチェックするだけなので、特に複雑なCSSセレクターは必要なし。本来はページングの考慮は必要ですが、一月単位だとページングが出てくるほどの記事量がないので、そこも考慮なしで。

var yearGrouped = anchors
    .Select(x =>
    {
        var match = Regex.Match(x, @"(\d+)/(\d+)");
        return new
        {
            Url = x,
            Year = int.Parse(match.Groups[1].Value),
            Month = int.Parse(match.Groups[2].Value)
        };
    })
    .GroupBy(x => x.Year);

foreach (var year in yearGrouped.OrderBy(x => x.Key))
{
    var postCount = 0;
    foreach (var month in year)
    {
        var html = await context.OpenAsync(month.Url);
        postCount += html.QuerySelectorAll("h1").Count(); // h1 == 記事ヘッダー
    }
    Console.WriteLine($"{year.Key}年記事更新数: {postCount}");
}

結果は

2009年記事更新数: 92
2010年記事更新数: 61
2011年記事更新数: 66
2012年記事更新数: 30
2013年記事更新数: 33
2014年記事更新数: 22
2015年記事更新数: 19
2016年記事更新数: 24
2017年記事更新数: 13
2018年記事更新数: 11
2019年記事更新数: 14
2020年記事更新数: 11
2021年記事更新数: 5

ということで右肩下がりでした、メデタシメデタシ。今年は特に書いてないなあ、せめて2ヶ月に1本は書きたいところ……。

なお、C#による自家製静的サイトジェネレーターに移行した話 で紹介しているのですが、このサイトは完全にGitHub上に.mdがフラットに並んで.mdが管理されているので、こういうの出すなら別にスクレイピングは不要です。

UserAgentを変更する

スクレイピングといったらログインしてごにょごにょする。というわけで、そうしたログイン処理をさくっとやってくれるのがAngleSharpの良いところです。ので紹介していきたいのですが、まずはやましいことをするので(?)、UserAgentを偽装しましょう。

AngleSharpが現在何を送っているのかを確認するために、とりあえずダミーのサーバーを立てます。その際には .NET 6 のASP .NET から搭載されたMinimal APIが非常に便利です!そしてそれをLINQPadで動かすと、テスト用サーバーを立てるのにめっちゃ便利です!やってみましょう。

image

たった三行でサーバーが立ちます。便利。

await context.OpenAsync("http://localhost:5000/headers");

でアクセスして、 AngleSharp/1.0.0.0 で送られていることが確認できました。

なお、LINQPadでASP.NETのライブラリを使うには、Referene ASP.NET Core assembliesのチェックを入れておく必要があります。

image

他、よく使うNuGetライブラリや名前空間なども設定したうえで、Set as default for new queriesしておくと非常に捗ります。

さて、で、このUser-Agentのカスタマイズの方法ですが、AngleSharpはServicesに機能が詰まっているようなDI、というかService Locatorパターンの設計になっているので、ロードされてるServicesを(Watch Windowで)一通り見ます。

image

型に限らず全Serviceを取得するメソッドが用意されていない場合でも、<object>で取ってやると全部出てくるような実装は割と多い(ほんと)ので、とりあえずやってみるのはオススメです。今回も無事それで取れました。

で、型名を眺めてそれっぽそうなのを見ると DefaultHttpRequester というのがかなりそれっぽく、その中身を見るとHeadersという輩がいるので、これを書き換えればいいんじゃないだろうかと当たりがつきます。

ここはやましい気持ちがあるので(?)Chromeに偽装しておきましょう。

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36";

再びOpenAsyncしてLINQPadの表示を見て、変更されてること確認できました。

image

ちなみに、DefaultじゃないHttpRequesterをConfigurationに登録しておく、ということも出来ますが、よほどカスタムでやりたいことがなければ、デフォルトのものをちょっと弄るぐらいの方向性でやっていったほうが楽です。

FormにSubmitする

クローラーと言ったらFormにSubmit、つまりログイン!そしてクッキーをいただく!認証!

さて、が、まぁ認証付きの何かを例にするのはアレなので、googleの検索フォームを例にさせていただきたいと思います。先にまずはコード全体像と結果を。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom; // 拡張メソッドとかで有効化されたりするのでusing大事
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies(); // login form的なものの場合これでクッキーを持ち歩く

using var context = BrowsingContext.New(config);

// お行儀悪いので(?)前述のこれやっておく
var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://google.com/");
var form = doc.Forms[0];
var result = await form.SubmitAsync(new { q = "AngleSharp" }); // name = valueは匿名型が使える

// とりあえず結果を表示しておく
var titles = result.QuerySelectorAll<IHtmlHeadingElement>("h3").Select(x => x.TextContent);
var i = 1;
foreach (var item in titles)
{
    Console.WriteLine($"{i++:00}: {item}");
}

image

WithDefaultLoader と、そして認証クッキー持ち歩きのために WithDefaultCookies をコンフィギュレーションに足しておくことが事前準備として必須です。User-Agentの書き換えはご自由に、ただやましいこと、ではなくてUA判定をもとにして処理する、みたいなサイトも少なからずあるので、余計ないこと考えなくて済む対策としてはUAをChromeに偽装しておくのはアリです。

FormへのSubmit自体は3行というか2行です。ページをOpenしてFormに対してSubmitするだけ。超簡単。 .FormsIHtmlElementFormsがすっと取れるので、あとは単純にSubmitするだけです。渡す値は { name = value }の匿名型で投げ込めばOK。

度々出てくるウォッチウィンドウの宣伝ですが、この何の値を投げればいいのか、を調べるのにHTMLとニラメッコではなく、ウォッチウィンドウで調査していきます。

image

まず("input")を拾うのですが、9個ある。多いね、で、まぁこれはほとんどtype = "hidden"なので無視して良い(AngleSharpがSubmitAsync時にちゃんと自動でつけて送信してくれる)。値を入れる必要があるのはhiddden以外のものなので、それをウォッチで普通にLINQで書けば、3件に絞れました。で、中身見ると必要っぽいのはqだけなので、 new { q = "hogemoge" } を投下、と。

認証が必要なサイトでは、これでBrowingContextに認証クッキーがセットされた状態になるので、以降のこのContextでのOpenや画像、動画リクエストは認証付きになります。

画像や動画を拾う

スクレイピングといったら画像集めマンです(?)。AngleSharpでのそうしたリソース取得のやり方には幾つかあるのですが、私が最も良いかな、と思っているのはIDocumentLoader経由でのフェッチです。

// BrowsingContextから引っ張る。Contextが認証クッキー取得済みなら認証が必要なものもダウンロードできる。
var loader = context.GetService<IDocumentLoader>();

// とりあえず適当にこのブログの画像を引っ張る
var response = await loader.FetchAsync(new DocumentRequest(new Url("https://user-images.githubusercontent.com/46207/142736833-55f36246-cb7f-4b62-addf-0e18b3fa6d07.png"))).Task;

using var ms = new MemoryStream();
await response.Content.CopyToAsync(ms);

var bytes = ms.ToArray(); // あとは適当にFile.WriteAllBytesでもなんでもどうぞ

内部用なので少し引数やAPIが冗長なところもありますが、それは後述しますが別になんとでもなるところなので、どちらかというと生のStreamが取れたりといった柔軟性のところがプラスだと思っています。普通にHttpClientで自前で取るのと比べると、認証周りやってくれた状態で始められるのが楽ですね。

並列ダウンロードもいけます、例えば、このブログの全画像を引っ張るコードを、↑に書いた全ページ取得コードを発展させてやってみましょう。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://neue.cc/");
var loader = context.GetService<IDocumentLoader>();

foreach (var arvhives in doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a"))
{
    var page = await context.OpenAsync(arvhives.Href);

    // content(ページ本体)下のimgを全部。
    // 今回はページ単位で5並列ダウンロードすることにする(粒度の考え方は色々ある)
    var imgs = page.QuerySelectorAll<IHtmlImageElement>("#content img");
    await Parallel.ForEachAsync(imgs, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (img, ct) =>
     {
         var url = new Url(img.Source);
         var response = await loader.FetchAsync(new DocumentRequest(url)).Task;

         // とりあえず雑にFile書き出し。
         Console.WriteLine($"Downloading {url.Path}");
         using (var fs = new FileStream(@$"C:\temp\neuecc\{url.Path.Replace('/', '_')}", FileMode.Create))
         {
             await response.Content.CopyToAsync(fs, ct);
         }
     });
}

.NET 6から Parallel.ForEachAsync が追加されたので、asyncコードを並列数(MaxDegreeOfParallelism)で制御した並列実行が容易に書けるようになりました。async/await以降、Parallel系の出番は圧倒的に減ったのは確かなのですが、Task.WhenAllだけだと並列に走りすぎてしまって逆に非効率となってしまって、そこを制御するコードを自前で用意する必要が出てきていたりと面倒なものも残っていました。それが、このParallel.ForEachAsyncで解消されたと思います。

Kurukuru Progress

数GBの動画をダウンロードする時などは、プログレスがないとちゃんと動いているのか確認できなくて不便です。しかし、ただ単にConsole.WriteLineするだけだとログが凄い勢いで流れていってしまって見辛くて困りものです。そこを解決するC#ライブラリがKurukuruで、見ればどんなものかすぐわかるので、まずは実行結果を見てもらいましょう(素の回線だと一瞬でダウンロード終わってしまったので回線の低速シミュレーションしてます)

guruguru

一行だけを随時書き換えていってくれるので、見た目も非常に分かりやすくて良い感じです。これはとても良い。Kurukuru、今すぐ使いましょう。ちなみに今回の記事で一番時間がかかったのは、Kurukuruの並列リクエスト対応だったりして(対応していなかったのでコード書いてPR上げて、今日リリースしてもらいましたできたてほやほやコード)。

AngleSharp側のコードですが、この例はFile Examples のMP4を並列で全部取るというものです。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;
using Kurukuru;
using System.Text;

// Kurukuruを使う上で大事なおまじない
// え、デフォルトのEncodingがUTF8じゃないシェルがあるんです!?←Windows
Console.OutputEncoding = Encoding.UTF8;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://file-examples.com/index.php/sample-video-files/sample-mp4-files/");
var loader = context.GetService<IDocumentLoader>();

// ここから本体
var mp4s = doc.QuerySelectorAll<IHtmlAnchorElement>("a").Where(x => x.Href.EndsWith(".mp4"));
Console.WriteLine("Download sample-mp4-files");
await Parallel.ForEachAsync(mp4s, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (mp4, ct) =>
{
    var bin = await loader.FetchBytesAsync(mp4.Href);
    // あとはFile.WriteAllBytesするとか好きにして
});

ポイントは var bin = await loader.FetchBytesAsync(mp4.Href); で、これは拡張メソッドです。loaderにProgress付きでbyte[]返すメソッドを生やしたことで、随分シンプルに書けるようになりました。StreamのままFileStreamに書いたほうがメモリ節約的にはいいんですが、中途半端なところでコケたりした場合のケアが面倒くさいので、ガチガチなパフォーマンスが重視される場合ではないならbyte[]のまま受けちゃってもいいでしょう。1つ4GBの動画を5並列なんですが?という場合でも、たかがメモリ20GB程度なので普通にメモリ積んで処理すればいいっしょ。

FetchBytesAsyncの中身は以下のようなコードになります。

public static class DocumentLoaderExtensions
{
    public static async Task<byte[]> FetchBytesAsync(this IDocumentLoader loader, string address, CancellationToken cancellationToken = default)
    {
        var url = new AngleSharp.Url(address);
        var response = await loader.FetchAsync(new DocumentRequest(url)).Task;
        if (response.StatusCode != System.Net.HttpStatusCode.OK)
        {
            return Array.Empty<byte>(); // return empty instead of throws error(ここをどういう挙動させるかは好みで……。)
        }

        // Content-Lengthが取れない場合は死でいいということにする
        var contentLength = int.Parse(response.Headers["Content-Length"]);

        using var progress = new ProgressSpinner(url.Path.Split('/').Last(), contentLength);
        try
        {
            return await ReadAllDataAsync(response.Content, contentLength, progress, cancellationToken);
        }
        catch
        {
            progress.Cancel();
            throw;
        }
    }

    static async Task<byte[]> ReadAllDataAsync(Stream stream, int contentLength, IProgress<int> progress, CancellationToken cancellationToken)
    {
        var buffer = new byte[contentLength];
        var readBuffer = buffer.AsMemory();
        var len = 0;
        while ((len = await stream.ReadAsync(readBuffer, cancellationToken)) > 0)
        {
            progress.Report(len);
            readBuffer = readBuffer.Slice(len);
        }
        return buffer;
    }
}

public class ProgressSpinner : IProgress<int>, IDisposable
{
    readonly Spinner spinner;
    readonly string fileName;
    readonly int? totalBytes;
    int received = 0;

    public ProgressSpinner(string fileName, int? totalBytes)
    {
        this.totalBytes = totalBytes;
        this.fileName = fileName;
        this.spinner = new Spinner($"Downloading {fileName}");
        this.spinner.Start();
    }

    public void Report(int value)
    {
        received += value;
        if (totalBytes != null)
        {
            var percent = (received / (double)totalBytes) * 100;
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)} / {ToHumanReadableBytes(totalBytes.Value)} ( {Math.Floor(percent)}% )";
        }
        else
        {
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)}";
        }
    }

    public void Cancel()
    {
        spinner.Fail($"Canceled {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    public void Dispose()
    {
        spinner.Succeed($"Downloaded {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    static string ToHumanReadableBytes(int bytes)
    {
        var b = (double)bytes;
        if (b < 1024) return $"{b:0.00} B";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} KB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} MB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} GB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} TB";
        b /= 1024;

        return $"{0:0.00} PB";
    }
}

KurukuruのSpinnerを内包した IProgress<T> を作ってあげて、その中でよしなにやってあげるということにしました。まぁちょっと長いですが、一回用意すれば後はコピペするだけなので全然いいでしょう。みなさんもこのProgressSpinner、使ってやってください。

コマンド引数やロギング処理やオプション取得

クローラーとしてガッツシやりたいなら、モードの切り替えとかロギングとか入れたいです、というか入れます。そこで私が定形として使っているのはConsoleAppFrameworkZLogger。Cysharpの提供です。ワシが作った。それと今回のようなケースだとKokubanも便利なので入れます。やはりCysharpの提供です。

<ItemGroup>
    <PackageReference Include="AngleSharp" Version="1.0.0-alpha-844" />
    <PackageReference Include="Kurukuru" Version="1.4.0" />
    <PackageReference Include="ConsoleAppFramework" Version="3.3.2" />
    <PackageReference Include="ZLogger" Version="1.6.1" />
    <PackageReference Include="Kokuban" Version="0.2.0" />
</ItemGroup>

この場合Program.csは以下のような感じになります。割と短いですよ!

using ConsoleAppFramework;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Text;
using ZLogger;

Console.OutputEncoding = Encoding.UTF8;

await Host.CreateDefaultBuilder()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.AddZLoggerConsole();
        x.AddZLoggerFile($"logs/{args[0]}-{DateTime.Now.ToString("yyyMMddHHmmss")}.log");
    })
    .ConfigureServices((hostContext, services) =>
    {
        services.Configure<NanikaOptions>(hostContext.Configuration.GetSection("Nanika"));
    })
    .RunConsoleAppFrameworkAsync(args);

public class NanikaOptions
{
    public string UserId { get; set; } = default!;
    public string Password { get; set; } = default!;
    public string SaveDirectory { get; set; } = default!;
}

コンソールログだけだとウィンドウ閉じちゃったときにチッとかなったりするので(?)、ファイルログあると安心します。ZLoggerは秘伝のxmlコンフィグなどを用意する必要なく、これだけで有効化されるのが楽でいいところです。それでいてパフォーマンスも抜群に良いので。

ConsoleAppFrameworkはGenericHostと統合されているので、コンフィグの読み込みもOptionsで行います。appsettings.jsonを用意して

{
  "Nanika": {
    "UserId": "hugahuga",
    "Password": "takotako",
    "SaveDirectory": "C:\\temp\\dir",
  }
}

.csprojのほうに

<ItemGroup>
    <None Update="appsettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
</ItemGroup>

と書いてあげれば、自動で読み込まれるようになるという仕様です。そして本体のコードは

public class NanikaDownloader : ConsoleAppBase
{
    readonly ILogger<NanikaDownloader> logger;
    readonly NanikaOptions options;

    // コンストラクタインジェクションでOptionsを受け取る
    public NanikaDownloader(ILogger<NanikaDownloader> logger, IOptions<NanikaOptions> options)
    {
        this.logger = logger;
        this.options = options.Value;
    }

    public async Task DownloadAre()
    {
        // Context.CancellationTokenを渡すのを忘れないように!(Ctrl+Cのキャンセル対応に必須)
        await loader.FecthAsyncBytes("...", Context.CancellationToken)
    }

    public async Task DownloadSore(int initialPage)
    {
        // Kokubanを使うとConsoleに出す文字列の色分けが簡単にできる!( `Chalk.Color +` だけで色が付く)
        logger.LogInformation(Chalk.Green + $"Download sore {initialPage} start");
    }
}

のように書きます。これの場合は、引数で NanikaDownloader.DownloadAre, NanikaDownloader.DownloadSore -initialPage * の実行切り替えができるようになるわけですね……!

また、文字色が一色だけだとコンソール上のログはかなり見づらいわけですが、Kokubanを使うことで色の出し分けが可能になります。これは、地味にめちゃくちゃ便利なのでおすすめ。別にバッチ系に限らず、コンソールログの色を調整するのってめっちゃ大事だと、最近実感しているところです。

ASP .NET Core(とかMagicOnionとか)で、ZLoggerでエラーを赤くしたい!とか、フレームワークが吐いてくる重要でない情報はグレーにして目立たなくしたい!とかの場合は、ZLoggerのPrefix/SuffixFormatterを使うのをオススメしてます(Kokubanのようにさっくり書けはしないのですが、まぁConfigurationのところで一回やるだけなので)

logging.AddZLoggerConsole(options =>
{
#if DEBUG
    // \u001b[31m => Red(ANSI Escape Code)
    // \u001b[0m => Reset
    // \u001b[38;5;***m => 256 Colors(08 is Gray)
    options.PrefixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error)
        {
            ZString.Utf8Format(writer, "\u001b[31m[{0}]", info.LogLevel);
        }
        else
        {
            if (!info.CategoryName.StartsWith("MyApp")) // your application namespace.
            {
                ZString.Utf8Format(writer, "\u001b[38;5;08m[{0}]", info.LogLevel);
            }
            else
            {
                ZString.Utf8Format(writer, "[{0}]", info.LogLevel);
            }
        }
    };
    options.SuffixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error || !info.CategoryName.StartsWith("MyApp"))
        {
            ZString.Utf8Format(writer, "\u001b[0m", "");
        }
    };
#endif

}, configureEnableAnsiEscapeCode: true); // configureEnableAnsiEscapeCode

こういうの、地味に開発効率に響くので超大事です。やっていきましょう。

まとめ

AngleSharpにかこつけてウォッチウィンドウをとにかく紹介したかったのです!ウォッチウィンドウ最強!値の変化があると赤くなってくれたりするのも便利ですね、使いこなしていきましょう。別にUnityとかでもクソ便利ですからね?

あ、で、AngleSharpはめっちゃいいと思います。他の言語のスクレピングライブラリ(Beautiful Soupとか)と比べても、全然張り合えるんじゃないかな。冒頭に書きましたがE2Eテストへの応用なども考えられるので、使いこなし覚えるのとてもいいんじゃないかと思います。ドキュメントが色々書いてあるようで実は別にほとんど大したこと書いてなくて役に立たないというのは若干問題アリなんですが、まぁ触って覚えるでもなんとかなるので、大丈夫大丈夫。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive