xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

C# 12 の新機能「Primary Constructors」に対する IntelliSense の神対応

2023 年 11 月に C# 12 が正式リリースされました!大変おめでたいし、Microsoft 本社の C# 開発チームの方々には (毎年のことながら) 感謝申し上げます...!

で、C# 12 で導入された目玉機能のひとつに Primary Constructors というものがあります。必須コンストラクタを record 型っぽい記述で書けるようにする機能です。

class A(int x)
{
    public int X { get; } = x;
}

これは C# 6 の頃から導入が検討されていた機能で、約 10 年弱の検討と改良を経て正式に採用されました。以下の記事は 2014/6/16 に書いたものですが、リリース直前に搭載が見送られたことまで残っていました。過去の自分、エラいね...w

正式リリースされたけど使うことに抵抗があった

正直なところ、Primary Constructors を自分が書くコードに採用するのには前向きではありませんでした。特に以下のふたつが理由です。

  1. コンストラクタ引数をキャプチャして利用できてしまう
  2. コンストラクタ内で何か処理を書きたくなったら通常のコンストラクタに変換しないといけない

「1」は Primary Constructors の挙動をちゃんと理解しないとダメで、自分がよくてもチーム全体で統制できるかが不安でした。「2」は単純に面倒だからです。通常のコンストラクタから Primary Constructors への変換機能は Visual Studio に搭載されているので簡単ですが、逆がないのが懸念でした。

けど「使っていこう」と決心した

しばらく抵抗感が強かったにも拘らず心変わりした理由は次の通りです。

  • .editorconfig を使って CS9124 をエラーにすることで多重 Backing Field 問題を防げる
    • C# の挙動を詳細まで把握していないチームメイトに対してもある程度の強制力を持って対処できる
  • DI 部分に限ればコンストラクタ内に処理を書くことがまずない
    • ので、そこに限れば「十分飲み込める」と思えるようになってきた
  • Visual Studio の IntelliSense が多重 Backing Field 問題を緩和していることに気付いた

特に最後の IntelliSense の挙動を知ったことが大きいです。「百聞は一見に如かず」ということで以下のキャプチャをご覧ください。

プロパティに引数を代入しさえすれば IntelliSense にすら表示されない という神対応!これによって「コードを書いてから CS9124 で間違いに気付くのではなく、コードを書く瞬間から間違いを起こさせない」ことになるので抵抗感がグッと下がりました。

まとめ

今後は Primary Constructors にも書き慣れていきたい所存。しかし、この IntelliSense はどうやって対応しているんでしょうね。決め打ちの特別対応なのかな。

Path.GetDirectoryName() は overload によって挙動が異なる

久々に Path.GetDirectoryName() を使ったら、件名の通りの挙動に引っ掛かりました。文章で説明するよりもサンプルコードを見た方が早いので、以下をご覧ください。

const string fileName = "A/B/C/D.cs";

var dir1 = Path.GetDirectoryName(fileName);
dir1.Dump("string");  // A\B\C

var dir2 = Path.GetDirectoryName(fileName.AsSpan());
dir2.Dump("ReadOnlySpan<char>");  // A/B/C

ご覧の通り string の overload だと /\ に化けますが、ReadOnlySpan<char> の方はそうなりません。だいぶ罠ですね。

string の overload は .SubString() をして返すだろうことは容易に想像が付きますが、\ への変換までオマケで付いてきます。PathInternal.NormalizeDirectorySeparators() なんて余計なことを...。 ReadOnlySpan<char> の overload は .Slice() をするだけでメモリアロケーションもなく、加えて変なオマケも付いてこないので安心です。

string の overload は過去の遺産ということで見なかったことにして、ReadOnlySpan<char> の overload を使っていきましょう。

Swashbuckle.AspNetCore.Cli で FileLoadException が出る場合の対処

最近社内で「CI で swagger.json を生成したいから対応してほしい」と言われてやってみました。そして思いっきりハマりました。ということで忘れないようにメモ。

ちなみに本内容は執筆時点での最新版である「.NET 7 + Swashbuckle.AspNetCore (v6.5.0)」で再現しているもので、バージョンが異なると挙動が変わる可能性があります。その点はご了承ください。

TL;DR

  1. SwaggerHostFactory.CreateHost を利用する
  2. 古き良き Startup クラスを使って IHost を生成する

発生した問題を再現してみる

最小プロジェクトを準備

細かいことは省略しつつ、大まかに以下のようなミニマムな ASP.NET Core MVC プロジェクトを生成します。

