はじめに
この記事は、FIXER Rookies Advent Calendar 2024の13日目の記事です。
皆様、お久しぶりです。小野寺です。
11日目の室さんの記事を見てくれた人は「タイトル違いますやん何してくれてんの」と思うかもしれないですが、これには深い事情があるんです。
最後にパワーテクニックを紹介しますので安心してください。
さて、皆さんはたくさん Logic Apps を持っていますか?持っていますよね。
Logic Appsと言えばノーコード・ローコードで書ける、謂わばScratch感覚(当社比)で作れちゃう代物です。
このLogic Appsの設定を変更するときもボタンをポチポチしてあげると簡単にできますが、これが何十個もLogic Appsがあると考えたらさあ大変。
設定変更だけで日が暮れます。しかしこのLogic Appsには実は裏技があり、コード自体はJSON形式で構成されているためこのJSONさえ取得できれば容易に自動化できます。
そこで登場するのがjqコマンドです。今回はこのjqコマンドを使って同じ設定を使用している大量のLogic Appsを処理するスクリプトを組んでいきます。
動作環境
- PowerShell 7
- Azure CLI 2.66.0
- VS Code
jqコマンドって?
まずは、jqコマンドについて簡単に説明したいと思います。まずはGaiXerに聞いてみましょう。
分かりやすく完結に、そして例文まで出してくれるGaiXer君大好きです。
はい、jqコマンドとは、JSONデータを効率的に処理できる便利なコマンドです。フィルター条件を設定すれば複雑な構造や深いネストも処理することができるためJSONを扱うときは重宝します。
スクリプトを組んでみた
早速、このjqコマンドを使ってLogic Appsを書き換えるスクリプトを組んでいきましょう。
そして追加で用意するのはこちら
- az logic workflow list
- az logic workflow show
- az logic workflow update
全体の処理の流れとしては、listでLogic Appsの名前を一覧取得、listで各コードを取得、jqコマンドで設定値を変更、最後にupdateでコードを更新させるという感じです。
では、実際にコードに書き起こしてみましょう。
# === 設定セクション ===
# テナント ID を設定(必要な場合)
$tenantId = "テナントID "
# サブスクリプション ID を設定
$subscriptionId = "サブスクリプションID"
# 置換するパスの指定
$before = "変更前の設定値"
$after = "変更後の設定値"
# 一時ファイルを保存するディレクトリ
$tempDir = "C:\Temp"
# === スクリプト開始 ===
# Azure にログイン
az login --tenant $tenantId --output none
az account set --subscription $subscriptionId
Write-Host "Azure へのログインが完了しました。"
# Logic App の一覧を取得
# すべてのリソース グループを対象
$logicApps = az logic workflow list --subscription $subscriptionId --query "[].{name:name, resourceGroup:resourceGroup}" -o tsv
# 各 Logic App を処理
$logicAppsLines = $logicApps -split "`n"
Write-Host "処理対象の Logic App 数: $($logicAppsLines.Count)"
#jq フィルターを保存(一度だけ保存)
$jqFilterContent = @'
def replace_paths:
walk(
if type == "object" and has("inputs") then
if .inputs.path then
.inputs.path |= sub($before; $after)
elif .inputs.uri and .inputs.uri.path then
.inputs.uri.path |= sub($before; $after)
else
.
end
else
.
end
);
replace_paths
'@
$jqFilterFile = Join-Path $tempDir "jqFilter.jq"
if (-not (Test-Path $jqFilterFile)) {
Set-Content -Path $jqFilterFile -Value $jqFilterContent -Encoding UTF8
}
foreach ($line in $logicAppsLines) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
# タブで分割
$fields = $line -split "`t"
$logicAppName = $fields[0]
$resourceGroupName = $fields[1]
# Logic App の定義を取得
$definition = az logic workflow show --name "$logicAppName" --resource-group "$resourceGroupName" -o json
# 定義をファイルに保存(変更前)
$definitionFileBefore = Join-Path $tempDir "definition_before_$logicAppName.json"
Write-Host "変更前の定義をファイルに保存しています: $definitionFileBefore"
Set-Content -Path $definitionFileBefore -Value $definition -Encoding UTF8
# jq エラー出力をキャプチャ
$jqErrorFile = Join-Path $tempDir "jqError_$logicAppName.txt"
# jq を実行し、出力を一時ファイルに保存
$tempOutputFile = Join-Path $tempDir "temp_output_$logicAppName.json"
& jq --arg oldPath "$before" --arg newPath "$after" -f $jqFilterFile $definitionFileBefore 2> $jqErrorFile > $tempOutputFile
# エラーメッセージを読み込み
$jqError = Get-Content $jqErrorFile -ErrorAction SilentlyContinue -Encoding UTF8
Remove-Item $jqErrorFile -ErrorAction SilentlyContinue
if (-not (Test-Path $tempOutputFile) -or $jqError) {
Write-Host "パスの置換に失敗しました。エラーメッセージ:"
Write-Host $jqError
Write-Host "スキップします。"
continue
}
# 置換後の定義を読み込み
$updatedDefinition = Get-Content -Path $tempOutputFile -Encoding UTF8 -Raw
# 更新された定義をファイルに保存(変更後)
$updatedDefinitionFile = Join-Path $tempDir "definition_after_$logicAppName.json"
Write-Host "変更後の定義をファイルに保存しています: $updatedDefinitionFile"
Set-Content -Path $updatedDefinitionFile -Value $updatedDefinition -Encoding UTF8
# 一時ファイルを削除
Remove-Item $tempOutputFile -ErrorAction SilentlyContinue
# Logic App の定義を更新
az logic workflow update --name "$logicAppName" --resource-group "$resourceGroupName" --definition "@$updatedDefinitionFile" | Out-Null
Write-Host "Logic App '$logicAppName' の処理が完了しました。"
}
デバッグも含めてつらつらとコードを書きましたが、今回の処理の肝はここです。
# jq フィルターを保存(一度だけ保存)
$jqFilterContent = @'
def replace_paths:
walk(
if type == "object" and has("inputs") then
if .inputs.path then
.inputs.path |= sub($before; $after)
elif .inputs.uri and .inputs.uri.path then
.inputs.uri.path |= sub($before; $after)
else
.
end
else
.
end
);
replace_paths
'@
jqコマンドで変換するときにはフィルター条件が大事になりますが、これがその処理になります。
上記の関数の流れとしては、$before
に修正前の設定値、$after
に修正後の設定値で、JSONのオブジェクトと$before
の値が一致すれば$after
の値に置き換わるというものです。
また、Logic Appsの処理はinputsオブジェクトの中に書かれているので、inputsオブジェクトの中にあるものだけについて探索させるようにしています。
そしてそのファイルを使ってjqコマンドを実行します。
jq --arg oldPath "$before" --arg newPath "$after" -f $jqFilterFile $definitionFileBefore 2> $jqErrorFile > $tempOutputFile
$jqFilterFileがフィルター条件が格納されているファイルのパス、$definitionFileBeforeが修正前のLogic Appsが格納されているファイルのパスです。
そういえば、処理の流れとしてフィルターと修正前jsonを一度ファイルに保存しているかというと、jqコマンドはフィルター条件を直接入れるとバグることがあり、その対策と処理の前後との比較がしやすいためです。切り戻しも修正前のコードを送信するだけで済むので楽です。
ついでに下に実行した結果も載せます。
修正前
修正後
このように変更したい場所が変更出来ています。このコードはGaiXerに作成してもらいました。GaiXer大好き。
1つ、注意点があります。
このスクリプトは注意点があり、JSONを保存するときは絶対utf-8エンコーディングを指定してください。
Set-Content -Path $definitionFileBefore -Value $definition -Encoding UTF8
指定しないと、修正後のJSONに文字化けが発生します。
なぜ、文字化けが発生するかというと、jqコマンドはutf-8で出力するのに対して、PowerShellは、デフォルトの文字コードはShift-JISなのでそこの相違で文字化けが発生します。
しかし文字コードの指定をしないで保存した修正前のファイルを開くと、ちゃんとutf-8として表示されているんですよね。(理由は分からなかったです)
おそらくVS Code側の認識の問題か、PowerShellの自動判断に誤りがあったと思われます。
ことりあえずこの文字化けのせいで、私は延々と悩んでいました。
ということで、今回はLogic Appsの修正を一気に行う方法について簡単に解説しました。
PowerShellでもJSONを扱うコマンド(ConvertFrom-Json etc..)がありますが、複雑な処理や大量のjsonを扱いたいときはjqコマンドの方が良いかなと個人的に思っています。皆もLogic Appsとjsonでもっと遊ぼう!
おまけ
お待たせした。冒頭に話したゴリ押し解決策です。
当時、文字化けが直らず延々と悩んでいたまぬけなエンジニア(私)は良い解決策を思いつきました。
「そうだ!日本語を別に保存して後からまたくっ付ければ良いじゃん!」と。
ということで、またGaiXer君に日本語を抽出させる処理を作ってもらいました。
function Extract-JapaneseText($json) {
# 日本語文字を含む値を抽出してハッシュテーブルに格納
$japaneseTextMap = @{}
$regex = '[\p{IsCJKUnifiedIdeographs}\p{IsHiragana}\p{IsKatakana}]'
$jsonObj = $json | ConvertFrom-Json
$traverse = {
param ([object]$node, [string]$path)
if ($node -is [System.Collections.IDictionary]) {
foreach ($key in $node.Keys) {
$value = $node.$key
$currentPath = "$path.$key"
if ($value -is [string] -and $value -match $regex) {
$japaneseTextMap[$currentPath] = $value
}
elseif ($value -isnot [string]) {
& $traverse -node $value -path $currentPath
}
}
}
elseif ($node -is [System.Collections.IEnumerable] -and $node -isnot [string]) {
$index = 0
foreach ($item in $node) {
$currentPath = "$path[$index]"
& $traverse -node $item -path $currentPath
$index++
}
}
}
& $traverse -node $jsonObj -path '$'
return $japaneseTextMap
}
この関数は、簡単に言えば、値が文字列であり日本語文字を含む場合、その値とそのパスを抽出して $japaneseTextMap
に保管するというものです。
実際の例を使って説明すると、
{
"user": {
"name": "太郎",
"details": {
"address": "東京都"
}
},
"items": [
"りんご",
"バナナ"
]
}
というJSONがあるとします。
"太郎"
のキーはuser.name
"東京都"
のキーはuser.details.address
- 配列内の
"りんご"
のキーはitems[0]
- 配列内の
"バナナ"
のキーはitems[1]
という情報をハッシュテーブルに保持しておきます。これで日本語をコピーする形で保存することができます。
そして、jqコマンドで変換後は元に戻す処理も必要です。
function Restore-JapaneseText($json, $japaneseTextMap) {
$jsonObj = $json | ConvertFrom-Json
foreach ($path in $japaneseTextMap.Keys) {
$script = [string]::Format('$jsonObj{0} = "{1}"', $path.Substring(1), $japaneseTextMap[$path].Replace('"', '\"'))
Invoke-Expression $script
}
return $jsonObj | ConvertTo-Json -Depth 99
}
上記も解説すると、$japaneseTextMap.Keysが日本語があったパス、$japaneseTextMap[$path]に日本語が格納されており、先ほどの"太郎"
を参考にすると、
'$jsonObj.user.name = "太郎"'
という風になり、そのまま変換後のJSONに代入されていきます。
Substring(1)
と"Replace('"', '\"')"
に関しては、良い感じに整形してくれているものだと思ってください。
この処理をjqコマンドの前後において、それぞれ関数を呼び出すと文字コード関係なく文字化けが発生しなくなります。楽ですね!