Tsubatoの発信記録

主に機械学習やデータサイエンス関連で学んだことを書いています。

pytestでテスト入力をテスト関数間で共有する

0. この投稿の概要

  • クラスメソッドをテストする時に、同じ入力を異なるメソッドのテストで使いまわしたいと思うことがあったので、pytestでの実現方法を調べてみました。
  • 今回はfixtureとparametrizeを組み合わせた実装を紹介します。多分色々方法はあるかと思いますので、一例として参考になればと思います。

1. 実装例

テスト対象のクラス

  • 初期化時に変数を2つ受け取り、メソッドで足し算や掛け算結果を返すという単純なクラスです。
import pytest

class Calculator:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def add(self):
        return self.a + self.b

    def mul(self):
        return self.a * self.b

    def add_c(self, c):
        return self.a + self.b + c

パターン1: 1つのテスト関数で複数の関数をテストする。

  • parametrizeに複数の関数の期待値を記載する方法です。
  • 最も単純なやり方ではありますが、柔軟性がないのでadd_cのように別途引数がある場合などでは他の関数に無駄なテストが追加されてしまいます。
  • 今回は引数も期待値も整数型なので問題にはなりませんが、これがリストや辞書など大きな入力になると視認性も悪くなりそうです。
@pytest.mark.parametrize(
    ("a", "b", "add_expected", "mul_expected"),
    [(1, 2, 3, 2), (3, 4, 7, 12)],
)
def test_all(a, b, add_expected, mul_expected):
    assert add_expected == Calculator(a, b).add()
    assert mul_expected == Calculator(a, b).mul()

パターン2: parametrizeのindirectを使う。

  • parametrizeのindirectオプションを使うと、指定したパラメータの値を受け取ったfixtureを使うことができます。
  • 下の例ではfixture calc_instanceに"test1"や"test2"といった値がrequest.param経由で渡されます。それを受けてパラメータを切り替えたインスタンスを異なるテスト関数に渡すことができます。
  • パターン1よりも柔軟にテストを作成できると思います。test_add_cではfixture以外の引数cも別途指定しています。
@pytest.fixture
def calc_instance(request):
    config_dict = {"test1": {"a": 1, "b": 2}, "test2": {"a": 3, "b": 4}}
    config = config_dict[request.param]
    return Calculator(config["a"], config["b"])


@pytest.mark.parametrize(
    ("calc_instance", "expected"),
    [("test1", 3), ("test2", 7)],
    indirect=["calc_instance"],
)
def test_add(calc_instance, expected):
    assert expected == calc_instance.add()


@pytest.mark.parametrize(
    ("calc_instance", "expected"),
    [("test1", 2), ("test2", 12)],
    indirect=["calc_instance"],
)
def test_mul(calc_instance, expected):
    assert expected == calc_instance.mul()


@pytest.mark.parametrize(
    ("calc_instance", "c", "expected"),
    [("test1", 1, 4), ("test2", -1, 6)],
    indirect=["calc_instance"],
)
def test_add_c(calc_instance, c, expected):
    assert expected == calc_instance.add_c(c)

参考:
docs.pytest.org

パターン3: getfixturevalueを使う。

  • getfixturevalueにfixture名を渡せばそのfixtureが返ってきます。
  • 下の例ではfixture名"test3"や"test4"をparametrizeで渡しています。
  • 今回の簡単な例ではパターン2とパターン3ではほぼ同じような使い勝手です。もっと複雑な例では違いが出てくるかもしれません。
@pytest.fixture
def test3():
    return Calculator(1, 3)


@pytest.fixture
def test4():
    return Calculator(2, 4)


@pytest.mark.parametrize(
    ("calc_instance", "expected"),
    [("test3", 4), ("test4", 6)],
)
def test_add2(calc_instance, expected, request):
    calc = request.getfixturevalue(calc_instance)
    assert expected == calc.add()


