デコレータで例外処理を共通化する[Python]

業務でSaas機械学習サービスを開発しているのですが、そこである決済サービスを使用しています。

その決済サービスのライブラリ(Python)を利用していると、ライブラリで定義されたExceptionが返ってくるのですが、

ライブラリ定義のExceptionに対して、同じような例外処理を業務コードのあちこちに書かないといけなく、とても冗長でした。

そこでPythonデコレータを使って例外処理を共通化したので、今回はそのことについての記事を少し書きます。

決済サービスの話はマイナーケースだとは思いますが、サービス開発する上での例外処理を共通化したい場面はあると思うので参考になれば幸いです。

例外処理を共通化をしない場合

Pythonで普通に例外処理を書こうとするとこのようになると思います。

ログ処理やAPIでHttpExceptionを投げないといけない時などはかなり冗長になってしまいます。

例外処理だけでなくtryの中の処理もそれぞれ同じ処理内容だとメソッドを切って共通化するだけで終わりますが、

例外処理だけ共通化したい場合は工夫が必要です。

def pay(product: Product):
    try:
        ...
    except HogeHogeException as e
        ...
    except FugaFugaException as e
        ...
    except Exception as e:
        logging.critical(e)
    ...
    return ...


def subscribe_to(product: Product):
    try:
        ...
    except HogeHogeException as e
        ...
    except FugaFugaException as e
        ...
    except Exception as e:
        logging.critical(e)
    ...
    return ...

デコレータを使って例外処理を共通化した場合

デコレータを使うと上のコードを以下のように記述することができます。

例外処理を一箇所にまとめて切り出すことができました。例外処理に修正が入る場合も簡単に修正できそうです。

def payment_exception(func):
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except HogeHogeException as e
            ...
        except FugaFugaException as e
            ...
        except Exception as e:
            ...

    return wrapper


@payment_exception
def pay(product: Product):
    ...
    return ...


@payment_exception
def subscribe_to(product: Product):
    ...
    return ...

ちなみに、デコレータとは引数として関数を受け取り、別の関数を返す関数のことです。

デコレータはシンタックスシュガー@payment_exceptionと簡単に書くことができていますが

pay = payment_exception(pay)と動作的に等価です。

終わりに

今回はデコレータによる例外処理の共通化を行いましたが、例外処理だけでなく、ロギングなどいろいろな処理の共通化できるので試してみてください。

サクッとWSGI・ASGIに触れてみる

普段FlaskやFastAPIなどのpythonフレームワークを使っている方は、起動時のメッセージやエラーメッセージなどでWSGIやASGIという言葉をよく目にすることがあると思います。でもフレームワークを使っているだけではWSGIやASGIについてあまり意識する必要はありません。私も言葉だけ知ってるだけで何者かよく知らない状態だったので、今回調べてことにしました。

WSGIとは

WSGIとはWeb Server Gateway Interfaceの略で、Pythonにおいて、WebサーバとWebアプリケーションが通信するための、標準化されたインタフェース定義のことです。アプリケーション(またはフレームワークやツールキット)がWSGI仕様で書かれていれば、WSGI をサポートするサーバ(gunicornなど)上であればどこでも動作させることができます。
サーバ側とアプリケーション側の両方のインタフェース定義については、PEP 3333で規定されています。

WSGIに沿ったアプリケーションの実装

よりWSGIを理解するためにWSGIアプリケーションを実装したいと思います。 WSGIアプリケーションは、呼び出し可能オブジェクトとして実装されます。(関数・クラス・object.call()メソッドを持つインスタンスなどが呼び出し可能オブジェクトです。) これはリクエストを受けてレスポンスを返す単一の同期呼び出し可能なもので、非同期処理やWebSocketのようなプロトコルは許可されていません。

WSGIアプリケーションについて以下のことが定義されてます。

  1. 次の2つの引数を持つ
    • 環境変数を含む辞書(environ)
    • HTTP ステータスコード/メッセージと HTTP ヘッダをサーバに送信するためにアプリケーションが使用するコールバック関数(start_response)
  2. ResposeBodyをイテレータブルな文字列としてサーバに返す

上の定義にしたがって、環境変数(environ)を羅列したtextを返すだけのWSGIアプリケーションを簡単に実装してみました。

#app.py
from wsgiref.simple_server import make_server

# WSGIアプリケーションを作成(今回は関数)
# 環境変数environとコールバック関数start_responseの二つの引数を持つ
def application(environ, start_response):

    response_body = [
        '%s: %s' % (key, value) for key, value in sorted(environ.items())
    ]
    response_body = '\n'.join(response_body)

    status = '200 OK'
    response_headers = [
        ('Content-Type', 'text/plain'),
        ('Content-Length', str(len(response_body)))
    ]
    start_response(status, response_headers)

    return [response_body.encode('utf-8')]

# applicationへのコネクションを受け付けるWSGIサーバを作成。戻り値はserver_classのインスタンス
httpd = make_server(
    'localhost', # ホストネーム
    8051, # ポート番号
    application # 上で実装しているapplication classを使ってリクエストを処理
)

