この記事は .NET Core 3.1 のデーモンコンソールアプリで MSAL トークンをクライアント ID と X.509 証明書で取得してみた時の作業メモです。
やったこと:
- Azure Key Vault で証明書作成
- Azure AD アプリ作成
- .NET からトークン取得
Azure Pipelines 使っていますか? 昨今は GitHub Actions に後塵を拝している感が否めませんが、マルチステージを利用した承認機能など、実は結構便利に利用できます。今回は Xamarin.Forms で実装した iOS アプリをビルドするための Azure Pipelines を組んでみます。またビルド結果を AppCenter へ送信し、テスト配布も行います。
全体を一本の記事にするととても長くなってしまうためビルドとテスト配布の2つに分割しました。本記事は後編の配布編です。
ノンビリと書いていたら GitHub Actions にも承認処理が来てしまったような気がしますが、気にしたら負けです。
今回試したものはこちらに置いてあります。
前編の記事でビルドした Xamarin iOS アプリを AppCenter へ送信する Pipeline YAML は全体でこのようになります。実際にはビルドと配布のマルチステージ構成となっていますので、 GitHub 上の YAML をご参照下さい。
variables: AppCenterAppSlug: 'AppCenterの識別名 "チーム名/アプリ名" ' pool: vmImage: 'ubuntu-latest' # 何でも良い steps: # Pipeline Artifacts をダウンロード - download: current # AppCenter へビルドしたアプリを配信 - task: AppCenterDistribute@3 displayName: 'Distribute to AppCenter' inputs: serverEndpoint: 'AppCenterConnection' appSlug: '$(AppCenterAppSlug)' appFile: '$(Pipeline.Workspace)/drop/SampleApp.iOS.ipa' releaseNotesOption: 'input' releaseNotesInput: '$(Build.SourceVersionMessage)' destinationType: 'groups' isSilent: false
download
は Download Pipeline Artifact
タスクのショートカットです。ドキュメントに記載の通りこのショートカットを使用すると $(Pipeline.Workspace)/
に Pipeline Artifacts がダウンロードされます。デプロイ系のジョブであれば自動で Pipeline Artifacts を持ってきてくれる気がしますが普通のジョブでは手動の点、注意が必要です。
ビルドで生成された *.ipa
ファイルを AppCenter で配布するために AppCenter へと送信するタスクです。このタスクを使用するには AppCenter から取得したトークンを元に ServiceConnection を作成することが必須となります。また AppCenter のアプリ登録を {userName}/{appName}
の形式で appSlug
に設定します。先行タスクで Pipeline Artifacts から ipa ファイルをダウンロードしているので、 appFile
にはダウンロードしたファイルのパスを指定します。
そしてちょっと面倒臭いことに AppCenter に登録するリリースノート情報が必須です。リリースノートの指定は releaseNotesOption
と releaseNotesInput
または releaseNotesFile
を使用します。サンプルではビルド対象のコミットのコミットメッセージが格納されている Azure Pipelines の Build.SourceVersionMessage
環境変数を固定で指定しています。
task: AppCenterDistribute@3 inputs: serverEndpoint: 'AppCenterConnection' appSlug: '$(AppCenterAppSlug)' appFile: '$(Pipeline.Workspace)/drop/SampleApp.iOS.ipa' releaseNotesOption: 'input' releaseNotesInput: '$(Build.SourceVersionMessage)' destinationType: 'groups' isSilent: false
続いて AppCenter へ送信するために必要な ServiceConnection の登録方法です。まずはプロジェクトの設定画面から ServiceConnection を選択して新規作成を行います。種別は Visual Studio App Center
を選択します。選択した後の設定値は表を参考にして下さい。
設定名 | 設定値 |
---|---|
Server URL | (元の値から変更しない) |
API Token | AppCenter の設定画面から FullAccess スコープで出力したトークンキー |
Service connection name | AppCenterDistribute タスクの serverEndpoint に指定する名前 |
Description | お好みで |
ApplicationInsights で記録したログが時系列に表示される「エンドツーエンドトランザクションの詳細画面」を検索画面を経ずに URL 生成して直接表示してみようという試みのメモです。エンドツーエンドトランザクションの詳細画面はコレ。
この画面の URL はパスにエスケープされた JSON を2つ含んでおり、見やすくエスケープとフォーマットをしてみます。
https://portal.azure.com/#blade/AppInsightsExtension/DetailsV2Blade/DataModel/ { "eventId": "00000000-0000-0000-0000-000000000000", // 対象レコードの itemId "timestamp": "2020-09-21T10:23:57.159Z", // 対象レコードの timestamp "cacheId": "00000000-0000-0000-0000-000000000000", // 不明 "eventTable": "exceptions", // 対象レコードの itemType "timeContext": { // 日時検索範囲 "durationMs": 86400000, // 検索期間ミリ秒 "endTime": "2020-09-21T19:21:00.000Z", // 検索終了日時 "createdTime": "2020-09-21T19:20:14.460Z", // 検索条件が作成された日時? "isInitialTime": false, // 不明 "grain": 1, // 不明 "useDashboardTimeRange": false // 不明 } } /ComponentId/ { "Name": "ApplicationInsightsの名前", "SubscriptionId": "サブスクリプションID", "ResourceGroup": "リソースグループの名前" }
この中から実際に画面を叩いてみて必要だったパラメーターは次の6つでした。
詳細画面を直接開くURLは例えばこのように生成できます。
// JSの場合 const aiName = 'ApplicationInsights Resource Name'; const subscriptionId = '00000000-0000-0000-0000-000000000000'; const resourceGroupName = 'ResourceGroup Name'; const eventId = '00000000-0000-0000-0000-000000000000'; // 対象レコードID const timestamp = new Date('2020-09-22T04:43:00+09:00'); // 対象レコード日時 const durationMs = 24 * 60 * 60 * 1000; // 1日 = 86400000 const endTime = new Date(timestamp.getTime() + durationMs / 2); // 表示終了日時 = 対象レコードから12時間後 const dataModel = JSON.stringify({ eventId, timeContext: { durationMs, endTime } }); const componentId = JSON.stringify({ Name: aiName, SubscriptionId: subscriptionId, ResourceGroup: resourceGroupId }); const url = 'https://portal.azure.com/#blade/AppInsightsExtension/DetailsV2Blade/DataModel/' + encodeURIComponent(dataModel) + '/ComponentId/' + encodeURIComponent(componentId);
データを POST するときは文字列、 GET するときはオブジェクトという C# 泣かせの構造な API に出会ってしまったため、 System.Text.Json の Converter を作った時のメモです。
公式のドキュメントはこちら。
冒頭に記述したように、今回の対象は POST と GET で異なる型の C# 泣かせな JSON です。
実際の JSON はこのようになっています。
// POST { "key": "abcde" } // GET { "key": { "link": "https://xxxxxx/abcde", "value": "abcde } }
これを Converter を駆使してイイカンジに POCO にマッピングします。
// 対象の POCO public class LinkObject { public string? Link { get; set; } public string? Value { get; set; } }
冒頭にリンクを張ったドキュメントによると System.Text.Json の Converter 仕様は色々あるようです。今回は複雑なものでは無いためシンプルに JsonConverter<T>
を実装します。
まずは POCO から JSON にシリアライズする処理を実装します。実装方法はシンプルで、 Utf8JsonWriter
を使って JSON の構造を記述していきます。文字列を1つ書き込むだけなので難しいことはせず、 null or not で処理がちょっと違うだけです。
public override void Write(Utf8JsonWriter writer, LinkObject value, JsonSerializerOptions options) { if (value.Value == null) { writer.WriteNullValue(); return; } writer.WriteStringValue(value.Value); }
ちょっと分からなかったのは null の除外方法です。 JsonSerializerOptions
で IgnoreNull
を指定すると null を JSON に出力させない設定が可能ですが、 Converter に入ってきた時点でプロパティのキーは書き込まれてしまっているため、 オブジェクトは null ではないが状態としては null の場合は null を書き込まざるをえませんでした。
続いて JSON から POCO へのデシリアライズ処理を実装します。 Utf8JsonReader
はいわゆる? PullParser 形式で、 JSON の構成要素を一つ一つ順番に読んでいきます。その時点でのトークンを見ながらオブジェクトにマッピングしていきます。
public override LinkObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // objectスコープに入っていることをチェック if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException("Invalid TokenType"); } // Readを繰り返してobjectスコープの終わりまで読む var obj = new LinkObject(); while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { return obj; } if (reader.TokenType != JsonTokenType.PropertyName) { continue; } var propName = reader.GetString().ToLower(); if (!reader.Read()) { throw new JsonException(); } var value = reader.TokenType switch { JsonTokenType.Null => null, JsonTokenType.String => reader.GetString(), _ => throw new JsonException() }; switch (propName) { case "value": obj.Value = value; break; case "link": obj.Link = value; break; default: throw new JsonException("Unknown property name"); } } throw new JsonException(); }