@pytest.mark.parametrize(
    ("calc_instance", "expected"),
    [("test3", 3), ("test4", 8)],
)
def test_mul2(calc_instance, expected, request):
    calc = request.getfixturevalue(calc_instance)
    assert expected == calc.mul()


@pytest.mark.parametrize(
    ("calc_instance", "c", "expected"),
    [("test3", 1, 5), ("test4", -1, 5)],
)
def test_add_c2(calc_instance, c, expected, request):
    calc = request.getfixturevalue(calc_instance)
    assert expected == calc.add_c(c)

参考:
docs.pytest.org

読書記録 良いコード/悪いコードで学ぶ設計入門

0. この投稿の概要

  • 最近話題の「良いコード/悪いコードで学ぶ設計入門」を読んだので学んだことをまとめます。どれくらいのレベル感の内容が説明されているか知るための参考になれば幸いです。

1. 学んだこと

クラス設計の基本

  • 完全コンストラクタ: 初期化時点でインスタンス変数を指定し終え、その後はsetterなどで値を変更しない。値を更新する必要があれば新しくインスタンスを生成して返す。→思わぬ場所で値が変更されることを防ぎ、実行結果が毎回同じになるためテストもしやすくなる。
    • Pythonでも3.8からFinal型注釈を付けられるようになっています。 PEP 591 – Adding a final qualifier to typing | peps.python.org
    • ローカル変数や引数も基本的には不変にしておくことが推奨されています。可変にするのは大量のデータ処理などでパフォーマンスに影響があるケースなど。
    • getterやsetterを実装することはカプセル化ではない。
  • 値オブジェクト: 値をプリミティブ型ではなくクラスとして表現する。これにより不正な値を防ぐことができる。また、その値に関する計算も備えさせると、そのロジックが色々な場所に実装されることを防げる。
    • 基本的にはインスタンス変数を用いるメソッドをクラスに持たせ、ログ出力用のような汎用的なもの以外でstaticメソッドを避ける。
    • 目的別に異なる初期化をするためのファクトリメソッドもstaticメソッドとして実装される。
  • コレクション型についても同様にコレクションとそれを操作するメソッドをクラスにまとめる(ファーストクラスコレクション)。
    • Pythonではリストの要素の変更を禁止するメソッドは見当たりませんでした。代わりにcopyメソッドで別のオブジェクトとして返せばいいかと思います。
  • サブクラスはスーパークラスに大きく依存するため、継承は安易に使うべきではない。継承より委譲、コンポジションとして使いたいクラスをもたせると変更の影響を受けにくい。

ネストを浅くするテクニック

  • 早期リターンでif文のネストやif-else文をすっきりさせる。
    • C言語の一部の規約では関数末尾以外でのreturnを禁止されているため、自分は早期リターンに心理的な抵抗がありましたが、今後はもっと活用しようと思います。
  • 種類毎に処理を切り替えたい場合はswitch文の代わりにinterfaceを用いて実装する(ストラテジパターン)。mapから使いたいインスタンスを持ってきて、共通のメソッドをコールする。
  • 複数の条件をチェックしたい場合はポリシーパターンを使う。これは条件判定のinterfaceを作り、それを元に具体的な条件を1つ1つ実装する。ルールを集約するクラスに必要な条件をコレクションとして持たせてfor文で全て確認する。

名前設計

  • たとえ現実世界のオブジェクトが1つだとしても、関心事や目的に応じてより具体的なクラスへ分割する。例: Userを個人認証のためにPersonalAccount、特徴表現のためにProfileとして定義する。
  • 開発中の会話の中で頻発するキーワードや形容詞はそのままコード上のクラス名などに実装する。
  • Manager, Processor, Controllerという単語をclass名に入れると巨大なクラスになりがち。具体的にやることで名付けるべき。
  • 「動詞+目的語」のメソッドに注意。この場合目的語の概念を表現するクラスを作り、そのクラスに動詞1語のメソッドを追加するとよい。