# 一つのリクエストだけ処理します。killするまでずっと立たせる場合はserve_foreverを使います。
httpd.handle_request()

作成したapp.pyを実行して Postmanなどを利用してhttp://localhost:8051/にGETリクエストを送信してみます。
以下のような環境変数が羅列されたテキストが返ってきたら成功です。

f:id:okiyasi:20200809230046p:plain

Pythonの標準モジュールであるwsgirefを使用してWEBサーバを立てましたが、WSGIに則っているのでもちろんgunicornなどを使ってもアプリケーションを動作させることができます。 より本格的なフレームワークやアプリケーションを作成したい場合にはもっと細かい仕様について理解する必要があります。詳しくはPEP 3333をご覧ください。

ASGIとは

ASGIとはAsynchronous Server Gateway Interfaceの略です。WSGIの精神的な後継仕様であり、asyncioを介して非同期で動作するように設計されていて、またWebSocketなど複数のプロトコルをサポートしています。 Djangoの開発組織がASGIのドキュメントを管理しており、2020年8月現在、ASGIはまだPEP化はされてないようです。 DjangoやFastapiがASGIに対応しており、非同期処理を簡単に実装できます。

ASGIに沿ったアプリケーションの実装

WSGIと同じようにASGIアプリケーションを実装していきます。

ASGIアプリケーションでは以下のことが定義されています。

  1. 次の3つの引数を持つ
    • 受信したリクエストに関する情報を含む辞書(scope)
    • ASGI イベントメッセージを受信するために使用される非同期関数(receive)
    • ASGI イベントメッセージを送信するために使用する非同期関数(send)

上の定義にしたがって、実装していきます。 ここでは時間を測るTimingMiddlewareというものでappをラップしています。 また、starletteというASGI frameworkを使用します。starletteを使うことで簡潔にASGIアプリケーションを実装することができます。

# app.py
import asyncio
from starlette.responses import PlainTextResponse
import time


class TimingMiddleware:
    def __init__(self, application):
        self.application = application

    # ちゃんとsleepされているか検証
    async def __call__(self, scope, receive, send):
        start_time = time.time()
        await self.application(scope, receive, send)
        end_time = time.time()
        print(f"Took {end_time - start_time:.2f} seconds")


async def application(scope, receive, send):
    await asyncio.sleep(3)
    response_body = [
        '%s: %s' % (key, value) for key, value in sorted(scope.items())
    ]
    response_body = '\n'.join(response_body)
    response = PlainTextResponse(response_body)
    await response(scope, receive, send)


application = TimingMiddleware(application)

今回はASGIサーバとしてuvicornを使用します。uvicornをインストールしてapp.pyがあるディレクトリでuvicorn app:applicationを実行します。するとASGIサーバーが立ち上がりASGIアプリケーションが動作します。
WSGIの場合と同様にPostmanを使ってhttp://localhost:8000/GETリクエストを送信します。3秒待った後にtextが返ってきたら成功です。

f:id:okiyasi:20200810181927p:plain
コンソールには次のように出力されているはずです。
f:id:okiyasi:20200810182638p:plain:w600

ASGIについてより詳しい仕様を知りたい方は公式ドキュメンテーションをご覧ください。

まとめ

今回はWSGIとASGIの簡単なアプリケーションを実装しました。 いつもはあまり意識しないWSGIとASGIについて少しは知れたかと思います。Django version3になってASGIに対応したり、Fastapiが少しずつ人気になってきてたりフレームワークのASGIへの移行はトレンドになっているので今後も注目していきたいですね。

参考

wsgiref --- WSGI ユーティリティとリファレンス実装 — Python 3.9.3 ドキュメント Introduction — WSGI Tutorial ASGI Documentation — ASGI 3.0 documentation

Lambda+Aurora serverless+WebhookでTeamsに毎日analyticsを通知するようにしてみた

社内で新しいサービスがローンチしたのですが、どれぐらい伸びてるか毎日統計情報を取りたいとなり、LambdaでAurora Serverlessに接続しSQLを発行して分析し、その結果をWebhookでTeamsに通知するようなものを作ったので作り方を記事にまとめてみます。

以下の手順で作成していきます。

  1. Temasチャンネルにwebhook用コネクタを追加
  2. Aurora serverlessのData API設定
  3. Lambda関数作成
  4. CloudWatch Eventsをトリガーに追加
  5. チャンネルに通知

Temasチャンネルにwebhook用コネクタを追加

通知をしたいTeamsチャンネルの右上の3点ボタンをクリックし、メニューを表示して「コネクタ」を選択します。

f:id:okiyasi:20200802231435p:plain:w200
コネクタの中から「Incoming Webhook」を探し「構成」をクリックします。すると以下のような画面が出てくるので適当な名前を入力して「作成」でWebhookを作成します。
f:id:okiyasi:20200802232108p:plain:w600

作成するとこのようにWebhookのURLが表示されるのでこのURLをコピーしておきます。(後に使うのでどこかに保存しておいてください。)

f:id:okiyasi:20200802232602p:plain:w600

