.NET 9 の JSON Schema 生成機能の挙動確認
.NET 9 で JSON Schema 生成機能が追加されました。この機能を使って、C# クラスから JSON Schema を生成する方法を確認します。
record
型での required
の挙動
以前 Azure OpenAIでStructured Outputsを使う! C# 版 という記事で JSON Schema を使って Azure OpenAI の Structured Outputs を使う方法を紹介しました。この時は class
を使って JSON Schema を生成しましたが、今回は record
を使って JSON Schema を生成すると required
がどうなるか確認します。
まずは、何も考えずに record
を使って JSON Schema を生成してみます。
今回は Person
という record
を使って JSON Schema を生成します。Name
と Age
というプロパティを持つ Person
です。
using System.Text.Json;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
// Person record から JSON Schema を生成する
var jsonSchema = JsonSchemaExporter.GetJsonSchemaAsNode(
options: new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
},
typeof(Person),
exporterOptions: new()
{
TreatNullObliviousAsNonNullable = true,
});
// JSON Schema を JSON 文字列に変換して表示する
Console.WriteLine(jsonSchema.ToJsonString(options: new() { WriteIndented = true }));
// Person record
record Person(string Name, int Age);
実行すると以下の JSON Schema が生成されます。何も考えずにプライマリーコンストラクターの引数に指定してあるプロパティが required
になっていることがわかります。
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer"
}
},
"required": [
"name",
"age"
]
}
required
を外したい場合は通常のプロパティとして定義する必要があります。例えば Age
プロパティを required
から外す場合は以下のようにします。
// Person record
record Person(string Name)
{
public int Age { get; set; }
}
このようにすると Age
プロパティが required
から外れます。実行結果を以下に示します。
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer"
}
},
"required": [
"name"
]
}
Nullable の挙動
プロパティの型を int?
のような null
許容値型や string?
のような null
許容参照型にすると、JSON Schema に null
許容値型として出力されます。以下の例では Name
と Age
プロパティの型に ?
を追加しています。
record Person(
string? Name)
{
public int? Age { get; set; }
}
実行結果を以下に示します。
{
"type": "object",
"properties": {
"name": {
"type": [
"string",
"null"
]
},
"age": {
"type": [
"integer",
"null"
]
}
},
"required": [
"name"
]
}
Description 対応
デフォルトでは、以下のように Description
属性を追加しても何も起きません。
record Person(
[property: Description("氏名")]
string Name)
{
[Description("年齢")]
public int? Age { get; set; }
}
これに対応するためには exporterOptions
で設定している JsonSchemaExporterOptions
の TransformSchemaNode
プロパティを使って動作をカスタマイズする必要があります。よくあるパターンだと思うので公式ドキュメントにもサンプルがあります。
では、コードを書き換えて TransformSchemaNode
を使って Description
属性を使うようにしてみます。
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
using System.Text.Unicode;
// Person record から JSON Schema を生成する
var jsonSchema = JsonSchemaExporter.GetJsonSchemaAsNode(
options: new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
},
typeof(Person),
exporterOptions: new()
{
TreatNullObliviousAsNonNullable = true,
TransformSchemaNode = (context, schema) =>
{
// Determine if a type or property and extract the relevant attribute provider.
ICustomAttributeProvider? attributeProvider = context.PropertyInfo is not null
? context.PropertyInfo.AttributeProvider
: context.TypeInfo.Type;
// Look up any description attributes.
DescriptionAttribute? descriptionAttr = attributeProvider?
.GetCustomAttributes(inherit: true)
.Select(attr => attr as DescriptionAttribute)
.FirstOrDefault(attr => attr is not null);
// Apply description attribute to the generated schema.
if (descriptionAttr != null)
{
if (schema is not JsonObject jObj)
{
// Handle the case where the schema is a Boolean.
JsonValueKind valueKind = schema.GetValueKind();
Debug.Assert(valueKind is JsonValueKind.True or JsonValueKind.False);
schema = jObj = new JsonObject();
if (valueKind is JsonValueKind.False)
{
jObj.Add("not", true);
}
}
jObj.Insert(0, "description", descriptionAttr.Description);
}
return schema;
}
});
// JSON Schema を JSON 文字列に変換して表示する
Console.WriteLine(jsonSchema.ToJsonString(options: new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
}));
// Person record
record Person(
[property: Description("氏名")]
string Name)
{
[Description("年齢")]
public int? Age { get; set; }
}
地味に最後の JSON Schema を生成するところに日本語がエスケープされないようにおまじないを追加していますが気にしないでください。実行すると以下の JSON Schema が生成されます。Description
属性が反映されていることがわかります。
{
"type": "object",
"properties": {
"name": {
"description": "氏名",
"type": "string"
},
"age": {
"description": "年齢",
"type": [
"integer",
"null"
]
}
},
"required": [
"name"
]
}
enum を文字列化したい
enum
を普通に使うと数字にされてしまいます。例えば以下のような Rank
という enum
を使ってみます。
record Person(
[property: Description("氏名")]
string Name)
{
[Description("年齢")]
public int? Age { get; set; }
[Description("ランク")]
public Rank Rank { get; set; }
}
enum Rank
{
[Description("未設定")]
None,
[Description("子供")]
Child,
[Description("大人")]
Adult,
[Description("シニア")]
Senior,
}
実行すると integer
扱いされていることがわかります。
{
"type": "object",
"properties": {
"name": {
"description": "氏名",
"type": "string"
},
"age": {
"description": "年齢",
"type": [
"integer",
"null"
]
},
"rank": {
"description": "ランク",
"type": "integer"
}
},
"required": [
"name"
]
}
以下のように enum
に JsonStringEnumConverter
を使って文字列化することで string
として出力されるようになります。
[JsonConverter(typeof(JsonStringEnumConverter))]
enum Rank
{
[Description("未設定")]
None,
[Description("子供")]
Child,
[Description("大人")]
Adult,
[Description("シニア")]
Senior,
}
実行結果は以下のようになります。
{
"type": "object",
"properties": {
"name": {
"description": "氏名",
"type": "string"
},
"age": {
"description": "年齢",
"type": [
"integer",
"null"
]
},
"rank": {
"description": "ランク",
"enum": [
"None",
"Child",
"Adult",
"Senior"
]
}
},
"required": [
"name"
]
}
因みに enum
につけた Description
属性を JSON Schema に反映する方法はわかりませんでした…。
JsonSerializerOptions から JSON Schema を生成する
今まで JsonSchemaExporter.GetJsonSchemaAsNode
を使って JSON Schema を生成していましたが、JsonSerializerOptions
からも JSON Schema を生成することができます。以下のように JsonSerializerOptions
から JSON Schema を生成してみます。
// Person record から JSON Schema を生成する
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};
// JsonSerializerOptions に GetJsonSchemaAsNode 拡張メソッドがある
var jsonSchema = options.GetJsonSchemaAsNode(
typeof(Person),
exporterOptions: new()
{
// 省略
});
複数の型を受け取るプロパティの場合
enum
では諦めましたが以下のようにクラスの継承を使って複数の型を受け取るプロパティを定義すると enum
で諦めた Description
付きの enum
のようなことが出来ます。Person
の Rank
プロパティを IRank
インターフェースにして、それを実装する None
, Child
, Adult
, Senior
クラスを作成します。
// Person record
record Person(
[property: Description("氏名")]
string Name)
{
[Description("年齢")]
public int? Age { get; set; }
[Description("ランク")]
public required IRank Rank { get; set; } = new None();
}
[JsonDerivedType(typeof(None), typeDiscriminator: "none")]
[JsonDerivedType(typeof(Child), typeDiscriminator: "child")]
[JsonDerivedType(typeof(Adult), typeDiscriminator: "adult")]
[JsonDerivedType(typeof(Senior), typeDiscriminator: "senior")]
interface IRank
{
}
[Description("未設定")]
class None : IRank;
[Description("子供")]
class Child : IRank;
[Description("大人")]
class Adult : IRank;
[Description("シニア")]
class Senior : IRank;
これを実行すると以下のようになります。ばっちり。
{
"type": "object",
"properties": {
"name": {
"description": "氏名",
"type": "string"
},
"age": {
"description": "年齢",
"type": [
"integer",
"null"
]
},
"rank": {
"description": "ランク",
"type": "object",
"required": [
"$type"
],
"anyOf": [
{
"description": "未設定",
"properties": {
"$type": {
"const": "none"
}
}
},
{
"description": "子供",
"properties": {
"$type": {
"const": "child"
}
}
},
{
"description": "大人",
"properties": {
"$type": {
"const": "adult"
}
}
},
{
"description": "シニア",
"properties": {
"$type": {
"const": "senior"
}
}
}
]
}
},
"required": [
"name",
"rank"
]
}
因みに以下のような IRank
インターフェースを実装したクラスを定義するとその他みたいなものもいい感じに表現できそうです。
[Description("その他")]
class Other : IRank
{
[Description("特記事項")]
public string? Notes { get; set; }
}
生成される JSON Schema は以下のようになります。
{
"type": "object",
"properties": {
"name": {
"description": "氏名",
"type": "string"
},
"age": {
"description": "年齢",
"type": [
"integer",
"null"
]
},
"rank": {
"description": "ランク",
"type": "object",
"required": [
"$type"
],
"anyOf": [
{
"description": "未設定",
"properties": {
"$type": {
"const": "none"
}
}
},
{
"description": "子供",
"properties": {
"$type": {
"const": "child"
}
}
},
{
"description": "大人",
"properties": {
"$type": {
"const": "adult"
}
}
},
{
"description": "シニア",
"properties": {
"$type": {
"const": "senior"
}
}
},
{
"description": "その他",
"properties": {
"$type": {
"const": "other"
},
"notes": {
"description": "特記事項",
"type": [
"string",
"null"
]
}
}
}
]
}
},
"required": [
"name",
"rank"
]
}
まとめ
生成 AI を使うアプリケーションでは最終的にユーザーに表示する文字列を生成する箇所では、普通に生成 AI の回答を表示するので良いですが、プログラムで処理するための値を生成 AI に作ってもらうためには Structured Output を使うのがやりやすいです。その際に出力するデータ構造を指定する方法として JSON Schema が使われています。
そのため、JSON Schema をプログラムで生成する需要が以前より増えて来ています。ということで .NET も .NET 9 から JSON Schema 生成機能が追加されています。今回はその機能を使って JSON Schema を生成する方法を確認しました。
Discussion