2. 総評

  • オブジェクト指向プログラミングにまだ自信のない自分としては、参考になるテクニックを見つけることができました。ただ章と章の結びつきが弱く、ストーリー性が薄いためTips集のような印象を受けました。逆に言えばどの章からでも読み始められる利点はあるのですが…
  • interfaceの理解は特に重要だと思いますが、本書のように初心者向けの本でわかりやすい説明が書かれたものは少ないように思います。言語の本は機能だけの説明になりがちですし、OOPの本でさえポリモーフィズムの説明はありますがそれが実践の場でどのように役立つのかイメージしづらいです。
  • そういった意味でJavaエンジニアの初心者には文法を学んだ次の一冊としておすすめします。初心者にとって他言語で書かれた本はとっつきにくい所ではありますが、オブジェクト指向の言語を使うのであればJavaエンジニア以外も選択肢に入れて良いと思います。

3. その他の参考書

  • 現場で役立つシステム設計の原則: 業務システムを例としたシステム設計の入門書です。言語はJavaで、データベースやUIなどについても説明されているため本書よりも範囲が広いです。本書でも紹介されていますが、業務システムを開発するなら次に読む本として丁度いいかと思います。

読書記録 ゼロから作るDeep Learning 4 強化学習編

0. この投稿の概要

  • ゼロから作るディープラーニングの新作、強化学習編を読んで学んだことをまとめます。ご存知の方が多いと思いますが、このシリーズは可能な限りライブラリを使わずにディープラーニングを実装しながら理解を深めていく構成になっています。

  • 本書のサポートページにコードは公開されているため、この投稿には特にコードを載せていません。トピックの箇条書きになって読みづらいと思いますが、購入の際の参考になれば幸いです。

1. 各章の概要

1章 バンディット問題

  • 本章では多腕バンディット問題に取り組む。これは複数の選択肢から最善策を選ぶ問題で、エージェントの行動が環境に影響を及ぼさない。
  • ε-greedyアルゴリズムによって、探索と活用の両方を行う。確率εでランダムにスロットマシンを選び、それ以外の場合はこれまで得られた報酬の平均が最も大きいスロットマシン選ぶ。
  • 報酬の計算は確率が一定の定常問題では各試行を等しく評価する標本平均で良いが、非定常問題では新しい試行に重きを置く指数移動平均を使う。

2章 マルコフ決定過程

  • 問題を解きやすくするためにマルコフ性を過程する。状態Stで行動Atを行い、報酬Rtを得て次の状態であるSt+1に遷移するというプロセスが繰り返される。本章では2マスのグリッドワールド問題に取り組む。
  • 状態価値関数: ある状態と方策が与えられ、時間経過と共に得られた報酬を割引率を考慮して和をとったもの。
  • 本章の問題では状態が2つ、行動2つでかつ決定論的な方策を過程したため、全ての状態価値関数を手計算して最適な方策を得た。

3章 ベルマン方程式

  • 決定論的ではない場合、状態価値関数を手計算で求めるのが困難になる。この場合現在の状態の価値関数と次に取りうる状態の価値関数の関係性を示すベルマン方程式を用いる。
  • 特に最適方策を考える場合は方策は決定論的になるためベルマン方程式は簡略化される。
  • ベルマン方程式により、価値の連立方程式が得られる。それを解ければ行動価値を最大にするような方策を得ることができる。
  • 本章で行動価値関数(Q関数)が導入される。これは状態価値関数の条件に行動aが追加される。この行動は方策には関係なく任意に決められる。このQ関数を最大化するようにaを選ぶと最適方策が得られる。この場合環境のモデルに依存しないで方策を求められるので、後ほど環境のモデルが未知の場合はQ関数を主に使っていくことになる。

