やわらかテック

業務を通して得られた知見、知見。個人的に試してみたこと、興味のあることについてアウトプットしています。

ターミナルのパイプの中にElixirの処理を挟む方法を思いついた

きっかけは突然に

弊社のインフラエンジニアの方がデプロイ作業の中でこんなようなperlの処理系をコマンドの中にパイプで渡しているのを発見しました👀

ls | tail -1 | perl -ne 'if (/release command failed/){print 1}else{print 0}'

処理の内容はともかく、パイプの中に言語の処理系が組み込めることに感動しました。perlは経験のない言語なので、このためだけに新しく言語を習得することはありませんが、自分が愛用しているElixirでも同じことが出来ないか試してみました。リスト操作はElixirEnumとパイプラインが得意とするところです。
ところが、ElixirのREALiexでは上記のようにパイプの中に組み込んで引数を渡して、文字列として受け取ったコードを評価して実行するということは出来ませんでした。

出会いは突然に

その代わりといっては何ですが、色々と調査を進める内に、文字列として渡されたElixirのコードを評価して実行することが出来るCode.eval_stringという関数を発見しました。こんな感じで文字列のコードを評価して実行して結果を得ることが出来ます。

iex(1)> code = "[1,2,3,4,5] |> Enum.map(&(&1 + 5)) |> Enum.sum()"
"[1,2,3,4,5] |> Enum.map(&(&1 + 5)) |> Enum.sum()"

iex(2)> Code.eval_string(code)
{40, []}

Code.eval_stringには引数が3つまで渡すことが可能で変数を評価出来るようになっています。今回は詳細は省きますので、ドキュメントを参照して頂ければと思います。

hexdocs.pm

ひらめきは突然に

プログラミングElixirの書籍の中でも紹介されている、コマンドライン引数をパースするサンプル(第13章)のことを唐突に思い出しました。「あれ、これもしかして、コマンドライン引数から文字列を渡せば任意のコードを実行出来るんじゃ...??」と思いつき、試してみました。

完成したコードはこちらです。

github.com

コマンドラインの評価は超がつくほどシンプルです。-cというオプションの後に受け取った値をElixirのコードとして評価するようにしています。

def parse_args(argv) do
  parse = OptionParser.parse(argv, switches: [c: :boolean], aliases: [ c: :code ])
  case parse do
    { [ code: code ], args, _ } -> { :code, code, args }
    _ -> { :error, "", [] }
  end
end

あとはここでパースしたコードをCode.eval_stringに渡すだけです。

defp eval_string({ :code, code, _ }) do
  { result, _ } = Code.eval_string(code)
  result
end

作成したコードをコマンドとして実行出来るように、escriptを使ってコードをビルドします。githubの方にはすでにビルド済みのcommand_iexというバイナリファイルをあげてあります。このファイルの名前を変更してみましたが問題なく使えることを確認しました。

# mix.exsにmain関数が記述された対象のモジュールを指定
defp escript_config do
  [ main_module: CommandIex ]
end

mix escript.build

escriptsの詳細 elixirschool.com.

この時点で以下のようなことが可能になりました。ターミナルから文字列で渡したElixirのコードが評価され実行されました。

$ ./command_iex -c '[1,2,3,4,5] |> Enum.map(&(&1+5)) |> Enum.sum()'
40

パイプに組み込む

次にやりたいのは引数をパイプ経由でコードに渡すことです。先ほどは[1,2,3,4,5]という配列として扱いたい値を文字列で渡したコードの中に直接、書き込みました。これでは、不便なので引数を動的に渡して、評価するようにしてみます。Code.eval_stringの第2引数に評価したい変数名をキーワードリストで指定します。ここでネックになるのは引数として扱い値の変数名が固定されてしまうということです。マクロ等を使えば、もっと汎用性のあるコードが書けるでしょうが、簡単のため、今回はlstという変数名で固定化してあります。

# [lst: args]を追加
defp eval_string({ :code, code, args }) do
  { result, _ } = Code.eval_string(code, [lst: args])
  result
end

これで以下のようなことが可能になりました。lstにはargsのデフォルト値の空配列([])が束縛されるため、実行の結果がエラーとならず0になっています。

$ ./command_iex -c 'Enum.map(lst, &(&1+5)) |> Enum.sum()'
0

変数も評価されるようになりました。

コマンドから引数を渡す

色々なことを試しましたが、ここはElxiirの制御外で、コードの書き方でどうにかなる問題ではありませんでした。色々と調べた結果、xargsというコマンドを使えばやりたいことが出来そうだったので、試してみました。なお、xargsとパイプ経由でcommand_iexに渡せたのはコマンドの実行結果だけでした。

$ ls | xargs ./command_iex -c 'Enum.map(lst, &(&1 <> "_test"))'
["README.md_test", "_build_test", "build.sh_test", "command_iex_test",
 "lib_test", "mix.exs_test", "test_test"]

なんということでしょう!
lsコマンドの実行結果の値を引数としてcommand_iexに渡されて、値がlstに束縛され、評価され、実行されました。まさに今回やりたかったことはこれです。試しにlsの実行結果から.mdファイルだけを抽出してみます。

$ ls | xargs ./command_iex -c 'Enum.filter(lst, &(String.contains?(&1, ".md")))'
["README.md"]

やりました!!

PATHを通してどこからでも使えるようにする

このままだと/command_iexディレクトリの直下でしか実行出来ないので、どこからでも呼び出せるように自作コマンドとして登録します。私の環境はMacだったので、こちらの記事を参考に自作コマンドciex(command line iexの略)として./command_iexを呼び出すようにしました。

wemo.tech

これでciexが呼びされるようになり、どこからでも実行出来るようになりました。
(解決出来ていないエラーが出るので、お分かりの方がいましたら、ぜひPRを...🙇‍♂️).

$ ciex
** (FunctionClauseError) no function clause matching in CommandIex.eval_string/1

    The following arguments were given to CommandIex.eval_string/1:

        # 1
        {:error, "", []}

    (command_iex 0.1.0) lib/command_iex.ex:15: CommandIex.eval_string/1
    (command_iex 0.1.0) lib/command_iex.ex:10: CommandIex.main/1
    (elixir 1.11.4) lib/kernel/cli.ex:124: anonymous fn/3 in Kernel.CLI.exec_fun/2

先程のコマンドを実行出来ることを確認しました。

$ ls | xargs ciex -c 'Enum.filter(lst, &(String.contains?(&1, ".md")))'
["README.md"]

文字列として受け取れば、次のパイプにつなげることも出来ます。

$ ls | xargs ciex -c 'Enum.filter(lst, &(String.contains?(&1, ".md"))) |> Enum.at(0)'
"README.md"
$ ls | xargs ciex -c 'Enum.filter(lst, &(String.contains?(&1, ".md"))) |> Enum.atgs echo "file: "
file:  README.md

やりたいことが出来ました!

f:id:takamizawa46:20210604224157j:plain:w200