⚠️

ビルドは通るが動かない!実際に引っ掛かった「.NET 6 -> .NET 8」移行時の破壊的変更 2 選

2024/06/22に公開

ここ数日、.NET 6 でできたアプリを .NET 8 に更新する作業をしています。.NET 8 のリリースから半年以上が経った今になって遅ればせながらやっているのは、Azure Functions (In-Proc) がようやく .NET 8 に対応したからです。それに引っ張られてずっと .NET 6 のままの運用を強いられていました。

それはそれとして、近年の .NET は互換性を高く保ちつつもちょこちょこと破壊的変更を入れて「よりあるべき姿」になろうと奮闘しています。その点については大変好感を持っていますし、実際これまでに幾度となく .NET のバージョンを上げてきたときも全くと言っていいほど破壊的変更を踏むことがなかったので若干過信していたところはあります。

https://learn.microsoft.com/ja-jp/dotnet/core/compatibility/breaking-changes

が、今回検証過程で実際に遭遇して「うわ、危なッ」となる部分があったので紹介していきます。

実際にハマッた破壊的変更 2 選

いずれも「ビルドは難なく通るが実行時に期待した挙動にならない」というものです。ビルド時に検知できれば優しかったんですけどねー。そうは問屋が卸さない。

1. [FromBody] 属性がないと BadRequest になる

以下のコードは .NET 6 で動きますが .NET 8 だと BadRequest が返ります。なんでやねーん。

[ApiController]
[Route("api/sample")]
public class SampleController : ControllerBase
{
    [HttpPost("echo")]
    public IActionResult Echo(EchoRequest request)
        => this.Ok(request);
}

public sealed class EchoRequest
{
    [BindRequired]
    [Required]
    [FromBody]  // 実際には無くても良い。Body から解決していることを明示したくて書いただけ。
    [JsonInclude]
    [JsonPropertyName("name")]
    public string Name { get; init; } = default!;
}
POST https://localhost:5001/api/sample/echo HTTP/2.0
Content-Type: application/json

{"name":"xin9le"}

これを .NET 8 で動かすようにするためには、以下のように Action 引数に [FromBody] 属性を足すか、もしくはプロパティから [FromBody] 属性を消します。

Action 引数に [FromBody] 属性を足す
[ApiController]
[Route("api/sample")]
public class SampleController : ControllerBase
{
    [HttpPost("echo")]
    public IActionResult Echo([FromBody] EchoRequest request)  // 属性を足した
        => this.Ok(request);
}
プロパティから [FromBody] 属性を消す
public sealed class EchoRequest
{
    [BindRequired]
    [Required]
    [JsonInclude]
    [JsonPropertyName("name")]
    public string Name { get; init; } = default!;
}

以上の解決策のどちらか、もしくはどちらもで OK ですが、プロパティにだけ [FromBody] 属性を付与している場合は危険です。Body から解決していることをメモするつもりで「善かれ」と思ってやっていたら仇になりました。

2. required プロパティへの JSON 逆直列化

高速化目的で Redis に JSON 形式でデータをキャッシュしていました。その際値が null なプロパティを直列化しないようにしてペイロードの軽量化も図っていました。こんな感じです。

いろいろ省略して大事なところだけ
// required が付いたプロパティがある
public sealed class Product
{
    // インスタンス生成時にプロパティの設定を必須にするために required を付けている
    public required string? ImagePath { get; init; }
}

// JSON 化する
var product = new Product() { ImagePath = null };  // 値が null だったとする
var options = new JsonSerializerOptions
{
    // ペイロードの軽量化目的で null を書き込まないように指定
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
var json = JsonSerializer.Serializer(product, options);  // 空の JSON が吐き出される

// JSON から戻す
var decoded = JsonSerializer.Deserialize<Product>(json);  // ここでエラー

このコードも .NET 6 では動きますが .NET 8 では実行時エラーになります。実際には .NET 7 でも実行時エラーになります。なかなか困りましたね。どういうことか簡単に解説していきます。

まず .NET 7 以降で [JsonRequired] 属性が導入されました。これは「JSON を逆直列化する際にプロパティへのマッピングを必須要件とする」ことを明示するためのものです。少し言い換えると「JSON の中にマッピング元になるプロパティが存在する」ことを明示しているとも言えます。意図しない形の JSON を弾けるので便利ですね。

System.Text.Json の JsonSerializer はこの [JsonRequired] 属性を見て解釈するのが基本的な挙動なのですが、required キーワードの付いたプロパティも同様に扱うという振る舞いをするようになっています。これが今回引っ掛かった部分です。

https://learn.microsoft.com/ja-jp/dotnet/standard/serialization/system-text-json/required-properties

required キーワードは C# 11 (.NET 7 世代) 以降で導入されたものです。が、冒頭で書いたように諸事情で数年 .NET 6 で運用していました。このとき C# だけは最新バージョン (C# 12) を利用していたんですね。.NET が古かろうが C# の最新言語機能は使いたいですからね。

通常 .NET 6 は C# 10 とペアだけど C# 12 を使う
<PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <LangVersion>12.0</LangVersion>
    <Nullable>enable</Nullable>
</PropertyGroup>

という流れで .NET 6 で運用しつつも required キーワードを多用していたところ、.NET 8 に更新したら JsonSerializer の (機能追加に伴う) 挙動の変更を踏んだわけです。これを「破壊的変更」とは言わないのかもしれないけれど、C# と .NET のバージョンは正しい組み合わせで使わないといけない義務はないので大変辛いところです。実際壊れてますし。

まとめ

Discussion