4章 動的計画法

  • ベルマン方程式で得られた連立方程式を解く方法は状態が増えると途端に解くのが困難になる。本章では3×4のグリッドワールドの問題に取り組む。
  • 動的計画法(DP)はベルマン方程式の価値関数を推定値に置き換える。適当な初期値を与えて反復計算することで、真値に近づく反復方策評価。
  • 価値の評価と方策の改善を繰り返して最適方策を得る方策反復法(Policy Iteration)。方策の改善ではその時点での各状態の価値関数を最大にするように決定論的な方策を選ぶ。
  • 評価と改善をそれぞれ最大限行うことを繰り返す方策反復法に対して、それぞれ最小限だけ行うのが価値反復法(Value Iteration)。状態1つ毎に評価と改善を行う。評価と改善で現れる式はほとんど同じであることから、価値反復法は価値の更新式の1つにまとめられて実装もシンプルになっている。

5章 モンテカルロ法

  • DPでは環境のモデル(状態遷移関数、報酬関数)が既知であることが前提だった。モンテカルロ法はエージェントが環境とやり取りを繰り返して得た経験から価値関数を推定する。
  • 方策評価はエージェントの各ステップ毎に状態、行動、報酬を記録しておき、エピソードが終わったらそれを使って価値関数を計算する。
  • 方策制御はε-greedy法を用いる。完全なgreedyでは行動が固定されて同じ経験しか得られないため。また、方策が更新されていくため価値関数は指数移動平均を用いて得る。
  • これまでは評価と改善の対象となるターゲット方策と、エージェントが実際に行動を起こす際に使われる挙動方策を区別しないでいた。挙動方策に探索をさせ、ターゲット方策に活用させる手法を方策オフ型という。この時異なる分布からサンプルしたデータを使うため、重点サンプリングを用いる。

6章 TD法

  • モンテカルロ法はエピソードの終わりにたどり着かないと更新できないという欠点がある。TD法はサンプリングしたデータを元に価値関数を更新するが、エピソードの終わりをまたずにnステップ先の情報を使って逐次更新する。
  • 方策制御はSARSAが使われる。方策を決める際は環境モデルが必要でないQ関数を更新していく。1ステップ先の情報を用いるため、更新に必要なのはSt,At,Rt,St+1,At+1でありこれがSARSAの名前の由来になっている。方策オン型の場合は探索を行うためにε-greedy法を用いる。
  • 方策オフ型の場合は重点サンプリングを組み合わせるが、結果が不安定になりやすいという弱点がある。これを解決するのがQ学習。SARSAでは方策からサンプリングされたQ関数をターゲットに更新していたのに対し、Q学習ではQ関数の最大値をターゲットにする(方策に依存しない)ので、重点サンプリングによる補正が不要。
  • エージェントの実装方法は分布モデルとサンプルモデルがある。分布モデルでは行動の確率分布を明示的に保持するが、サンプルモデルでは分布を保持せずに行動を選択する場合はQ関数を最大化する行動をサンプリングする。

7章 ニューラルネットワークとQ学習

  • 状態と行動のサイズが大きくなるとQ関数をテーブルとして保持してそれを更新することが困難になる。これを解決するためにQ関数をディープラーニングで近似する。
  • グリッドワールドの問題をNNベースのQ学習で定式化。状態はone-hotベクトルで表し、NNは状態を入力にして行動の候補の数だけQ学習を出力する。
  • Q学習における更新のターゲット(Rt+γmaxQ(St+1,a))を教師データとする回帰問題と考えることができる。

8章 DQN

  • 取り組む問題はOpenAI Gymのカートポール。DQNでは学習を安定させるために経験再生とターゲットネットワークという技術が使われる。
  • Q学習ではエージェントの経験を使い学習を行うが、経験は前後のデータに強い相関があり学習データに偏りが生まれる。経験再生では経験を保存しておいて、そこからランダムに学習用データをサンプルすることで偏りを防ぐ。
  • DQNにおける正解ラベルであるQ学習のターゲットはQ関数の更新に伴い変動する。これでは学習が安定しないため、ターゲットの値を固定するために重みを更新するネットワークとは別にラベルを生成するためのターゲットネットワークを用意する。
  • DQNの拡張:
    • Double DQN: 学習のターゲットを2つのネットワークのQ関数を元に設定する。
    • 優先度付き経験再生: ターゲットとQ関数の差の大きさを元にサンプルする経験に優先度をつける。
    • DuelingDQN: Q関数をアドバンテージ関数+価値関数Vに分けてそれぞれを別のネットワークで学習する。