// Program.cs
var builder = WebApplication.CreateBuilder(args);

var services = builder.Services;
services.AddControllers();
services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapControllers();
app.Run();
// SampleApiController.cs
[ApiController]
[SkipStatusCodePages]
[Route("api/sample")]
public class SampleApiController : ControllerBase
{
    [AllowAnonymous]
    [HttpGet("foo")]
    public IActionResult Foo()
        => this.Ok();
}

この状態で /swagger を実行すると以下のように正常に Swagger UI が表示されますし、/swagger/v1/swagger.json も開くことができます。非常に順調です。あとはこの swagger.json を CLI で出力できるようにするだけです。

{
    "openapi": "3.0.1",
    "info": {
        "title": "WebApplication2",
        "version": "1.0"
    },
    "paths": {
        "/api/sample/foo": {
            "get": {
                "tags": [
                    "SampleApi"
                ],
                "responses": {
                    "200": {
                        "description": "Success"
                    }
                }
            }
        }
    },
    "components": {}
}

CLI 出力を試みる

では Swashbuckle.AspNetCore.Cli をローカルツールとして導入して swagger.json を出力してみましょう。コマンドは以下のような感じです。

// インストール
dotnet new tool-manifest
dotnet tool install --local Swashbuckle.AspNetCore.Cli
// 出力
dotnet swagger tofile --output swagger.json WebApplication2/bin/Debug/net7.0/WebApplication2.dll v1

すると以下のように FileLoadException が出力されます。...なんでやねん!

Unhandled exception. System.IO.FileLoadException: The given assembly name was invalid.
File name: 'WebApplication2/bin/Debug/net7.0/WebApplication2.dll'
   at System.Reflection.AssemblyNameParser.ThrowInvalidAssemblyName()
   at System.Reflection.AssemblyNameParser.Parse()
   at System.Reflection.AssemblyNameParser.Parse(String name)
   at System.Reflection.AssemblyName..ctor(String assemblyName)
   at Microsoft.AspNetCore.Hosting.StartupLoader.FindStartupType(String startupAssemblyName, String environmentName)
   at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.ScanAssemblyAndRegisterStartup(HostBuilderContext context, IServiceCollection services, WebHostBuilderContext webhostContext, WebHostOptions webHostOptions)
   at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.<.ctor>b__6_2(HostBuilderContext context, IServiceCollection services)
   at Microsoft.AspNetCore.Hosting.BootstrapHostBuilder.RunDefaultCallbacks()
   at Microsoft.AspNetCore.Builder.WebApplicationBuilder..ctor(WebApplicationOptions options, Action`1 configureDefaults)
   at Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(String[] args)
   at Program.<Main>$(String[] args) in C:\Users\xin9le\Documents\Visual Studio 2022\Projects\WebApplication1\WebApplication2\Program.cs:line 1
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
   at System.Reflection.MethodInvoker.Invoke(Object obj, IntPtr* args, BindingFlags invokeAttr)
--- End of stack trace from previous location ---
   at Microsoft.Extensions.Hosting.HostFactoryResolver.HostingListener.CreateHost() in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\HostFactoryResolver.cs:line 271
   at Microsoft.Extensions.Hosting.HostFactoryResolver.<>c__DisplayClass8_0.<ResolveHostFactory>b__0(String[] args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\HostFactoryResolver.cs:line 75
   at Swashbuckle.AspNetCore.Cli.HostingApplication.GetServiceProvider(Assembly assembly) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\HostingApplication.cs:line 72
   at Swashbuckle.AspNetCore.Cli.Program.GetServiceProvider(Assembly startupAssembly) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\Program.cs:line 152
   at Swashbuckle.AspNetCore.Cli.Program.<>c.<Main>b__0_4(IDictionary`2 namedArgs) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\Program.cs:line 82
   at Swashbuckle.AspNetCore.Cli.CommandRunner.Run(IEnumerable`1 args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\CommandRunner.cs:line 68
   at Swashbuckle.AspNetCore.Cli.CommandRunner.Run(IEnumerable`1 args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\CommandRunner.cs:line 59
   at Swashbuckle.AspNetCore.Cli.Program.Main(String[] args) in C:\projects\ahoy\src\Swashbuckle.AspNetCore.Cli\Program.cs:line 121

対処方法

StackTrace を見るに、どうも Swashbuckle.AspNetCore.Cli は指定したアセンブリの Main メソッドをダイレクトに実行するようです。その過程で Startup クラスを探そうとして失敗している、と。しかし .NET 6 以降で標準テンプレートとなった Top Level Statement で初期化 / 実装をしているので Startup クラスはもうありません。どうも Top Level Statement に対応していない感じですね。

ではどう対処するのかというと、Swashbuckle.AspNetCore.Cli の実行時にのみ特別に解釈される IHost の生成処理を利用します。アセンブリ内にある特定の型名とメソッド名から IHost を返してあげればそれを利用するようになっています。特別扱いするのは以下のどちらかの書き方です。所謂ダックタイピング的アプローチですね。

public class SwaggerHostFactory
{
    public static IHost CreateHost()
    { }  // 省略
}
public class SwaggerWebHostFactory
{
    public static IWebHost CreateWebHost()
    { }  // 省略
}

実際にやってみる

では先の書き方に従って実装していきましょう。僕は次のように実装してみました。ポイントとして SwaggerHostFactoryinternal でも問題ありません。Startup クラスは同一ファイル内でしか利用しないので file スコープで大丈夫です。また、ここでも UseStartup をしない書き方で IHost を生成することは認められていません。同様に実行時エラーになります。

internal sealed class SwaggerHostFactory
{
    public static IHost CreateHost()
        => Host
        .CreateDefaultBuilder()
        .ConfigureWebHostDefaults(static x => x.UseStartup<Startup>())
        .Build();
}

file sealed class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
        services.AddSwaggerGen();
    }

    public void Configure(IApplicationBuilder _)
    { }
}