Aurora serverlessのData API設定

Aurora Serverlessとは(簡単に)

Aurora ServerlessはAuroraをオンデマンドで自動スケーリングしてくれるようなもので、一定時間リクエストがないときはDBインスタンスが停止状態になり、逆に負荷が高まったりした時は自動でスケーリングしてくれるので普通のAuroraに比べてとてもコストパフォーマンスが高いです。まだユーザーの少ない新規サービスや開発環境などの場合はServerlessを使うとAuroraに比べてかなりコストを抑えられます。Auroraへの移行も比較的簡単です。

Data APIとは(簡単に)

Data APIはAurora Serverless の エンドポイントとして機能するもので、Lambdaなどを使用する場合にはAurora Serverlessに直接接続せずに、Data APIを介して接続することでRDBの最大接続数の問題などを回避することができたり、VPC内にLambdaを設置しなくてもAurora Serverlessに接続できたりするのでとても便利な機能になります。

設定するには

AWSのコンソールで接続したいAurora Serverlessのクラスターを選択して「変更」を行います。変更の中の「ネットワーク & セキュリティ」という箇所があるのでその中にわかりにくく、Data APIチェックボックスがあるのでそれをオンにして、変更を適用して終わりです。設定するとシークレットマネージャーに自動で接続情報が登録されます。
MySQL Version 5.6以上・PostgreSQL Version 10.7以上と互換性のあるAuroraで使用可能です ※2020年8月時点)

f:id:okiyasi:20200803001012p:plain

Lambda関数作成

次にLambda関数を作成します。 まず通常のコンソール画面から、Lambdaデフォルトのポリシーがアタッチされたロールを付与されている新しい関数を作成します。その次にIAMロールの編集を行います。

ポリシーの作成

Data APIに接続するためのポリシーの作成を行います。
ポリシーについてはこののクラスメソッドさんの記事を参考に作成しました。 (※ちなみに2020年7月時点ではもうLayerはなくてもData APIは叩けました)
https://dev.classmethod.jp/articles/aurora-sl-dataapi-with-lambda-layer/

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "SecretsManagerDbCredentialsAccess",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:GetSecretValue",
                "secretsmanager:PutResourcePolicy",
                "secretsmanager:PutSecretValue",
                "secretsmanager:DeleteSecret",
                "secretsmanager:DescribeSecret",
                "secretsmanager:TagResource"
            ],
            "Resource": [
                "arn:aws:secretsmanager:*:*:secret:rds-db-credentials/*",
                "--ここに自動作成されたのシークレットマネージャーのarnを記入(シークレットマネージャーのコンソールに載っている)--"
            ]
        },
        {
            "Sid": "RDSDataServiceAccess",
            "Effect": "Allow",
            "Action": [
                "dbqms:CreateFavoriteQuery",
                "dbqms:DescribeFavoriteQueries",
                "dbqms:UpdateFavoriteQuery",
                "dbqms:DeleteFavoriteQueries",
                "dbqms:GetQueryString",
                "dbqms:CreateQueryHistory",
                "dbqms:DescribeQueryHistory",
                "dbqms:UpdateQueryHistory",
                "dbqms:DeleteQueryHistory",
                "dbqms:DescribeQueryHistory",
                "rds-data:ExecuteSql",
                "rds-data:ExecuteStatement",
                "rds-data:BatchExecuteStatement",
                "rds-data:BeginTransaction",
                "rds-data:CommitTransaction",
                "rds-data:RollbackTransaction",
                "secretsmanager:CreateSecret",
                "secretsmanager:ListSecrets",
                "secretsmanager:GetRandomPassword",
                "tag:GetResources"
            ],
            "Resource": "*"
        }
    ]
}

このポリシーを作成したLambdaのロールにアタッチします。

Lambdaのコードを作成

今回はzipで外部ライブラリをLambdaに持ってくるとかの説明は省きたいので、pythonの標準ライブラリだけでできるような、ユーザーの数だけを取得して通知する簡単なコードを紹介します。

import json
import boto3
import urllib
from datetime import date

def lambda_handler(event, context):

    rdsData = boto3.client('rds-data')

    # Aurora ServerlessのクラスターのARN(RDSのコンソールから取得できる)
    cluster_arn = 'arn:aws:rds〜〜〜〜〜'
    # シークレットマネージャーのARN
    secret_arn = 'arn:aws:secretsmanager〜〜〜〜〜'

    response = rdsData.execute_statement(
                resourceArn = cluster_arn, 
                secretArn = secret_arn, 
                database = '自分の作ったデータベースの名前', 
                # ユーザーの数を取得するだけのSQL
                sql = 'select count(*) from user情報のテーブル名') 

    user_number = response['records'][0][0]['longValue']
    today = date.today().strftime('%Y年%m月%d日')
    message = f'{today}の累計ユーザー数:{user_number}人'
    
    print(message)
    
    # 1で作成したwebhookのURL
    webhook_url = 'https://outlook.office.com〜〜〜〜〜〜'

    data = {
        "text": message,
    }
    headers = {'Content-Type': 'application/json'}
    request = urllib.request.Request(
        webhook_url,
        json.dumps(data).encode("utf-8"), 
        headers
    )
    with urllib.request.urlopen(request) as response:
        response_body = response.read().decode("utf-8")