9章 方策勾配法

  • 価値関数を経由しないで方策を直接表す方策ベースの手法も存在する。報酬の総和の期待値を目的関数とし、ニューラルネットワークでモデル化した方策を勾配上昇法で最適化するのが方策勾配法。
  • 方策勾配法の改善:
    • REINFORCE: 勾配の計算時に、報酬の総和ではなくその時刻以降の報酬Gtだけを考える。それ以前の時刻の報酬は関係ないので。
    • ベースライン: 報酬Gtから予測値(ベースライン)を引いたものを使う。ベースラインには価値関数が使われることが多い。これにより、例えばカートポールが絶対に倒れるようなタイミングによる学習をスキップできる。
    • Actor-Critic: ベースラインとしてニューラルネットワークでモデル化した価値関数を用いる。価値ベースかつ方策ベースの手法。

10章 さらに先へ(興味あるトピックだけ抜粋)

  • 方策勾配法系列の発展: A2C、DDPG、PPO
  • DQN系列の発展: カテゴリカルDQN、Noisy Network、Rainbow、Agent57
  • 応用事例: NASAWS DeepRacer
  • 実システムへの適用: Sim2Real、オフライン強化学習、模倣学習

2. 感想

  • 相変わらず丁寧な作りで理解がしやすいシリーズでした。強化学習の手法を幅広く扱っていますが、300ページ程度にまとまっているのはとても素晴らしいです。ある手法の弱点を補うように別の手法が説明されていくので、各手法の関連もより明確になりました。
  • 技術は自分で使わないと身につかないのですが、強化学習は仕事でも使わないし個人開発ネタもなかなか思い浮かばないのが辛いです。いい加減強化学習についても本で同じ内容を何度も勉強し直すループから抜けねばと思っています。本書で書かれていた応用の中ではNASAWS DeepRacerが面白そうでした。

3. その他の学習ソース

  • 言うまでもなく本書はPython機械学習の経験をある程度持っている人向けです。本書を難しいと思った方は以下の本が手を出しやすいかと思います。内容としては初心者がDQNまで最短で到達するような構成になっています。扱う内容が狭い分、環境構築などの基礎も説明されています。

  • Youtubeの中ではDeepMindの講義動画がわかりやすかったです。もちろん英語ですが。

www.youtube.com

(AWS CDK)LambdaのLayerでコードを再利用する

0. この投稿の概要

  • AWS Lambdaの異なるFunction間でコードを再利用をするための仕組みであるLayerをAWS CDKでdeployします。
  • 元となるLambda Functionは以前の投稿で実装した図書館アラートシステムです。aburaku.hatenablog.com

1. Layerとは?

  • AWS Lambdaはコードをアップロードすることで、サーバの細かい設定をせずに周期実行やトリガー実行できるアプリを簡単に作成できるサービスです。
  • Lambdaの実行単位であるFunctionは基本的に独立しており、また外部ライブラリを使う場合はそれを丸ごとアップロードする必要があります。
  • Layerは異なるFunction間でコードを再利用する仕組みです。特に外部ライブラリは重くなりがちですが頻繁に更新しないので、自分のコードと切り離すことでdeployの時間を短縮することもできます。
  • 以下のgifはAWSのブログからの引用ですが理解しやすいかと思います。

2. CDKによるdeploy

  • コードはgithubに上げています。

github.com

layer = _lambda.LayerVersion(
    self,
    "Packages",
    code=_lambda.Code.from_asset(LAYER_PATH),
    description="requests module",
)