ここまで実装したら改めて dotnet swagger tofile コマンドを実行してみましょう。無事に出力されるはずです。めでたし ×2。

公式 GitHub にもこのような対処方法については書かれていないですし、実行時例外の StackTrace しか情報がなく解決に大変苦労しました。同じような問題にぶち当たっている方の一助になれば幸いです。

Azure Functions (Isolated) における ITelemetryInitializer / ITelemetryProcessor の注意点

最近 Azure Functions (Isolated) から出力される Application Insights のテレメトリと格闘していました。何を今更そんなに格闘することがあるのかというと、テレメトリの一種である RequestTelemetry に対するアクセスが全くできないという問題に遭遇したからです。今回はそんな挙動について調べたことを残していきます。

tl;dr;

  • Azure Functions (Isolated) において、一部のテレメトリ (RequestTelemetry など) にアクセスできない
  • Azure Functions (Isolated) が Host と Worker に分離したことに起因
  • Worker 側で ITelemetryInitializer 等を利用してテレメトリを操作しようにも、Host 側が出力したテレメトリには触れられない

同様の問題に対する Issue は既に挙がっています。

前提知識

まず Azure Functions の前提として、InProc Model と Isolated Model の違いを押さえておく必要があります。詳細は公式のドキュメントに譲りますが、Azure Functions を利用しているとこの違いがあれこれと面倒を起こします。

InProc Model

  • C#/.NET で動作させる場合の従来モデル
    • .NET 6 まではこちら
  • Function Host にアプリケーション .dll を読み込ませ、Host と一体となってコードを実行させる
  • .NET の最新バージョンを使いたい場合、Function Host が対応してくれない限りアプリ側が利用できない問題があった
    • それを解消するのが Isolated Model
    • Node.js など C#/.NET 以外で作る場合は最初から Isolated Model だったが、C#/.NET は特別に一体型だった

Isolated Model

  • Functions Host とアプリケーション側 (Worker) を分離させ、各々好き勝手できるようにしたモデル
    • .NET 6 以降で利用可能で、.NET 7 以降では完全にこちらのみ
  • Host / Worker の間は gRPC で繋がれている
    • これが色々と厄介な問題や制限を抱えるのだけど、一旦それはさておき...

検証

ということで、Isolated Model における Application Insights へのテレメトリ送信の実験をしてみます。Application Insights SDK はテレメトリ送信前をフックして内容を編集するための拡張ポイントである ITelemetryInitializerITelemetryProcessor を提供しています。今回は動作検証ということで ITelemetryInitializer のみで試してみましょう。以下のようなコードを書きます。

// テレメトリに投げる前に編集しちゃう君
internal class MyTelemetryInitializer : ITelemetryInitializer
{
    public void Initialize(ITelemetry telemetry)
    {
        var props = (ConcurrentDictionary<string, string>)telemetry.Context.Properties;
        props.TryAdd("SayHello", "Hello, World!!");  // カスタムプロパティを追加
        telemetry.Context.Component.Version = "1.2.3.4";  // バージョンを設定
    }
}
// Isolated Host を利用した Azure Functions の初期化処理
new HostBuilder()
    .ConfigureFunctionsWorkerDefaults(static (context, builder) =>
    {
        // DI に登録
        builder.Services.AddSingleton<ITelemetryInitializer>(new MyTelemetryInitializer());
    })
    .Build()
    .Run();