Lambdaで外部ライブラリを使ってもっと複雑なことをしたい場合はこちらの記事をお読みください。 【AWS・Lambda】Python外部ライブラリ読み込み方法 - Qiita

CloudWatch Eventsをトリガーに追加

Lambdaの画面にある「デザイナー」の中の「トリガーを追加」を選択し、トリガーを選択で「Event Bridge(CloudWatch Events)」を選択します。

f:id:okiyasi:20200803011039p:plain:w300
ルールで「新規のルールの作成」を選び名前を記入し、ルールタイプはスケジュールタイプを選択します。
f:id:okiyasi:20200803012009p:plain
スケジュール式にcron(0 0 ? * MON-FRI *)と記入することによって平日のUTC時間の午前0時0分にLambdaを実行することが可能です。 cron式については詳しくはこちらをご覧ください。 docs.aws.amazon.com 最後に「追加」を押すとトリガーの追加が完了します。

テスト

以上の作業でTeamsのチャンネルに毎日通知を行うことが可能になりました! ちゃんとできてるかUTCの午前0時0分まで待てないので一旦Lambdaのテストを使って、通知を実施してみます。今回はパラメータは使わないのでデフォルトのhello worldのテストをそのまま使ってテストをすると以下のように指定したチャンネルに通知がくると思います。

f:id:okiyasi:20200803013403p:plain

Pythonの特殊メソッド__new__とは

特殊メソッド __new__について

Pythonには、「特殊メソッド」と呼ばれるものが存在します。
Pythonではこの特殊メソッドをオーバーライドし使用することで、自分が定義したクラスのインスタンスの振る舞いを細かく調整することが可能です。 その特殊メソッドの一つが__new__です。
__new__はインスタンスの生成時に呼ばれる特殊メソッドです。 他にも、インスタンスに関わる特殊メソッドとして、インスタンスの初期化時に呼ばれる私たちに馴染深い__init__メソッド、インスタンスの破壊時に呼ばれる__del__メソッドがあります。
__new__を考える上で 切り離せないのが特殊メソッド__init__です。__init__と__new__の役割の違いを見ていきましょう。

__new__と __init__の違い

__init__はどちらかというと他の言語(Javaなど)のコンストラクタ的なものなので、 __init__が呼び出されるとオブジェクトの生成+初期化が行われていると思われがちですが、Pythonの場合はオブジェクトの生成はnewメソッドで公開/カスタマイズされるもので、 __init__の役割はあくまで初期化です。(Javaなどはコンストラクタが呼び出されるとオブジェクトの生成が自動で行われ、コンストラクタ内の処理で初期化が行われます。)
なので、 __new__はインスタンス生成、__init__はインスタンス初期化という役割に明確な違いがあります。
当たり前ですが、名前通りの働きをしてくれていることになりますね。

__new__

  1. 新しいインスタンス生成時に呼ばれます。
  2. 第一引数にはクラス自身を表すclsをとります。

__init__

  1. initという言葉通りインスタンスが生成された後に初期化時に呼び出されます。
  2. 第一引数には生成されたインスタンス自身を表すselfをとります。

利用例

以下で __new__を使用する場合の利用例を示していきます。

1.シングルトンパターン

シングルトンパターンとはGoFデザインパターンの一種で生成されるインスタンスを一つに制限することです。
ロケールなどの処理で使用されるデザインパターンだそうですが、使われるのはあまり見ないかもしれません。

Singleton パターン - Wikipedia

# main.py
class Singleton():
    singleton = None

    # *argがないとSomeClassの__init__で引数を指定できない
    def __new__(cls, *arg, **kwargs):
        if cls.singleton is None:
            cls.singleton = super().__new__(cls)
        return cls.singleton

class SomeClass(Singleton):
    def __init__(self, name: str):
        self.__name = name

    @property
    def name(self):
        return self.__name

def main():
    a = SomeClass('Jone')
    print(f'a.name is {a.name}')
    print(a)

    b = SomeClass('Ken')
    print(f'a.name is {a.name}')
    print(f'b.name is {b.name}')
    print(a)
    print(b)

if __name__ == '__main__':
    main()
# 実行結果
a.name is Jone
<__main__.SomeClass object at 0x10214a150>
a.name is Ken
b.name is Ken
<__main__.SomeClass object at 0x10214a150>
<__main__.SomeClass object at 0x10214a150>

ちゃんと生成されたインスタンスが一つだけになっています。

2.イミュータブルなクラスの継承した時の初期化

strやtuppleなどイミュータブルなクラスを継承した場合はinitでは初期化できません。
そこで__new__を使用することによって擬似的に初期化(インスタンス生成時のカスタマイズ)を行います。

strの場合の例

修正前

# strの場合
class SomeClass(str):
    def __init__(self, args):
        self = 'Jone'