library_alert_lambda = _lambda.Function(
    self,
    "LibraryAlert",
    code=_lambda.Code.from_asset(API_PATH),
    handler="library_alert.lambda_handler",
    memory_size=512,
    timeout=core.Duration.seconds(120),
    role=lambda_role,
    layers=[layer],   # <- 追加
    **common_params,
)
  • CDKのLayerVesionの必須引数はcodeだけです。pip install requests -t ./packages/pythonのようにtargetオプションをつけてパッケージを指定した場所にインストールをして、その場所をcodeに指定します。
  • この際気をつけるのはpythonというフォルダの下に配置することです。こうしておくとパスが通るのでLambda側で特にパスを追加しなくてもimportが可能になります。

参考:Lambda レイヤーの作成と共有 - AWS Lambda

  • Function側の変更はlayers引数に追加したいlayerをリストで渡してやるだけです。

3. 結果

  • 動作自体は元のコードと変化はしませんが、GUIでLayerを追加したFunctionを見に行くと、確かに追加されていることが確認できます。

既読のリンクを非表示にするchrome拡張機能を作る

0. この投稿の概要

  • マッチングアプリをやっている時に、一度見たプロフィールを一覧から非表示に出来れば何度も同じプロフィールを見ないで済むのではと考えました。
  • 既存のものが見つからなかったので、chrome拡張機能として実装しました。この投稿ではペアーズを対象にして実装例を紹介しています。(顔写真を隠すのが面倒なので、実行例は載せていません…)

1. 機能の概要

自分が実装した拡張機能は以下のような構成になっています。今回初めて拡張機能を作ったので、私の構成は最適ではないと思いますが参考まで。

  • google chrome拡張機能アイコンをクリックすることで機能が動きます。
  • まずbackground.jsがクリックイベントに反応して実行され、chromeの閲覧履歴から対象のURLを取得してlocal storageに保存、その後content.jsを実行します。*1
  • content.jsでアクティブなタブのhtmlから特定のclassを持つリンクを抽出し、それが閲覧履歴に含まれる場合はdisplayプロパティをnoneにすることで非表示にします。

2. 実装例

フォルダ構成

  • 以下の通りになっています。必要なコードは全部載せているので、コピーをして同様の構成を作り、chromeで読み込めば機能を使えます。読み込み方についてはchrome拡張機能のチュートリアルを参考にしてください。
  • イコン画像は何でもいいですが、私は同ページからダウンロードした画像をそのまま使っています。

.
├── background.js
├── content.js
├── images
│ ├── get_started128.png
│ ├── get_started16.png
│ ├── get_started32.png
│ └── get_started48.png
└── manifest.json

manifest.json

  • 拡張機能の設定ファイルみたいなもので、必須のファイルです。拡張機能の名前や呼び出すスクリプト、アイコン画像などを設定します。
  • 特に重要なのはpermissionです。ここで拡張機能がアクセスできる資源を指定しています。今回は閲覧履歴を取得するためのhistory、閲覧履歴を保存するためのstorage、webページを操作するためのactiveTab、スクリプトをページに追加するためのscriptingを許可しています。
{
  "name": "Hide read link",
  "description": "Hide links from web page if you have already read them.",
  "version": "1.0",
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js"
  },
  "permissions": ["storage", "activeTab", "scripting", "history"],
  "action": {
    "default_title": "Click",
    "default_icon": {
      "16": "/images/get_started16.png",
      "32": "/images/get_started32.png",
      "48": "/images/get_started48.png",
      "128": "/images/get_started128.png"
    }
  },
  "icons": {
    "16": "/images/get_started16.png",
    "32": "/images/get_started32.png",
    "48": "/images/get_started48.png",
    "128": "/images/get_started128.png"
  }
}

background.js

let microseconds3Days = 1000 * 60 * 60 * 24 * 3;
var threeDaysAgo = (new Date).getTime() - microseconds3Days;
// Execute when the icon is clicked.
chrome.action.onClicked.addListener((tab) => {
  storeHistory(tab);
});

