やわらかテック

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

【API設計】jsonを返す時はできる限りフィールドのkeyを統一してあげよう

APIの仕様書が送られてきた

外部連携させて頂く企業様から、新規に追加されるAPIの仕様書を頂きました。ユーザーが登録しているカテゴリの総数と、その内訳を取得することが出来るAPIだそうで、新たに追加されたとのことです。しかしながら、蓋を開けてみると、何とも言えない残念なレスポンスとなっていました。まずは、以下をご確認ください。

(情報は原文より書き換えてあります)

以下はユーザーが何かしらのカテゴリーを1件以上登録している場合のレスポンスになります。

{
  "total_category": 2,
  "category": [
    {
      "id": 1,
      "name": "Python"
    },
    {
      "id": 2,
      "name": "JavaScript"
    }
  ]
}

そして、こちらがユーザーが1件もカテゴリーを登録していない場合のレスポンスです。

{
  "total_category": 0
}

お分かりいただけただろうか

カテゴリーの登録があるかどうかでjsonのレスポンスのフィールドのkeyの構造に違いがあります。1件でもカテゴリーの登録がされていれば、categoryというkeyが存在して、valueにはカテゴリー情報を持つオブジェクトの配列が入っています。しかしながら、1件もカテゴリーの登録がされていなければ、categoryというkeyが存在しないのです。

発生する2つの問題

実際には2つの問題があろうとも、動くコードを記述することは出来ます。ただ、それは対応としては複雑さを生み出し、保守性と品質を低下させます。APIのレスポンスだけを修正すれば済む問題を、呼び出し元でカバーしなければならないのです。

keyが存在しない場合の挙動

例えば、フロントエンドでユーザーが登録しているカテゴリーを一覧表示しようとした場合に以下のようなコードを書きます。

const generateCategoryDOM = () => {
  const resp = getCategory(); // 架空の関数
  const category = resp.category;
  return (
    <div>
      {category.map((detail) => {
        return (
          <p>{`ID: ${detail.id}`}</p>
          <p>{`Name: ${detail.name}`}</p>
        )
      })}
    </div>
  )
}

カテゴリーの登録が1件以上ある場合には正常に動作するでしょうが、カテゴリーが1件も登録されていない場合はどうでしょうか。const category = resp.category;の評価の結果、categoryにはundefinedが束縛されてしまいます。undefinedに対してmapを呼び出すことは出来ないため、このコードはエラーを引き起こします。

const obj = { "a": 1 };
const exist = obj.a;
console.log(exist); // 1

const notExist = obj.b;
console.log(notExist); // undefined

この問題を回避するためにコードに手を加えなければなりません。無駄なコードが増えてしまいました。

const category = resp.category || [];

「なぜcategoryが存在しない場合に空配列を代入してるんだろう?」ということをコードを見る度に考える必要がある可能性があるので、このような変更はできる限りしたくありませんが、仕方ないですね😭

型定義の煩雑化

またtypescriptでレスポンスの型を定義する場合にcategory?としなければなりません。毎回、categoryが存在するかどうかを確認するコードを書かないといけなくなる可能性があります。型定義も煩雑になってしまいました。

interface CategoryResp {
    total_category: number;
    category?: CategoryDetail;
}

interface CategoryDetail {
    id: number;
    name: string;
}

あるべき姿

カテゴリーの登録がない場合にでも、categoryというフィールドを返してくれれば、上記2つの問題は発生しません。値としては空配列が適切ですね。

{
  "total_category": 0,
  "category": []
}

カテゴリーの登録がない場合にcategoryというフィールドが返ってこないというのは、感覚としては戻り値をbooleanと定義している関数から、nilが返ってくるようなものです。実際に呼び出し元では今回、紹介した問題が発生します。他にも問題はあるかもしれません。

APIというのはプロトコル経由で呼び出せる、ただの関数だ」と考えると、戻り値の型を揃えるのはごく自然なことかと思います。プリミティブな型であれば関数の戻り値の型を統一するというのが自然だと思います(言語によっては関数の戻り値の型が不一致でエラーになっているはず👀)。
jsonであっても、可能な限りレスポンスの型を統一してあげるべきです。