def main():
    a = SomeClass('Ken')
    print(a)
# 実行結果
Ken

修正後

class SomeClass(str):
    def __new__(cls, args):
        self = str.__new__(cls, 'Jone')
        return self

def main():
    a = SomeClass('Ken')
    print(a)
# 実行結果
Jone

tupleの場合の例

# tupleの場合
class SomeClass(tuple):
    def __init__(self, args):
        print(self[0] + self[1] + self[2])
        self[4] = 'San'

def main():
    SomeClass('Ken')
# 実行結果
TypeError: 'SomeClass' object does not support item assignment
class SomeClass(tuple):
    def __new__(cls, args):
        x = args[0]
        y = args[1]
        z = args[2]
        
        self = tuple.__new__(cls, (x, y, z, 'San'))
        return self

def main():
    a = SomeClass('Ken')
    print(a)
# 実行結果
('K', 'e', 'n', 'San')

こちらも積極的に使うことはないと思いますが、このようなことができます。

終わりに

今回はあまり見かけない特殊メソッド__new__について調べてみましたが、上の利用例の他にもメタプログラミングなどで__new__をよく使用することがあるみたいですが、あまりまだメタプログラミングについては詳しくないので割愛します。
調べた限りでは特殊メソッド__new__はそこまで積極的に使うことはなさそうですね。

Docker-Composeを使って5分でJupyterLabの環境構築

仕事でデータ分析とかもやっていかないといけなくて、勉強のための実行環境欲しいなと思って、自分のPCにJupyterLabをDocker-Composeを使って環境構築したので記事にしました。

実施環境

  • macOS Catalina(15-inch, 2016)
  • Docker for Macインストール済
  • Docker version 19.03.8
  • docker-compose version 1.25.4

JuptyerLabとは

ライブラリを使用したデータの可視化が簡単にでき、機械学習に必須の前処理やデータマイニングをデータを見ながら試行錯誤ができるという、データ分析には必須のIDEです。
JuptyerLabはJupyter notebookの後継でJupyter notebookの開発は一旦終了し,JupyterLabに移行すると公式アナウンスされています。

github.com

使用したDocker image

今回はJupyterLab公式のPythonとJuliaとRが使えて、デフォルトでpandasやscikit-learnなどのPythonライブラリが使えるdatascience-notebookというimageを使用します。Pythonしか使わない場合はscipy-notebookというimageがあるので、そちらを使った方が良いと思います。

他にもいろいろimageがありますので公式ドキュメントをご覧ください。
Selecting an Image — docker-stacks latest documentation

環境構築

  1. ローカルに今回の環境構築用のディレクトリを任意の場所に用意します。
  2. 1 で作ったディレクトリ配下に、workフォルダとdocker-compose.ymlを作成します。
  3. 作成したdocker-compose.ymlに以下のように記述します。
    version: "3"
    services:
      datascience-notebook:
        container_name: datascience-notebook-container
        # scipy-notebookの場合はjupyter/scipy-notebookになります。
        image: jupyter/datascience-notebook
        # コンテナの/home/jobyan/workをホストの./workで永続化
        volumes:
          - ./work:/home/jovyan/work
        ports:
          - 8888:8888
        restart: always
        # 今回はローカル起動用なのでログインは省略します。
        command: start.sh jupyter lab --NotebookApp.token=''
    
  4. terminalで 1 で作ったディレクトリ配下にて、初回は`docker-compose up --build`を実行しコンテナを起動します。(buildは結構時間がかかります。コマンドは次回以降`docker-compose up`でOK)
  5. http://localhost:8888/にアクセスして起動を確認します。 以下のような画面が出てきたら起動完了です。 f:id:okiyasi:20200712140752p:plain

動作確認

起動ができたら動作確認を行います。

  1. 左のworkフォルダをダブルクリックします。
  2. Luncherの中のNotebook Python3を選択すると以下のファイルが作成されます。 f:id:okiyasi:20200712141256p:plain
  3. 青い枠の中に以下のコードを書き込みます。
from sklearn.datasets import load_iris
import pandas as pd
iris = load_iris()
pd.DataFrame(iris.data, columns=iris.feature_names)

枠の中でShift + Enterを押し、以下のようなテーブルがちゃんと出てきたらOKです!

f:id:okiyasi:20200712141810p:plain

終わりに

Jupyter Labを使ったデータ分析の環境ができたので、本を使ったり、Kaggleをやったりしてデータ分析のスキルを身に付けていきたいですね!

O'Reilly Japan - Pythonによるデータ分析入門 第2版

Kaggle: Your Machine Learning and Data Science Community

AngularにおけるDIについて

Spring Frameworkを触っていたりすると、DIコンテナとか@Autowiredとか@Beanとか見慣れないものが登場しすぎて、 コード側でもDIの機構について理解しておかないとちゃんとコードが書けない!ってなるのですが、
Angularの場合は本当に簡単にDIが実現できてしまうので、そう言えばAngularのDIについてちゃんと知らない!と思ったので、公式ドキュメントを読みながら記事を書いてみることにしました。