function storeHistory(tab) {
  chrome.history.search({
    "text": "https://pairs.lv/user/profile/",
    "startTime": threeDaysAgo,
    "maxResults": 10000,
  },
  function(historyItems) {
    let historyList = [];
    for (let i = 0; i < historyItems.length; ++i) {
      let url = historyItems[i].url;
      let url_split = url.split("/");
      // Store only profile ID
      historyList.push(url_split[url_split.length-1]);
    };
    chrome.storage.local.set({ historyList });
    chrome.scripting.executeScript({
      target: {tabId: tab.id},
      files: ['content.js'],
    });
  });
};
  • 拡張機能のアイコンをクリックするとchrome.action.onClicked.addListenerで指定した関数が実行されます。
  • 今回は3日分のペアーズのプロフィール閲覧履歴をchrome.history.searchで取得しています。
  • URLは表示される場所によって変わるようなので、プロフィール固有と思われるIDをURLから抽出しています。そのリストをchrome.storage.local.setで保管しています。
  • 最後にchrome.scripting.executeScriptでcontent.jsをwebページに埋め込みます。

content.js

  • chrome.storage.local.getで保存しておいた履歴データを読み込む意外は普通のjavascriptコードです。対象リンクのクラスはchromeデベロッパーツールで確認しました。
function deleteReadLinks() {
  chrome.storage.local.get("historyList", ({ historyList }) => {
    let historySet = new Set(historyList)
    let profiles = document.getElementsByClassName("css-opde7s");
    for (let i = 0; i < profiles.length; ++i) {
      if ("href" in profiles[i]) {
        let url_split = profiles[i].href.split("/");
        if (historySet.has(url_split[url_split.length-1])) {
          profiles[i].style.display = "none";
        }
      }
    };
  });
}
deleteReadLinks();

3. 参考

*1:わざわざ別スクリプトを呼んでいるのは、何故かbackground.jsから直接webページを操作できなかったため。

図書館APIで新刊の入荷をいち早く知る(AWS CDKで実装)

0. 簡単なまとめ

  • 図書館に自分が読みたい新刊が入荷された時に通知するシステムをAWSに実装。
  • コードは下記のリンク参照。AWSのCDKを使ったのでコードを動かすだけで簡単に同じ環境を準備できるはず。

    github.com

     

1. やりたいこと

  • 図書館に自分が読みたい新刊が入荷されたことをいち早く知り予約することで、待たずに新刊を読めるようになりたい。
  • 定期的に自動で入荷状況を調べる必要がある。金を使わないために図書館を使っているので、当然このシステムを稼働させるのに金を使いたくない。

2. 実現方法

Lambda
Lambda
Amazon API...
Amazon
DynamoDB
Amazon...
Amazon EventBridge
Amazon EventB...
Lambda
Lambda
メール通知本や図書館の編集定期実行図書館API
Viewer does not support full SVG 1.1

3. 仕様と実装

  • コードは下記リンクに置いてます。今回はAWS  CDKなる、コードでAWSのリソースの準備を行えるツールを使って実装しました。いわゆるInfrastructure as Codeというやつですね。言語はPython3です。

    GitHub - tsubasawb/LibraryAlert

  • 主要コンポーネントの役割
    • 図書館API
      • 本のISBNと図書館のIDを渡すと本の貸し出し状況を返す。
    • API Gateway
      • 以下のAPIを用意している。
        機能
        エンドポイント
        HTTPメソッド
        全ステータス取得
        /
        GET
        本を新規追加
        /books
        POST
        図書館を新規追加
        /libraries
        POST
        本の削除
        /books/<isbn>
        DELETE
        図書館の削除
        /libraries/<id>
        DELETE
      • いちいちAPIを叩くのは面倒なので、Vue.3を利用してwebページ経由でAPIを叩くようにした。どうせ自分しか使わないので、index.htmlはローカルに置いてある。
    • EventBridge
      • Lambdaを定期的に実行するトリガーを出力する。今回は1時間に1回実行するよう設定した。
    • Lambda
      • API: APIの数だけLambda Functionを用意している。API Gatewayからのリクエストをトリガーに動作して、DynamoDBの操作を行う。
      • 定期実行: メインの関数。DBに登録されている図書館と本の全ペアの入荷状況を図書館APIで調べ、アップデートがある場合はDBを更新してメールで通知する。
    • DynamoDB
      • 監視対象の図書館、本とそれらの入荷状況を持つ。図書館APIでは貸し出し状況は細かく分類されているが、今回は本が入荷されているかどうかだけを考慮した。
  •  その他のコンポーネント
    • Lambdaがメールアドレスやアプリケーションキーのような機密情報にアクセスする必要がある。コードに埋め込みたくないので、SSMのParameter Storeを利用した。
    • Lambdaの定期実行で異常が発生していないかウォッチするためにCloudWatch Logsを利用。Lambda実行時のエラーを検知して、Amazon SNSで登録しておいたメールアドレスに通知する。
    • デフォルトの設定ではURLを知っていたら誰でもAPIにアクセスできてしまうので、API使用量とAPIキーを使ってアクセスを制限する。