// いわゆる HttpTrigger
internal class Http_Isolated_DebugStart
{
    [Function(nameof(Http_Isolated_DebugStart))]
    public async Task<HttpResponseData> EntryPoint(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "debug/start")] HttpRequestData request,
        FunctionContext context)
    {
        var logger = context.GetLogger<Http_Isolated_DebugStart>();
        logger.LogInformation($"Hello, Isolated!!");  // ログを吐いておく
        return request.CreateResponse(HttpStatusCode.OK);
    }
}

この状態で Azure にデプロイして Application Insights を確認してみます。すると次のような感じのテレメトリが出力されます。全プロパティを表示した画像のため拡大しないと見られないかもですが、その点はご了承ください。

RequestTelemetry (※A) TraceTelemetry (※A) TraceTelemetry (※B)
  • ※A : Host 側が出力
  • ※B : Worker 側が出力

※A で示した方には ITelemetryInitializer によるプロパティやバージョン値が入っていないことが分かります。一方で ※B で示した Worker 側で明示的に出力したログには値が入っていますね。Host と Worker が分離したことで、一部のテレメトリにはアクセスできていないことが確認できます。

まとめ

とういうことで Azure Functions (Isolated) におけるテレメトリ操作の注意点を簡単に見てきました。今回はサボッて載せませんが、InProc Model では (当然) すべてのテレメトリをフックして値を操作することができます。こういった部分の差分を認識して、運用時に困らないようにしていきたいですね。

Azure Functions (Isolated) は、まだまだ InProc と動作が異なる部分や InProc との機能差 (= Isolated の方が機能が足りてない) が残っています。Azure Functions チームはこれらを埋める作業を毎年地道に頑張っていて、最近では Durable Functions に対応したりもしています。応援!

2022 年の振り返りと 2023 年の抱負

あけましておめでとうございます!

ということで 2023 年が始まりました。昨年に引き続き、今年も忘れないように振り返りと抱負を書き残しておこうと思います。

健康

まず、昨年は 2022 年の抱負としてこんなことを書きました。

健康には真剣に目を向けたい。最近腰痛がかなりひどく、湿布と飲み薬に頼り切りというアカンコレ状態...。健康が理由でプログラマ人生が短くならないようにしていかなければ。

結果としては完全にダメでした。過去一番の体調不良で反省してもしきれないくらい反省しています。おかげで医療費がとんでもない額になりました。

  • 4 月と 6 月に 2 度ピポった (= 救急車で搬送された)
  • 右半身の痺れの検査で「腰椎穿刺」を受け、その副作用で「低髄液圧症候群」を発症し床に伏せる
  • 「食道裂孔ヘルニア 滑脱型」になり「逆流性食道炎」で 3 週間ほど腹痛と吐き気に悩む

今まで健康を害すると幸福度が爆下がりするということは聞いてはいたものの「ふーん」くらいだったんですが、心底痛感しました。 健康の維持も仕事

お仕事

本業

いろいろと日々の運用だとか、大手企業様の社内営業システムの開発支援だとか、新規 EC の開発のお仕事などを粛々とやっていました。正直いろいろあり過ぎてすでに記憶喪失気味です。

新規開発は過去の反省を生かしてあれこれとブラッシュアップされたコードを書くことができたなぁと思っています。とは言え正直全然満足できていなくて、役割分担の関係で主導できなかった部分で「これは赤点の設計ですね」みたいな部分を見ると「力及ばず」というか「どうにでもなーれ」というか。最初からゴミを錬成しているような部分はもっと減らしていきたい反面、自分の担当範囲を広げ過ぎると過負荷になってまたピポることになりそうで怖い...

福井の先輩エンジニアである中西さん (@hIDDEN_xv) がお仕事の一部をサポートしてくれて、それで何とかなった部分はあって本当にメチャクチャ助かりました。

副業

