銀の光と碧い空

クラウドなインフラとC#なアプリ開発の狭間にいるエンジニアの日々

.NET 10 Preview1で追加されたOrderedDictionaryのTryAddメソッドについて

先日のC# Tokyoの .NET 10 Preview1 イベントでも紹介した機能になります。動画の中でGitHubのサンプルコードを口頭で説明したのですが、それを実際のコードに起こしてみました。

www.youtube.com

OrderedDictionaryにTryAddとTryGetValueというメソッドが追加されました。

github.com

どのような使い方をするかというと、このGitHubにも書かれていますが以下のようなコードを想定していそうです。単語が文字列中に何回出現するかをカウントするのに、出現した単語をキーにして出現回数を値に確保しています。

public static void Execute()
{
    //指定された文字列が何回出現したかをカウントする
    var text = "apple banana apple cherry banana apple";
    var orderedDictionary = new OrderedDictionary<string, int>();
    foreach (var word in text.Split(' '))
    {
        IncrementValue(orderedDictionary, word);
    }
    foreach (var pair in orderedDictionary)
    {
        Console.WriteLine($"{pair.Key}: {pair.Value}");
    }
}

private static void IncrementValue(OrderedDictionary<string, int> orderedDictionary, string key)
{
    if (!orderedDictionary.TryAdd(key, 1, out int index))
    {
        int value = orderedDictionary.GetAt(index).Value;
        orderedDictionary.SetAt(index, value + 1);
    }
}

他の使い方としては、キーに関連する値を配列としてすべて確保するという使い方もできそうです。

private static void AddValue(OrderedDictionary<string, string[]> orderedDictionary, string key, string value)
{
    if (!orderedDictionary.TryAdd(key, [value], out int index))
    {
        var array = orderedDictionary.GetAt(index).Value;
        orderedDictionary.SetAt(index, [.. array.Prepend(value)]);
    }
}

TryGetValueメソッドの方は、最初の例に追加してカウントダウンする処理も追加したい場合に使えそうです。

private static void DecrementValue(OrderedDictionary<string, int> orderedDictionary, string key)
{
    if (orderedDictionary.TryGetValue(key, out int value, out int index))
    {
        //カウントが0になる場合は削除する
        if (value == 1)
        {
            orderedDictionary.RemoveAt(index);
        }
        else
        {
            orderedDictionary.SetAt(index, value - 1);
        }
    }//存在しない場合は何もしない
}

コードはこのリポジトリで公開しています。

github.com

ASP.NET Core的にAzure Web AppからAzure OpenAIへのアクセスをセキュアにする

ASP.NET Coreで作成したアプリをAzure Web Appで動かしAzure OpenAIにアクセスする場合、Microsoftのドキュメントでは認証をマネージドID認証にし、設定項目(エンドポイントなど)はKeyVaultに格納するという方法が記載されていました。ネットワーク的に制限をかける方法なども考えられますが、今回はC#のソースコードで考慮しないといけないこの2つを試してみます。

まず、マネージドID認証にする方法はこちらのドキュメントを参照します。

learn.microsoft.com

まず、記事の手順でAzure側の操作を行い、Azure Web AppsからOpenAIへのマネージド認証を有効にします。まず、Azure Web AppsのIdentityでStatusを有効にします。

この画面のAdd role assigmentからはアサイン済みのマネージドIDが確認でき、追加メニューもありますが一部のリソース種類のみでOpenAIは指定できませんが、OpenAIを含むリソースグループやサブスクリプションを指定することはできます。次の手順で設定するKeyVaultはここから追加できます。下の画面は次の手順まで実施したあとの様子です。今回はシステム割り当て IDを使っています。

さてコードについてですが、前回の記事のコードを書き換える場合を考えます。

tech.tanaka733.net

まずAzure.IdentityというNuGetライブラリを追加します。次にAPIKeyを指定する代わりに、次のようにDefaultAzureCredentialのインスタンスを指定します。別のコンストラクタを呼び出すことになります。

var credentials = new DefaultAzureCredential();
var options = sp.GetRequiredService<IOptions<AzureOpenAIOptions>>().Value;
var kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.AddAzureOpenAIChatCompletion(
    deploymentName: options.ChatDeploymentName,
    endpoint: options.Endpoint,
    credentials: credentials
);