1.DI(依存性の注入)とは

ここでは例としてインスタンスを生成の場合について考えます。

class ClassA {
    b :ClassB;

    constructor(){
        this.b = new ClassB();
    }

    doFuga() {
        this.b.doSomethingGreat();
    }
}

class ClassB {

    constructor() { }

    doSomethingGreat(){
        // 何かすごいことをやる
    }
}

上のコード(テキトーですが)のように、あるClassAの中で、ClassBのインスタンスが生成され、使用されている場合、
ClassAは既にClassBが実装されていないととクラスAを動かすことはできません。
また、ClassAはClassBの生成方法を知っていないといけません。
このような状態のことをClassAがClassBに依存しているといいます。

class ClassA {
    b :ClassB;

    constructor(b: ClassB){
        this.b = b
    }

    doFuga() {
        this.b.doSomethingGreat();
    }
}

しかしここで、クラスAの中でクラスBのインスタンスを直接生成するのではなく、外部から生成されたクラスBのインスタンスを渡してもらうことにしたらどうでしょうか?
クラスAがクラスBのインスタンスの生成方法を知らなくて良くなり、クラスAは渡されたクラスBのインスタンスを使用することだけを考えるだけで良くなります。
このデザインパターンがDIと呼ばれています。
DIによってまだクラスBが作られていなくても、同じインターフェイスを実装したクラスのインスタンスをクラスAに渡してあげることが可能なため、 テスト時はモックを使うなどが簡単に可能にできるようになります!

2.AngularのDIの基本

# Service側
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class HeroService {
  constructor() { }
}
# Component側
import { Component }   from '@angular/core';
import { Hero }        from './hero';
import { HeroService } from './hero.service';

@Component({
  selector: 'app-hero-list',
  template: ``
})
export class HeroListComponent {
  heroes: Hero[];
 
  // heroService: HeroServiceと書くだけで勝手にHeroServiceが注入される!
  constructor(heroService: HeroService) {
    this.heroes = heroService.getHeroes();
  }
}

AngularではServiceに@Injectable()デコレーターをつけることによって注入可能なServiceをマークすることができます。
そしてServiceを要求するComponentはコンストラクターの引数にheroService: HeroServiceと書くだけで、起動時にHeroServiceクラスのインスタンスを一つだけ生成し、勝手にComponent側に注入してくれて、heroServiceを使用することが可能になります。 (めちゃめちゃ簡単ですね。。。)

3.Injectorについて

実は2の@Injectable()デコレータをServiceにつけるだけでは注入可能なServiceとして認識されるだけで本当はAngularは何もしてくれません。
2では、{providedIn: 'root'}というprovidedIn メタデータオプションによってServiceをroot Injectorに登録することで、このroot InjectorがServiceインスタンスをComponentに注入してくれています。
root InjectorがDIをしてくれている張本人になります。

root Injectorの他にもDOMごとやModuleごとにInjectorが存在します。それぞれElement InjectorModule Injectorと呼ばれています。
各Injectorはトークンプロバイダーマップというものを持っていて、トークン(マップのキー・DIトークンとも呼ばれている)が保持されています。
2の例ではHeroServiceというトークンがroot Injectorのトークンプロバイダーマップに保持されていて、起動時にHeroServiceクラスのインスタンスが一つだけ生成されます。
また、Injectorは階層構造になっていて、トークンがどのInjectorにあるのか探索を行います。Element Injectorに欲しいトークンが登録されていなかったら、Module Injectorの中を探しに行き、なかったらどんどん上の階層のModule Injectorを探索しにいくことになります。そして最上位の階層のInjectorにもいない場合root Injectorが呼ばれ参照されます。
それでもなかったら最後にNull Injectorというものが呼ばれNullInjectorErrorが吐かれます。(結構登録し忘れててNullInjectorError見たことある人いるのでは?!)

4.injectorの設定方法

@Injectable()も含め、以下3つのInjectorに設定する方法があります。
- @Injectable()のデコレータ内での設定
- @NgModule()のprovidersオプションでの設定
- @Component() デコレーターの中での設定
の3つです。順に説明していきます。

@Injectable()のデコレータ内での設定

root Injectorでなく任意のモジュールのInjectorに設定したい場合は、モジュールクラス名を指定することによって可能です。
下の例ではAuthModuleのModule InjectorにSessionServiceトークンが登録されます。

@Injectable({
  providedIn: AuthModule
})
class SessionService {
     constructor(private http: HttpClient) {}
     login() {
      // ログイン
     }
}

@NgModule()のprovidersオプションでの設定

NgModuleのprovidersに登録することで設定可能です。この場合もModule InjectorにServiceが登録されます。
SessionServiceは省略記法でproviderオブジェクトリテラル{ provide: SessionService, useClass: SessionService }に展開されます。 useClass等のオプションについては5で解説します。

@NgModule({
  providers: [
    // 省略記法で登録
    SessionService
  ],
})
export class AuthModule { }

// 注入
@Component({...})
class LoginComponent {
  constructor(
    service :SessionService,
  ) {}
}