引き続き fingger さんの技術顧問のお仕事を継続していました。特に Azure 移行は Microsoft さん的には「ええやん」と言いそうな成果ですが、これは決して AWS がダメということではなくチーム全体のスキルセット観点での決断でした。そんなチームメイトのみんなは仲良くしてくれて嬉しい限りで、まだもうちょっとお仕事は続きそうです。

  • AWS にホストされていたシステムを Azure に移行する作業を達成
  • 管理画面を Azure AD 認証 + Blazor Server で実現
    • 下地部分だけ用意して機能の充実はチームメイトにお任せしました
  • 初期コードを全面的に書き換え
    • Join したときには Startup らしい 0 -> 1 なコードがそこにありました
    • チームメイトに書き換えの方針を提示して、地道に毎晩コードレビューを繰り返し
  • 新機能の設計/実装の方針決め

あと、はじめての確定申告というものを経験しました。税理士さんに頼むようなこともせず全部自力でオンライン申請をしたけれど、「本当にこれでいいのか?」がいつまでも消えずに怖かったのを覚えています。2 回目はきっと大丈夫でしょう。

コミュニティ活動

YouTube 配信

この 1 年も岩永さん (@ufcpp) とかずき先生 (@okazuki) と一緒に C# YouTube 配信 を継続しました。最新 / 最先端の C# を酒の肴に隔週くらいで雑談ができる機会があることは本当に助かっています。感謝!

.NET Conf 2022 Recap Event 東京

そして、YouTube 配信の延長 (?) で東京版 .NET Conf で C# 11 の話をしてきました。3 人揃うと雑談が止まらなくなってしまう (!) のでタイムコントロールできるかちょっと不安でしたが、うまく終えることができました。久々のオフラインイベントは本当に楽しくって、日常が戻ってきた感じがあっていいなぁって思うなどしました。

TechFeed Conference 2022

TechFeed CEO の白石さんからの依頼を受け、オンライン登壇をしました。「5 分で学ぶ Interpolated String Handler」というタイトルで、完全にヲタ向けな C# コンパイラの拡張ポイントの解説ですね。

そのときの内容を記事にしていただいたり、YouTube 動画でアーカイブを残していただいたりしました。TechFeed のみなさま、ありがとうございました!

プライベート

YouTube

とにかくひたすらに観る将棋にハマりました。毎日欠かさず藤森五段の YouTube チャンネル「将棋放浪記」を見てたし、ABEMA の将棋中継のせいで (?) 仕事が進まないって嘆いてました。他にも本の要約動画とか学習系のコンテンツが好きで相当な時間を吸われた感じがします。

買い物

基本的に妻も僕も物欲が全然なく無駄遣いするところがないんですが、妻と道の駅なんかに行って普段は全然食べない食材を見つけては試し食いするっていう遊びが流行しました。食事は「体験を買う」のひとつだと知れたことが大きかったです。

あとは楽天市場でふるさと納税をすることがトンデモチートであることを改めて学びました。税金払ってるだけなのに日常生活の支出まで減っていったので相当に気持ちのゆとりが出ました。

  1. 楽天スーパーセールなどを狙ってふるさと納税する
  2. 特に食品ばかり選ぶ
  3. かなり食費が浮く
  4. 万単位で楽天ポイントがつく
  5. 近くのドラッグストアで楽天ポイントを使って日用品を買う

子育て

ひとり娘を溺愛し過ぎて中学生になっても子離れできず甘やかしが捗りまくってます。反抗期もない優しい子に育っていて毎日 1 時間くらいは会話してる気がします。会話の内容はいろいろですが、学校で習ったことや時事問題のような話が多いです。歴史的円安になったときにちゃんと円安の話をするようなタイムリーさが大事ということで。知らんけど。

日頃は「宿題だけは忘れずに」とは言うものの「勉強しろ」とは全く言わないし、塾にも行かせてないし全く必要ないと思っています。YouTube を見るのも漫画を読むのも教養ということで、そのあたりは引き続き放置プレイを継続。それでコンスタントに 470 点前後だから僕とはどうも脳の作りが違うらしい。

2023 年の抱負

とにかく健康でいたい。不健康で辛い思いをして医療費も上がるくらいなら健康に時間とお金を使いたいと強く思っています。2022 年 6 月に「海外でワーケーションしてやるぞ!」と思っていたのに直前でピポって航空機のチケット 30 万円ちかくをドブに捨てた苦い想い出再来!なんてわけにはいかない。今年こそは海外でワーケーションするぞ!と、基本的にはお金は健康と経験のために。

プログラミングの面ではもっとクラウドサービスを使い倒した設計 / 実装をしたいと思っています。ちょっとした Web サービスだと決まりきった形になっていて面白みが薄れてきているので、そのあたりの俺的 Next Step を模索してみたいところ。C# / .NET の進化も変わらず注視して、最新のものを使う努力を怠らないようにしていきたいです。