APIの設計については、この書籍が大変役立っています。今回紹介したレスポンスに関するテーマは第3章で扱っていますのでご参考までに。

開発者が複雑だと思う機能はユーザーには使いこなせない

プロジェクトの肥大化

自分が参加しているプロジェクトが早いもので、スタートから2年が経ちました。
ありがたいことにお客様の数は増え続けており、今でも多くのお客様に使って頂いております。初期の頃と比べると、かなり機能がリッチになりました。 元々は、「シンプルさを保っていこう!」というKISSの原則に従い、誰が見ても初見で使えるアプリを目指していました。

意味するところは、設計の単純性(簡潔性)は成功への鍵だということと、不必要な複雑性は避けるべきだ

ja.wikipedia.org

こちらの書籍でもKISSの原則が紹介されています。

しかしながら、お客様が増えると様々な声が上がってきます。

  • 〇〇という機能が欲しい!
  • 既存のこの機能をもっとカスタマイズしてほしい!
  • 社内システムと連携したい!

など...
気がつけば、シンプルさを売りにしていたことを多くの人が忘れて、この提案された機能、仕様の実装に取り掛かってしまいます。自分はスクラムマスターなので、「いやいや、この機能はコンセプトからずれている」だとか「仕様が複雑すぎて使いこなせないよ」という話を提言したりします。

しかしながら、声の大きなお客様(大きな企業の...)に対しては自分の発言は無力な時があるのです。

欲しいと言われた複雑な機能を作った結果

どうしてもほしい!と言われたので対応をしました。
結果的には要望元のお客様が使いこなせない、設定を代行し、どの設定の時に、どういう動作をするのかを把握できる人物がほとんどいないという状態になりました。また、他のお客様には不要な機能であるため、使って頂けず、UIを複雑化させてしまいました。

使われていない AND 複雑なため取り除きたい機能ではありますが、それでも要望元のお客様が使っている以上、気軽に取り除くことも出来ないという最悪の状態になってしまいました。この複雑な機能を保守していかなければならないのです。

今回の教訓

実装時に開発者(エンジニア)から、「こんな複雑なもの使いこなせるんですかね」という声が上がっていました。自分もそう思います。しかしながら、上からの圧に負けて「GO!」の判断をしてしまいました。

「開発者が複雑だと思う機能は当然、ユーザーにも使いこなせないのだ」ということを改めて学びました。あるべき姿、ストーリーを把握している我々が、本当に必要かどうかを判断しないといけません。言わば、最終防衛ラインです。プロジェクトを間違った方向に向かわないようにコントロールしてあげなければいけません。

スクラムでは実際に作業する人たちが見積りをする。作業の量を見積もることが、自分たちの作業について考える良い機会にもなるんだ。

対策法

そのためにもプロジェクトがどういう方向に向かっているのか、強みは何か、ターゲットはどの層なのかをチームで再定義すると良いというアドバイスをメンターの方から頂きました。いわゆるインセプションデッキというものだそうです。

qiita.com

インセプションデッキを用意することで、「いや、その機能はコンセプトからずれてる」など発言がしやすくなりますし、インセプションデッキに記載されている以上、妥協することも難しくなるとのことです。

スクラムマスターとしてプロジェクトが正しい方向に向かうように導くのは簡単な事ではありませんが、今回の教訓を元に認識を改めようと思いました。

【JavaScriptサンプル有り】例外が発生した後の処理を高階関数にまかせる

これは便利

こちらの書籍で紹介されていた、リファクタリングの手法の1つで、例外が発生した後の処理って場面によって何をしたいか違うよねを叶えるためのコードです。例外が発生した(された)まま、処理を終了したい、ハンドリングして、値を返したいという異なる要望を記述することが出来ます。

const campMember = ["りん", "なでしこ"];
const campValidation = (memberName, unexpectExec) => {
    if (campMember.includes(memberName)) {
        return true
    } else {
        return unexpectExec();
    }
}
const debug = (memberName, unexpectExec = () => { throw 'メンバーではありません' }) => {
    const result = campValidation(memberName, unexpectExec);
    console.log(result);
}

