APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

シェルスクリプトで再帰関数を書こう

f:id:thanaism:20210903205313p:plain

おばんです、コンテナソリューショングループの髙井です。

今日はめずらしくシェルスクリプトで再帰関数を書いてみようという記事になります。

スクリプトは簡単な例にしたのですが、再帰のほかに、

  • ループ
  • 条件分岐
  • 引数
  • 変数スコープ
  • 配列

といった要素も含んでいますので、再帰に限らずシェルスクリプトを全体的におさらいできるかと思います。

再帰の書き方

シェルスクリプトでの再帰の書き方ですが、これは非常に単純です。

function loop () {
    loop
}

基本的にはこれだけなんですが、実際に書こうとすると必要になるポイントがいくつかありますので後述します。

Azureで再帰が使える例を探す

せっかくなのでAzureを題材に再帰関数を使ってみましょう。

Azure Active Directory(AAD)のグループに含まれるユーザーの一覧をターミナルに表示させるスクリプトを考えます。

AADは、ざっくり言うとユーザー管理基盤です。その組織に属するユーザーがみんな登録されているわけですね。

で、たくさんのユーザーがいれば同じような属性をもつ集団をグループにして管理したくなるわけでして、当然その機能があります。

そして、このグループという機能、グループにグループを含めることが可能です。

はい、そうです。まさしく再帰的な構造です。盛り上がってまいりました。

AADグループに含まれるユーザーを再帰的に探索する

そうと決まればさっそく書いてまいりましょう。

ちなみに、グループへの権限割り当ては入れ子になっているグループに適用されませんが、条件付きアクセスの場合は適用されたりと複雑なので、以下のMicrosoftサポートブログも見ておくとよいでしょう。

jpazureid.github.io

メンバーがユーザーかグループかを判別する

なにはともあれ、お題を実装するためにまずはパーツを用意します。ある1つのグループのメンバーをリストするには以下のazコマンドが使用できます。

az ad group member list -g <aad-group-name>

このときのメンバーはユーザーだったりグループだったりするわけですが、それはobjectTypeというプロパティで判別することができます。

これを配列に入れてしまいましょう。

objTypes=($(az ad group member list -g <aad-group-name> -o tsv --query "[].objectType"))

ここでは、--queryオプションによりJMESPathでクエリしています。

手前味噌で申し訳ありませんが、JMESPathやJSONPathやjqなど、各種jsonパーサーについての簡単な比較記事もありますのでご参考までに。

dev.thanaism.com

ループを回して条件分岐する

さきほどの配列の各要素を見て、ユーザーかグループかで条件分岐させましょう。ここまでは、なんの変哲もないシェルスクリプトですね。

ついでにobjectIdもあとで使うのでそれも配列に入れておきます。

objTypes=($(az ad group member list -g <aad-group-name> -o tsv --query "[].objectType"))
objIds=($(az ad group member list -g <aad-group-name> -o tsv --query "[].objectId"))

for i in ${!objTypes[@]}
do
    if [ ${objTypes[i]} = "Group" ]
    then
        # Groupの場合の処理
    else
        # それ以外の場合の処理
    fi
done

bashの配列について

ちょっとだけ補足しておきます。これだけ押さえておけば不便はしないと思います。

  • ${array[@]}: 配列の全要素をforで回す
  • ${array[i]}:各要素にインデックスでアクセスする
  • ${#array[@]}:配列の要素数を取得する
  • ${!array[@]}:配列のインデックスのリストを取得する

引数を受け取る関数にする

前段のままだと<add-group-name>のところがリテラルになってしまっています。 関数にして引数で受け取りましょう。関数名はdfsとします(意味は次で説明します)。

function dfs () {
    objIds=($(az ad group member list -g ${1} -o tsv --query "[].objectId"))
    objTypes=($(az ad group member list -g ${1} -o tsv --query "[].objectType"))
    for i in ${!objIds[@]}
    do
        if [ ${objTypes[i]} = "Group" ]
        then
            # Groupの場合の処理:再帰呼び出しをする
        else
            # それ以外の場合の処理:メンバーの名前を表示する
        fi
    done
}

dfs <add-group-name>

変数スコープを考慮して再帰にする

いよいよ再帰呼び出しを実装するわけですが、上記のコードではグループが見つかったら先にそのグループを探索してから元のグループに戻ります

再帰を使っているので深さ優先で探索しているわけですね。先ほどの関数名がdfsだったのはdepth first searchの略です。

さて、なぜわざわざそんな説明をしたかというと、その理由は変数スコープになります。

関数内の変数定義に要注意

新しいグループを見つけたら先にそのグループを探索してしまうので、元のグループに戻ったときにはインデックスとして使っていたiがもう書き換わってしまっているのです。

  1. グループAで関数を呼び出す(インデックスiが1からはじまる)
  2. 途中で入れ子のグループBが見つかり再帰呼び出しをする(インデックスiがリセットされ再び1からはじまる)
  3. グループBの探索が終わり、呼び出し元のグループAの探索を再開(インデックスiがBの呼び出しで書き換わったまま)

関数スコープを再現する

変数は何もしないとグローバルスコープになってしまうのでこういった状況が発生します。一般的なプログラミング言語のように関数スコープを再現するには、()を使ってサブシェルとして呼び出してあげればよいです。

function dfs () {
# 丸括弧をつけるとサブシェル内で実行される
(
    objIds=($(az ad group member list -g ${1} -o tsv --query "[].objectId"))
    objTypes=($(az ad group member list -g ${1} -o tsv --query "[].objectType"))
    for i in ${!objIds[@]}
    do
        if [ ${objTypes[i]} = "Group" ]
        then
            # Groupの場合の処理:再帰呼び出しをする
            dfs $(az ad group show -g ${objIds[i]} -o tsv --query displayName)
        else
            # それ以外の場合の処理:メンバーの名前を表示する
            echo $(az ad user show --id ${objIds[i]} -o tsv --query displayName)
        fi
    done
)
}

dfs <add-group-name>

無事、想定通りの再帰関数を実装することができました。

おわりに

再帰にフォーカスして書こうとしたのに、必要な要素を盛り込むだけで意外と他の要素もまんべんなく使うことになって想定よりボリューミーになってしまいました。

これくらいの単純なスクリプトだと、わざわざCだとかRubyだとかを駆使すると一気に手間が増えてしまうので、再帰もサクッと使えるようにしておくと便利です!

ターミナルのコマンドでも、ブラウザ上のAzure Portalでも、IaCのコードでも、すべてにおいてAzureを駆使していきましょう。それでは~