@Component() デコレーターの中での設定

@Component() デコレーターの中でproviderを設定すると、Element Injectorにproviderが登録されます。

@Component({
  ...
  providers: [{ provide: ItemService, useValue: { name: 'lamp' } }]
})
export class TestComponent

Element Injector自体、そこまで多用することは多くないと思いますが、このようなユースケースがあるようです。

providerのオプション設定について

ここでは上で紹介したuseClassのような、providerでのオプションについて解説します。

Angular公式のコードが分かり易かったので拝借して説明したいと思います。

import { Component, Inject } from '@angular/core';

import { DateLoggerService } from './date-logger.service';
import { Hero }              from './hero';
import { HeroService }       from './hero.service';
import { LoggerService }     from './logger.service';
import { MinimalLogger }     from './minimal-logger.service';
import { RUNNERS_UP, runnersUpFactory }  from './runners-up';

const someHero = new Hero(42, 'Magma', 'Had a great month!', '555-555-5555');

@Component({
  selector: 'app-hero-of-the-month',
  templateUrl: './hero-of-the-month.component.html',
  providers: [
    { provide: Hero,          useValue:    someHero },
    { provide: TITLE,         useValue:   'Hero of the Month' },
    { provide: HeroService,   useClass:    HeroService },
    { provide: LoggerService, useClass:    DateLoggerService },
    { provide: MinimalLogger, useExisting: LoggerService },
    { provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }
  ]
})
export class HeroOfTheMonthComponent {
  logs: string[] = [];

  constructor(
      logger: MinimalLogger,
      public heroOfTheMonth: Hero,
      @Inject(RUNNERS_UP) public runnersUp: string,
      @Inject(TITLE) public title: string)
  {
    this.logs = logger.logs;
    logger.logInfo('starting up');
  }
}

Angular 日本語ドキュメンテーション

useClass

useClassプロバイダーキーを使用すると、指定したクラスの新しいインスタンスを1つ作成して注入することができます。
このuseClassを使用することで、クラスを代替クラスのインスタンスで置き換えることができます。 これにより元のクラスと異なる挙動で実装したり、クラスを拡張したり、テストでServiceをmockにできます。 上の実装例では{ provide: LoggerService, useClass: DateLoggerService },で使用されており、LoggerServiceトークンにDateLoggerServiceのインスタンスが紐づけられ、LoggerServiceの代わりにDateLoggerServiceが使用可能になります。

またテスト時には以下のように使用し、ServiceをMockServiceに変えることができます。

// テストの時の使用例 spec.ts
class MockUserService {
  isLoggedIn = true;
  user = { name: 'Test User'};
};

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  comp = TestBed.inject(WelcomeComponent);
  userService = TestBed.inject(UserService);
});

useValue

useValue キーを使用すると、固定値をトークンに関連付けることができます。
これによりServiceの代わりに、単体テスト内でモックデータを提供することなどができます。
上の実装例では{ provide: Hero, useValue: someHero }ではHeroトークンにHeroクラスの既存のインスタンスであるsomeHeroを紐づけています。(一見何してるのか分かりませんが、下のuseFactoryで再度登場します。)
{ provide: TITLE, useValue: 'Hero of the Month' },ではuseValueに文字列リテラルを指定します。
provide: TITLEのTITLEはもはやクラスでもなんでもないですが、これは代わりに InjectionTokenオブジェクトと呼ばれるものを使用しています。これによりクラス以外の文字列、関数、またはオブジェクトをDIすることが可能です。詳しくはこちら
@Inject(TITLE) public title: string)でtitleに値を注入しています。

またテストの場合は以下のように使用されます。

let userServiceStub: Partial<UserService>;

beforeEach(() => {
  // 作成したmock
  userServiceStub = {
    isLoggedIn: true,
    user: { name: 'Test User' },
  };

  TestBed.configureTestingModule({
     declarations: [ WelcomeComponent ],
     
     providers: [ { provide: UserService, useValue: userServiceStub } ],
  });

  fixture = TestBed.createComponent(WelcomeComponent);
  comp    = fixture.componentInstance;
  userService = TestBed.inject(UserService);
});

useFactory

useFactoryを使用すると、ファクトリー関数を呼び出してオブジェクトを作成し注入することができます。
上の{ provide: RUNNERS_UP, useFactory: runnersUpFactory(2), deps: [Hero, HeroService] }では runnersUpFactory()がファクトリー関数です。

export function runnersUpFactory(take: number) {
  return (winner: Hero, heroService: HeroService): string => {
    /* ... */
  };
};

このようにファクトリー関数を実装することができ、runnersUpFactory()(winner: Hero, heroService: HeroService): string => { };というプロバイダーファクトリー関数を返しています。 上のuseValueでHeroトークンに注入したインスタンスやHeroServiceをプロバイダーファクトリー関数の引数として使用することが可能でdeps: [Hero, HeroService]で使用するものが 定義されています。

useExisting(エイリアスプロバイダー)

useExistingを使用すると、あるトークンを別のトークンにマッピングできます。つまり、useExisting は他のトークンのエイリアスとして動作します。