実行サンプル

debug("りん", () => { return false })
# true
debug("なでしこ", () => { return false })
# true
debug("はなこ", () => { return false })
# false
debug("はなこ", () => { throw '残念ながらメンバーではありません' })

// debug("はなこ", () => { throw '残念ながらメンバーではありません' })
//                      ^
// 残念ながらメンバーではありません
// (Use `node --trace-uncaught ...` to show where the exception was thrown)

ボーイスカウト・ルールが保守開発に役立った話

コードはどんどん汚くなっていく

プロジェクトのスタート時にどれだけ入念に設計をしてコードを書き始めたとしても、プロジェクトの年数が経つにつれて、コードの状態は悪くなってきます。

  • 新機能追加
  • 既存仕様の変更
  • メンバーの入れ替わり
  • リリース日が変更出来ないため、テストを書く時間がない

などなど...
コードの状態が悪くなっていく原因は多岐にわたります。

つまり、何も考えずに開発を続けていくと、気づいた時にはコードは汚れ切った状態になっている可能性があります。この状態となってしまったコードを元の綺麗な状態に戻すのは非常に困難ですし、時間がかかります。また、新機能追加や仕様変更をしていくのも初期の状態と比べると非常に難しく、多くの時間を必要とすることでしょう。

では、プロジェクトの偉い人に相談をして「コードをリファクタングしたい」と伝えたところで、その願望が叶えられることはあるのでしょうか。残念ながら、それはほとんどありません。偉い人がエンジニア畑の出身で理解がある場合は可能性があるかもしれませんが、基本的にはリファクタングはユーザーには直接的には何の利益もないので、了承を得ることは難しいです。

どうすればいいのか

こちらの書籍を読んでいる中で、ボーイスカウト・ルールというものが紹介されていました。

xn--97-273ae6a4irb6e2hsoiozc2g4b8082p.com

(実は今だけ全て無料で公開されているので、読めちゃいます👀)

ボーイスカウトには大切なルールがあります。それは、「来た時よりも美しく」です。たとえ自分が来た時にキャンプ場が汚くなっていたとしても、そしてたとえ汚したのが自分でなかったとしても、綺麗にしてからその場を去る、というルールです。

つまり、何かコードに手を加える時に、過去に書かれたコードで以下のような条件に当てはまるのであれば、リファクタリングしてあげます。

  • 何かしら気になる箇所
  • TODOコメントが記述されている箇所
  • テストが十分に書かれていない箇所
  • 処理が複雑な箇所
  • 通化されていない箇所

などなど...

少しずつでもコードを綺麗な状態にしていければ、やがて、塵も積もれば山となるの如し、コードの状態はどんどん良くなっていきます。リファクタリングの許可を偉い人に取らずとも、普段の業務に+αでボーイスカウト・ルールを意識すれば良いのです。リファクタリングは長期的に時間を取って、行うよりも、コツコツと実行していくのが良いとこちらの書籍でも言及されていました。

少しずつであっても改善をしていくことには価値があります。キャンプの古い格言に、キャンプ場を去るときは、来たときよりもきれいにして帰ろうというのがあります。駄目なコードを見かけるたびに少しずつ改善をしていけば、やがて問題はなくなっていくでしょう。

実際にチームにボーイスカウト・ルールを取り入れてみた感想

新機能追加のPRやバグFIXを行う際に、過去に記述されたコードで気になった箇所があれば、少しで良いので、手が加えられそうであれば、修正をしていきましょう!とお願いをしました。まず「過去のコードに手を加えて良い』ということに対して、メンバーが積極的ではなかったことに気づきました。本番環境で問題なく稼働しているコードに手を加えることに、抵抗があったようです。(自分も含めて).

始めた当初は関数名や変数名の更新程度でしたが、次第に各所に存在していた重複する関数がモジュールになって共通化されたり、テストケースの追加など、リファクタリングの規模が大きくなってきました。

このまま続けていけば、現状、我々が抱えている、新機能の追加がしにくいと問題は解決されそうです🙇‍♂️

ターミナルのパイプの中に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