ユーザー割り当てIDを使う場合は次のようになります。

var credentials = new DefaultAzureCredential(
        new DefaultAzureCredentialOptions
        {
            ManagedIdentityClientId = "myIdentityClientId".
        }
    );

これで認証はできましたが、このままWebAppsで動かしても前回のコードのままだとdotnet secretに設定したエンドポイントなどの情報が取得できません。そこで次の記事を参照してAzure KeyVaultに格納した情報を取得するようにします。

learn.microsoft.com

KeyVaultを作成して、WebAppsからのマネージドIDをアサインします。アサインの手順はOpenAIの時と同様です。次にdotnet secretに格納した情報をKeyVaultのsecretに保存しますが、secretの名前はSection--SecretNameという形式にします。AzureOpenAI--ChatDeploymentNameAzureOpenAI--ChatDeploymentNameという名前になります。

コード側はまずAzure.Extensions.AspNetCore.Configuration.SecretsというNuGetライブラリを追加します。上の手順で追加したAzure.Identityも必要です。そしてoptionsを取得する手前に次のコードを挿入します。

builder.Configuration.AddAzureKeyVault(
    new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
    credentials);

ドキュメントではKeyVaultNameをConfigurationから取得していたので環境変数に設定しました。またif (builder.Environment.IsProduction())で括っていましたが、同じコードベースでAzure WebApps以外で動かす場合にEnvironmentを切り替えて別の認証を行うことを想定しているはずです。 最後に、前の記事ではAzureOpenAIOptionsクラスにApiKeyプロパティを用意していたので削除するか、null許容にしておきます。

以上の対応で冒頭に紹介した2つの対応が完了しました。

Semantic Kernel をASP.NET CoreのDIで利用するためのサンプルコード

SementicKernelをDIで利用するためのサンプルコード自体はこちらで公開されています。secretを取得するときのセクション名の指定がそのままだと動かないっぽいのでこちらのIssueを参照してください。

github.com

これだけでほぼ終わりなのですが、IChatCompletionService をDIせずにKernelのみをDIする方法を載せておきます。次のコードのようにKernelインスタンスを生成する際にAddAzureOpenAIChatCompletionメソッドで必要なパラメーターを与えておきます。この例では元のサンプル同様にAddOptionsメソッドを使ってsecretに設定した値を取得していますが、AddAzureOpenAIChatCompletion内に直接指定する場合はそれも不要になります。

builder.Services.AddOptions<AzureOpenAIOptions>()
                        .Bind(builder.Configuration.GetSection(AzureOpenAIOptions.SectionName))
                        .ValidateDataAnnotations()
                        .ValidateOnStart();

builder.Services.AddKeyedTransient("LabKernel", (sp, key) =>
{
    var options = sp.GetRequiredService<IOptions<AzureOpenAIOptions>>().Value;
    var kernelBuilder = Kernel.CreateBuilder();
    kernelBuilder.AddAzureOpenAIChatCompletion(
        deploymentName: options.ChatDeploymentName,
        endpoint: options.Endpoint,
        apiKey: options.ApiKey
    );
    return kernelBuilder.Build();
});

public class AzureOpenAIOptions
{
    public const string SectionName = "AzureOpenAI";

    public required string ChatDeploymentName { get; set; }

    public required string Endpoint { get; set; }

    public required string ApiKey { get; set; }

    public bool IsValid =>
        !string.IsNullOrWhiteSpace(ChatDeploymentName) &&
        !string.IsNullOrWhiteSpace(Endpoint) &&
        !string.IsNullOrWhiteSpace(ApiKey);
}

Controllerなり、RazorのModelクラスではこんな感じに利用します。

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;
    private readonly Kernel _kernel;

    public IndexModel(ILogger<IndexModel> logger, [FromKeyedServices("LabKernel")] Kernel kernel)
    {
        _logger = logger;
        _kernel = kernel;
    }
    public void OnGet()
    {
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        var answer = await _kernel.InvokePromptAsync(
"Why is the sky blue in one sentence?"
);

        //answerを使って何かする
        return RedirectToPage("./Index");
    }
}