{ provide: LoggerService, useClass: DateLoggerService },
{ provide: MinimalLogger, useExisting: LoggerService },

上の{ provide: LoggerService, useClass: DateLoggerService },LoggerServiceトークンに対応するDateLoggerServiceインスタンスは生成されているため、MinimalLoggerにはLoggerServiceが参照しているDateLoggerServiceインスタンスが注入されます。 またこの使用例では

export abstract class MinimalLogger {
  logs: string[];
  logInfo: (msg: string) => void;
}

を定義しておくことによって、LoggerServiceがMinimalLoggerよりも遥かに多いプロパティやメソッド を持ってる場合、MinimalLoggerに定義されているプロパティとメソッドだけを使用できるように機能を制限することができます。

multi(おまけ)

一つ前の記事でInterceptorを扱いましたが、その時、app.moduleのproviders[]に{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }を登録しました。
このmultiというのはtrue を指定すると同じトークンで複数の依存オブジェクトを扱う事ができるようになるもので、HTTP_INTERCEPTORSトークンに対応する、既存のInterceptor処理のインスタンスが入った配列にTokenInterceptorのインスタンスをpushしていることになります。

終わりに

今回はAngularのDIについて、実際にある程度使ったり、理解に役立つ範囲で紹介しました。 この他にもInjector階層の探索のオプション(@Optional, @Skip)、バンドルサイズに関わるツリーシェイキングの話があるのですが、話が長くなりすぎてしまうので、今回は割愛することにします。

参考

Angular 日本語ドキュメンテーション

AngularにおけるInterceptorの使い方

実務でAngularのInterceptorを使ったらhttpClientの事前・事後処理を共通化できてとても便利だったので軽くInterceptorについてまとめてみました。 たまたま見つけるまで自分は知らなかったのですが、そこそこメジャーな機能なようです。

Interceptorとは?

Angular 4.3から登場した機能で httpclientに割り込み処理を入れることで、リクエストを投げる前やレスポンスを受け取った後に共通の処理を行うことができます。
以下で実務で使えそうなInterceptorでできることを紹介します。

リクエスト時の認証用ヘッダー追加処理を共通化

JWTを使った認証を利用する場合、Authorizationヘッダーにトークンをセットする必要がありますが、通信ごとにヘッダーを追加するのはとてもめんどくさいので interceptorを使ってヘッダーを追加する処理を共通化することができます。 認証用ヘッダー以外のヘッダーも同じ要領で追加することができます。

// src/app/common/auth/token-interceptor.ts

import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CookieService } from 'ngx-cookie-service';
import { Observable } from 'rxjs';

@Injectable()
export class TokenInterceptor implements HttpInterceptor {

    constructor(private cookieService: CookieService ) { }

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        // Cookieに保存されているJWT(JSON Web Token)を取り出す
        const token = this.cookieService.get('jwt');
        const newRequest = request.clone({
            // 全てのリクエストのAuthorizationヘッダーにトークンをセット
            headers: request.headers.set(
                'Authorization', `Bearer ${token}`
            )
        });
        return next.handle(newRequest)
    }
}

HttpClientに適用するために、上で作成したInterceptorを既存のHTTP_INTERCEPTORSに追加する必要があります。 app,module.tsのprovidersに以下を追加することでHTTP_INTERCEPTORSに追加完了です。

// src/app/app.module.ts

import { TokenInterceptor } from './common/token-interceptor' ;
import { HTTP_INTERCEPTORS } from '@angular/common/http';

@NgModule({
  declarations: [
    AppComponent
  ],
  providers: [
    // 新たに追加
    { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

レスポンス時のエラー処理を共通化

ここでは例として1で送ったレスポンスがセッションが切れるなどして、Status Code 401(Unauthorized)が帰ってきたときにログイン画面にルーティングするような処理を共通化します。

// src/app/common/auth/unauthorized-interceptor.ts

import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, throwError} from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Router } from '@angular/router';

@Injectable()
export class UnauthorizedInterceptor implements HttpInterceptor {
    constructor(private router: Router) {}
    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        return next.handle(request).pipe(
            catchError((error: HttpErrorResponse) => {
                if (error.status === 401) {
                    /**
                     * ログイン画面へのリダイレクトや
                     * 「セッションが切れました」というモーダルの表示などを行う
                     */
                    this.router.navigate(['login']);
                }
                return throwError(error);
            }));
    }
  }

今回も1と同じようにapp,module.tsに { provide: HTTP_INTERCEPTORS, useClass: UnauthorizedInterceptor, multi: true } を追加することで全体で使用可能になります。
また今回は1・2を分けるために別々で書きましたが、一連の処理を同じinterceptorに書くことも可能です。

終わりに

他にもInterceptorを使用することでキャッシュを行ったり、ダミーのレスポンスを返すことができるようですが、そこまで使うことはないと思うので今回は遠慮させていただきます! 興味がある方は調べてみてくださいね。

参考

Top 10 ways to use Interceptors in Angular | by Michael Karén | Angular In Depth | Medium