4. 使い方

  • 環境構築
    • AWSの登録など
      • AWSアカウント取得、シークレットキー作成、CDKのインストールが必要です。このページが参考になります。

        コードで学ぶAWS入門

    • googleのアプリパスワード設定
    • 図書館APIアプリケーションキー登録
    • 環境変数の設定
      • cloneしたディレクトリ内に.envファイルを作成し、下記の通りgmailアドレス、googleアプリパスワード、図書館APIアプリケーションキーを設定してください。
      • EMAIL = "your mail address@gmail.com"
        GOOGLE_PASS = "your password"
        APP_KEY = "your application key"
        
    • cdk deployの実行
      • python3 -m pip install -r requirements.txtで必要なモジュールをインストールして、cdk deployを実行するとAWSのサービスが立ち上がります。
      • 環境を削除したい場合はcdk destroyを実行してください。
    • エンドポイントとAPIキーの登録
      • cdk deployが完了後にLibraryAlert.LibraryAlertApiEndpointXXX = https://XXXX.amazonaws.com/prod/という表示がコンソールに現れます。このURLがエンドポイントです。
      • APIキーはAmazon API GatewayGUI上で確認します。作成されたAPI(LibraryAlertApi)の下記の場所でAPIキーを表示できます。

      • gui/config.jsというファイルを作成して、以下のようにエンドポイントとAPIキーを記載します。*1
        const config = {
            endpoint: "your endpoint URL",
            apikey: "your API key",
        }
  • 図書館と本の登録
    • index.htmlをブラウザで開き、まずは図書館の登録をします。図書館のIDはカーリルの図書館マップで利用したい図書館のページから取得します。このIDは市町村内の図書館で共通なようなので、例えば"Tokyo_Machida"を指定すると町田の市立図書館全ての状況をチェックします。

      このIDをページ右上のフォームに書き、"図書館の追加"ボタンを押して更新をすると、下の表に追加した図書館が表示されます。

    • 次に本の登録をします。読みたい本のISBN-10を左上のフォームに書き、"本の追加"ボタンを押して更新すると、下の表に追加した本が表示されます。ISBN-10はamazonで確認できます。*2

    • 以降は登録した図書館と本の組み合わせ全てが表に追加されて監視対象になります。図書館や本を削除したい場合は、図書館IDやISBNをそれぞれのフォームに入れて削除ボタンを押してページを更新してください。
    • 1時間毎に起動するLambda functionで入荷状況が調べられます。もし新しく入荷があった場合は、「新刊入荷情報」というタイトルのメールが届きます。メール内容に図書館の予約ページへのリンクがあります。

5. 参考

*1:これも.envに定義した方がいいんだろうけど、インストールするものが増えるのが嫌なのでjsファイル内に定義した。

*2:ISBNに貼られているリンクはカーリルのページ。貸し出し状況を表示する場合はカーリルへのリンクを貼らなければいけないらしいので。