0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【MailOrca 実装実況②】1プロセスでSMTPとHTTPサーバーを同居させてみた【FastAPI + aiosmtpd】

Posted at

はじめに

 前回は、年末年始に作った MailOrca の概要をお話しました。

 今回からはいよいよ実装編。どうやって「Pythonだけで、1つのコマンドで、SMTPとWeb UIを同時に動かしているのか」というブートストラップ部分を解説します。

1. 入口を作る: Click による CLI の実装

 まずはユーザーが叩く runserver.pypip install した場合はコマンド mailorca)から呼ばれる cli.py です。

 設定ファイル(JSON)を読み込みつつ、コマンドライン引数でポート番号などを上書きできるようにしたい。こういう時は Click がお手軽で便利です。

mailorca/cli.py
import click

@click.command(context_settings={"auto_envvar_prefix": "MAILORCA"})
@click.option("--config", default="config.json", help="JSON configuration file")
@click.option("--smtp-port", default=1025, help="SMTP port")
@click.option("--http-port", default=8025, help="HTTP port")
# ... 他のオプション ...
@click.pass_context
def main(ctx, config, smtp_port, http_port, ...):
    # 設定の読み込みと上書き
    load_config(config)
    # 最終的な設定を CONFIG オブジェクトに反映
    # ...
    
    # サーバー起動!
    import uvicorn
    uvicorn.run("mailorca.web:app", host=..., port=...)

if __name__ == "__main__":
    main()

 デフォルト値、コマンドライン引数の値、環境変数、JSONファイルと、設定値のソース(情報源)が複数あると管理が面倒ですよね。MailOrca では、その値は config.py にて変数 CONFIG で一元管理し、起動時に以下のような優先順位でマージしています。

  1. コマンドライン引数(最優先)
  2. 環境変数 (MAILORCA_SMTP_PORT など)
  3. 引数 --config で指定された JSON ファイル(デフォルトはconfig.json
  4. デフォルト値

 Click の auto_envvar_prefix を使うと、環境変数を自動的に紐づけてくれ、その上でコマンドライン引数と環境変数では前者を優先してくれるので、コードをあまり書かずに「1」と「2」を実現させられるのがお気に入りな便利ポイントの一つです。

 また Click には、その値がデフォルト値か、環境変数からか、コマンドライン引数からか、区別する仕組みが用意されています。

    if ctx.get_parameter_source("smtp_host") != click.core.ParameterSource.DEFAULT:
        CONFIG["smtp"]["port"] = smtp_port

 つまり、デフォルト値でなければ、ユーザーがコマンドライン引数か環境変数で設定した値ですので、それを使う、ということが可能になります。

 さて、ここでもう一つ面白いのは、uvicorn.run() に文字列で "mailorca.web:app" を渡している点です。これにより、Uvicorn が Web サーバーとして動き出すわけですが、その時に mailorca/web.py が import され、変数 app が作られ(具体的なコードは後述)、それを参照します。

 この、mailorca/web.py が後から import されるというのは、実は大事だったりします。この web.py の中でも config.py が import され、設定値が格納されている変数 CONFIG を参照します。

つまり、やりたいことは以下です。

  1. cli.py でコマンド引数などを解釈し、変数 CONFIG に格納する。
  2. web.py で変数 CONFIG の値を見て、各実行がされる。

なのにもし cli.py の冒頭て web.py を import したらどうなるでしょう?

  1. web.py で変数 CONFIG の値を見て、各実行がされる。
  2. コマンド引数などを解釈し、変数 CONFIG に格納する

ということになってしまいます。これはつまり、コマンドライン引数などを考慮する前に、デフォルト値でしかない設定値を使われてしまい、ユーザーの意図する動作がされなくなってしまいます。

 少しややこしい話かもしれませんが、web.py を後から import させることは、上記の設定値の優先順位を維持するために必要なことです。

 さて、Web サーバの立ち上げまではできたわけですが。

「え、Web サーバーは動くけど、SMTP サーバーはどこで動かすの?」

 その答えは FastAPIlifespan にあります。

2. FastAPI の lifespan で SMTP サーバーを飼い慣らす

 FastAPI (Starlette) には、アプリケーションの起動時と終了時に特定の処理を行える lifespan という仕組みがあります。これを使って、Web サーバーが立ち上がる瞬間に SMTP サーバーもバックグラウンドで起動させてしまいます。

mailorca/web.py
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    # --- 起動時の処理 ---
    # aiosmtpd の Controller を作成
    controller = SMTPController(
        MailHandler(),
        hostname=CONFIG["smtp"]["host"],
        port=CONFIG["smtp"]["port"],
    )
    # 別スレッドで SMTP サーバーを開始
    controller.start()
    logger.info(f"SMTP listening on {CONFIG['smtp']['port']}")

    yield  # ここで Web サーバーがメインループに入る

    # --- 終了時の処理 ---
    controller.stop()
    logger.info("SMTP stopped.")

app = FastAPI(lifespan=lifespan)

 なお、変数 app の作成と、そこで使う関数 lifespan の中で CONFIG が使われている様子もわかるかと。

なぜ aiosmtpd なのか?

 Python 標準ライブラリの smtpd はすでに非推奨(Deprecated)になっています。aiosmtpd はその名の通り asyncio ベースで書き直されたライブラリで、非常に取り扱いが楽です。

 通常、controller.start() は内部でデーモンスレッドを立ててサーバーを動かしてくれるので、メインのイベントループ(Uvicorn)を邪魔することなく、同じプロセス内で SMTP サーバーを共存させることができます。

今回のまとめ

  • Click でコマンドラインインターフェースをサクッと構築。
  • uvicorn.run() で Web サーバーを起動。
  • FastAPI の lifespan フックを利用して、aiosmtpd をバックグラウンドで同時起動。

 これで、「1つのコマンドで複数の待ち受けポートを持つ」という MailOrca の骨格が出来上がりました。

 次回は、受け取ったメールをどうやって解析し、メモリ上に保存しているのか。

 email ライブラリとの格闘(パース編) をお送りします。お楽しみに!

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?