delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

terraform importで指定するオプションは対象のサービスによって異なる

tech.layerx.co.jp

上記の記事を参考にさせて頂いて

  1. まずはAWSマネージメントコンソール上でインフラを作成する
  2. terraform importで取り込む
  3. 作成されたtfstateを参考にしつつオプションを調べる

という手順でTerraformの定義を実装していた。

その際にterraform importのオプション指定の仕方が対象のAWSサービスによって微妙に異なるということを知ったのでメモしておく。

S3の場合

以下のように定義していたとする。(同名のバケットAWSマネージメントコンソールで作成済みの前提)

resource "aws_s3_bucket" "example-bucket" {
    bucket = "example-bucket"
}

以下のようにterraform import <プロバイダ>_<タイプ>.<リソース名> <バケット名>とするとインポートに成功する。

terraform import aws_s3_bucket.example-bucket example-bucket

SQSの場合

SQSの場合も同じようにいけると思い、以下のようなコマンドを実行した。

import aws_sqs_queue.example-queue example-queue

すると以下のようなエラーメッセージが出た。

Error: SQS Queue URL (example-queue) is in the incorrect format

公式ドキュメントのimportの項を読んだところ、SQSの場合はキュー名ではなくキューのURLを指定しないといけないらしい。 registry.terraform.io

正しいコマンド

terraform import aws_sqs_queue.example-queue https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/example-queue

公式ドキュメントにはAWSの各サービスごとのimportコマンドのサンプルが必ず乗っているようなので、それを確認するようにする。

AIでコンテキストマップを生成できないか試してみた

概要

新しいシステムに関わると、システムの概要を把握するためにモデルのコードやER図を眺めたりする。ただ、プロダクションレベルのシステムはテーブル数が多いので片っ端から読んでいてもなかなか全貌を把握するに至らない。

従って、DDDなどの文脈などで出てくる「コンテキストマップ」のようなもう少し概念レベルに抽象化した図がほしくなる。

生成AIであれば、モデルのコードやER図からコンテキストマップを生成してくれるのでは?と思いChatGPTを使って試してみた。

結論としてはうまくいかなかった。

やったこと

spreeというOSSRailsのECフレームワークを題材とした。 github.com

既にissueにて生成済みのER図が公開されている。

https://cloud.githubusercontent.com/assets/1022589/3699479/b82a8114-13d4-11e4-9069-c12fe104e0c5.png

1.ER図を食わせてコンテキストマップを生成させる

ChatGPTにはいつからか画像をアップロードできるようになったので、まずは上記のER図を食わせてコンテキストマップを作成するようにお願いしてみた。

すると、「私の機能では、画像から直接情報を読み取って新たな図を作成することはできません。」と言われてしまった。

従って、この方針は少なくともChatGPTではできないので諦めた。

Railsのモデルのファイル名をテキストで食わせてコンテキストマップを生成させる

テキストならいいのでは?ということで、以下のようなspreeのRailsモデルのファイル名を抽出してこれを渡した上で、コンテキストマップを作成するようにお願いしてみた。(Railsは1モデル1ファイルなので楽だった)

...
./core/app/models/spree/order
./core/app/models/spree/order/digital.rb
./core/app/models/spree/order/address_book.rb
./core/app/models/spree/order/checkout.rb
./core/app/models/spree/order/currency_updater.rb
./core/app/models/spree/order/store_credit.rb
./core/app/models/spree/order/emails.rb
./core/app/models/spree/order/payments.rb
...

すると、「ファイル名だけだとモデル間の相互関係が分からないので正確ではない」と前提を付けた上で今度は画像は描いてくれた。

補足しつつ何度か依頼したところ、以下のような画像たちが生成された。

感想

雰囲気は出ているのだが、満足できる図は作成してくれなかった。

システムの概要を把握する目的なのでシンプルでいいと伝えているのだが、いくら伝えても芸術要素を挟み込んでくる。

私のプロンプトが悪かったり、こういうことしたいのであればもっと向いてる生成AIサービスがあったりするのかもしれないので、引き続きトライしてみたい。

DjangoユーザーがRails入門してみて感じた違い

はじめに

私は仕事で3年以上Djangoを使っているが、副業でRailsを使う機会があった。同じそれぞれの言語を代表するフルスタックフレームワークだからそんなに違わないと思っていたらけっこう違ったので、感じた違いをメモしておきたい。

目次

  • フロントエンドに対する考え方
  • バージョンアップポリシー
  • CoC(Convention over Configuration)
  • モジュールのインポート
  • Formの有無
  • Modelの仕様の違い
  • Migrationファイルの扱い方の違い
  • ファイルの扱い
  • applicationの有無
  • 認証機能の有無
  • ググラビリティ

フロントエンドに対する考え方

Djangoはよくも悪くもフロントエンドに対して無関心である。

対して、RailsDHHの思想もあるのだろうが、フロントエンドに独自のやり方で関わろうとしている。

具体的にはRailsの実行環境にはYarnが必要であり、またRailsというFWは以下のようなものを包含している。

  • Turbolinks
  • Webpacker
  • Sprockets
  • Stimulus

私が関わったPJではTurbolinksの挙動に苦しめられた。

昨今主流の、RailsAPIサーバーとして使い、FEはReactやVueを使うという場合はあまり関係ないだろうが、画面もRailsで実装する場合はこれらも抑えておく必要がある。

最初からAPIサーバーとしてしか使わないと決めている場合は、RailsにはAPIモードというものがある。 railsguides.jp

バージョンアップポリシー

Django

Djangoはメジャーバージョンアップ時に大きな機能追加もない代わりに後方互換性を大事にしており、かなり安定していると感じている。実際に私もバージョンアップをしたことがあるが、主に問題になるのは依存しているライブラリが新バージョンのDjangoに対応していないとかであって、Django自体のバージョンアップで苦労したことはあまりない。

blog.pyq.jp qiita.com

Rails

対して、Railsは大きな機能追加や破壊的な変更が多い。

qiita.com developers.freee.co.jp

CoC(Convention over Configuration)

RailsはCoCを徹底している。慣れなのだと思うが、命名規約が違反していて動かないことが何度かあり、明示的に設定を書いた方が楽なのになと思ったことはある。

rubyonrails.org

モジュールのインポート

Djangoには特有のモジュールのインポートの機構はないので、通常のPythonと同じようにimportを書いてモジュールをインポートする。

対してRailsではZeitwerkというものが裏で動いており、何もコードを書かなくてもモジュールをインポートしてくれる。 railsguides.jp

従って、Railsではrequire(Pythonでいうimport)を基本的に書かない。

これも慣れなのだと思うが、Djangoに慣れてる身としては以下の点で苦労した。

  • コードを眺めても依存しているモジュールがぱっと見で分からない。
  • CoCと関連するが、命名規約を間違えていたりファイルの置き場所がRails Wayから外れていたりするとインポートされない場合がある。

Formの有無

DjangoにはFWの機能としてリクエストパラメータをバリデーションする層であるFormがあるが、Railsにはない。バリデーションはModelで行う思想らしい。 speakerdeck.com

※ t_wadaさんが「FormとModelのバリデーションルールがほぼ同じだからという理由で、Form層を切ったのがDHHの慧眼」という趣旨の話をどこかのポッドキャストでしていた記憶がある。見つけたら追記しておく。

RailsにおいてはFormはデザインパターンになっている。 techlife.cookpad.com

Modelの仕様の違い

DjangoではModelは「テーブル定義書 as code」のようになっており、Modelのコードを読むとテーブル定義が把握できる。

対してRailsではModelに記述するのはバリデーションやコールバックのみで、スキーマ定義自体は書かない仕様である。(実行時にDBからスキーマ情報を取得して、メタプログラミングで動的にフィールドを生成しているらしい。)

コードからテーブル定義を把握したい場合は、schema.rbを見たり、annotateというModelにコメントでテーブル定義を自動出力してくれるライブラリを使ったりする。

Migrationファイルの扱い方の違い

先のDjangoではModelでスキーマ定義するという話と関連するが、DjangoではMigrationファイルはModelをインプットとして得られる生成物なので、基本的に人間はあまり編集しない。

対してRailsではrails generate migrationしても雛形しか作成されないので、どのようなテーブルを作成したいかは人間が生成コマンドのオプションに渡したり、自分で記述したりする必要がある。(Modelにスキーマ定義がないからインプットにできるものが存在しないので、必然ではある)

ファイルの扱い

DjangoではModelに組み込みでFileFiledImageFiledが存在する。

対してRailsではCarrierWaveなどのライブラリを使って対応するのが一般的である。

Rails5.2以降ではActive Storageが使えるようになったが、ざっと説明を読んだ感じ癖が強い機能だと感じたので、新規の案件であっても要件に合うかよく考えた上で導入したほうがいいと個人的には思っている。

railsguides.jp

applicationの有無

Djangoにはapplicationという機能の塊ごとにコードをグルーピングする概念が存在するが、Railsにはない。

ただ最近は、Railsでのモジュラーモノリスに関する議論があり、その中でコンテキストごとにモジュールを分けるという考え方が出てきてはいるようである。 logmi.jp

認証機能の有無

DjangoにはデフォルトでUserモデルと認証機能が備わっているが、Railsにはないのでdeviceなどのライブラリを使って実現する。これに関してはDjangoの方が特殊といえるかもしれない。(実際にUserモデルに設計が引きずられてしまうことがありがちである)

github.com

ググラビリティ

DjangoRailsに比べるとユーザ数が少ないこともあってか、発信者に信頼できる方が多く、ググるとすぐに良質な情報に出会える印象がある。

Railsの方がググった時にヒットする情報が玉石混合で良質な情報を探すのに苦労した。他方でRailsを使っている会社のテックブログやKaigi On Railsの発表などは現場レベルのナレッジが多くとても充実している。

ユーザー数が多いというのはそういうことなのだと思う。

Ariadne ✖️ DjangoでスキーマファーストのGraphQL開発を体験してみた

はじめに

この記事はDjango Advent Calendar 2023の17日目の記事です。

本記事ではAriadneというスキーマファーストの思想のPythonのGraphQLライブラリとDjangoを使って、スキーマファーストのGraphQL開発に入門してみた経過と感想を書きます。 ariadnegraphql.org

バージョン

本記事で使用したバージョンは以下です。

動機

GraphQLのライブラリは以下の2つの思想に分類されます。

それぞれのメリット・デメリットについてはたくさん議論されているので、ここでは割愛します。 www.apollographql.com

私は仕事では、Strawberryというコードファーストの思想のGraphQLライブラリを使っています。 strawberry.rocks

Strawberryに関してはWEB+DB PRESS Vol.135で記事を書かせて頂き、また仕事で同じチームの@mysh_iiiiさんはDjango Congress2023で発表してくださりました。

www.docswell.com

Starwberryに特に不満はないのですが、スキーマファーストの思想のライブラリも触ってみたいなと思っていました。

そこで、Pythonスキーマファースト系のGraphQLライブラリで唯一GraphQLの公式サイトで紹介されており、GitHubのスター数も2000を超えているAriadneを技術検証がてらに触ってみることにしました。

※ 厳密にはTartifletteというライブラリも紹介されていますが、こちらは開発が停滞しているようです。

AriadneのDjangoのサポート状況

まずは、AriadneがDjangoをサポートしているのかが気になります。 結論からいうと、以下のような状態のようです。

Ariadneのリポジトリ本体はDjangoをサポートしていない

公式のTOPには以下のように紹介されており、Djangoをサポートしているように見えます。

しかし、ariadne.contrib.djangoというパッケージは以下の2021年のPRで削除されています。 github.com

公式のDjangoのIntegrationのページをみると、ariadne-djangoを使ってねとのことのようです。

ariadne-djangoはメンテされていない様子である

そのariadne-djangoですが、以下の理由よりほぼメンテされていない様子であることが伺えます。

  • 最終コミットが1年以上前
  • tox.iniをみるとDjango4系に対応していない

本記事における方針

本記事においては、以下の理由よりariadne-djangoを使うことにします。

  • Django 4.2でも問題なく動いた
  • あくまでスキーマファースト系のGraphQLを体験してみることが目的

実務で使う場合

実務の場合はさすがにメンテされていないものをブラックボックスのまま使うわけにはいかないので、以下のような方針の検討が必要になりそうです。

  • ariadne-djangoのコード量自体は多くないので、読んで理解した上で使う
  • Djangoとのインテグレーション部分は自前で実装する。
  • Ariadne本体はメンテされているので、Djangoとの連携は諦める(=Djangoを使わない)

触ってみた経過

ここからは触ってみた経過を書きます。

Djnagoのプロジェクトの作成

まずは普通に公式のチュートリアル通りにDjnagoのプロジェクトを作成します。

※ 公式ではpollsというapplicationを作成していますが、本記事では本を題材にするため、booksとしています。

Ariadneのインストール

ariadneariadne-djangoをインストールします。

pip install ariadne ariadne-django

INSTALLED_APPSへの追加

ariadne-djangoのREADMEに記載の通り、INSTALLED_APPSariadne_djangoを追加します。

INSTALLED_APPS = [
    # ...
    "ariadne_django",
    "books",
]

スキーマファイルの作成

ここがスキーマファーストのGraphQLを使う場合の特徴的な工程です。

DjangoのPJディレクトリ直下(=manage.pyと同じ階層)にschema.graphqlというファイルを作成します。

schema.graphqlに以下を記述していきます。

  • GraphQLの型
  • Query
  • Mutation
type Book {
    id: ID
    title: String!
    price: Int 
}

type User {
    id: ID
    username: String!
    books: [Book]
}

type Query {
    me: User
}

input RegisterBookInput {
    title: String!
    price: Int
}

type Mutation {
    registerBook(input: RegisterBookInput!): Book!
}

GraphQLのスキーマの記法の説明はここでは詳しくしませんが、型の後ろにビックリマークをつけているのはNot Nullという意味です。

補足説明

schema.graphqlはコードファーストのライブラリではコードをインプットとして自動生成される成果物ですが、スキーマファーストのライブラリでは手書きでコード実装よりも先に書きます。

一見コードファーストのライブラリと比べて作業が増えるデメリットしかないように感じます。

しかし、フロントエンドが別のチームや会社だったりする場合には、スキーマファイルをGraphQLのインターフェース策定のコミュニケーションツールとして使えるため、トータルでスキーマファーストの方が効率がいい場合も多いそうです。(この辺の話題は「スキーマ駆動開発」でググると情報がたくさん見つかります)

また今回は少量なので、補完ツールなどなしで手書きで書きましたが、業務のPJなどで量が多い場合はスキーマファイルを編集するための環境を整備した方がよいそうです。 zenn.dev

スキーマファイルを読み込む

作成したスキーマファイルは以下のように読み込むことができます。読み込んだスキーマの使い方は後述します。

from ariadne import load_schema_from_path

schema = load_schema_from_path("schema.graphql")

上記はmysite/mysite/schema.pyというファイルを作成してそこに記述する方針とします。以降、特に説明がなければコードは全てschema.pyに書く前提とします。

meクエリーの実装

まずは「meクエリー」というログインユーザーの情報を返すクエリーを実装していきます。

QueryTypeインスタンスを作成する

以下のようにQueryTypeというクラスのインスタンスを作成します。

from ariadne import QueryType

QueryType = QueryType()

ゾルバを定義する

ariadnegraphql.org

スキーマmeのクエリは定義済なので、以下のようにリゾルバを実装して紐づけます。

from graphql import GraphQLResolveInfo

# ...

@QueryType.field("me")
def resolve_me(
    _, info: GraphQLResolveInfo
) -> Optional[User]:
    user = info.context["request"].user
    if user.is_anonymous:
        return None
    return User.objects.get(pk=user.pk)

Djangoのリクエストオブジェクトはinfo.context["request"]から取得できるので、一般のDjangoアプリケーションと同じように認証できます。

なお、DjangoのModelからGraphQL型への変換はAriadneがやってくれました。 この辺のマッピングは自前で実装しなければいけないと思っていたので嬉しい誤算でした。

スキーマQueryTypeを紐づける

ariadnegraphql.org

スキーマQueryTypeを紐づけます。

以下のように読み込んだスキーマファイルとQueryTypemake_executable_schemaという関数に渡します。

schema = make_executable_schema(load_schema_from_path("schema.graphql"), [QueryType,],)

mysite/urls.pyにエンドポイントを定義する

github.com

以下のようにmysite/urls.pyにGraphQLのエンドポイントを定義します。

from ariadne_django.views import GraphQLView
from django.urls import path

from .schema import schema

urlpatterns = [
    path(
        "graphql/",
        GraphQLView.as_view(schema=schema),
        name="graphql",
    ),
]

ここまでで、ブラウザから「meクエリ」を実行できるようになります。

registerBookの実装

次にregisterBookというログインユーザーが本を登録するMutationを実装します。

Input型を定義する

まずはMutationのリクエストパラメータとなるInput型を定義します。 いくつか書き方があるようですが、公式ドキュメントの以下のセクションに記載されているように、いったんdataclassで定義してラムダ関数で渡すのがタイプヒントが効いてかつ簡潔に書けるので保守性が高そうです。

ariadnegraphql.org

from ariadne import InputType

# ...

@dataclass
class RegisterBookInput:
    title: str
    price: Optional[int] = None


RegisterBookInputType = InputType(
    "RegisterBookInput",
    lambda data: RegisterBookInput(**data),
)

MutationTypeインスタンスを作成する

QueryTypeと同様にMutationTypeというクラスのインスタンスを作成します。

from ariadne import MutationType

MutationType = MutationType()

ゾルバを定義する

クエリーの場合と同じようにリゾルバを実装して紐づけます。以下のように書くと、先ほど定義したRegisterBookInputを第3引数で受け取ることができます。

@MutationType.field("registerBook")
def resolve_register_book(
    _,
    info: GraphQLResolveInfo,
    input: RegisterBookInput,
):
    book = Book.objects.create(
        title=input.title,
        price=input.price,
        registered_by=info.context["request"].user,
    )
    return book

スキーマMutationInputを紐づける

最後にmake_executable_schemaの第二引数の配列に、以下のようにMutationTypeInput型を追加する必要があります。

schema = make_executable_schema(
    load_schema_from_path("schema.graphql"),
    [
        QueryType,
        MutationType,
        RegisterBookInputType,
    ],
)

これで以下のようにMutaionを実行できるようになります。

MutationTypeRegisterBookInputTypeの渡し方が分からず少しハマってしまい、以下のissueを読んで解決しました。

github.com

UserType#booksフィールドの実装

最後にUserTypeが持つbooksフィールドを実装します。

UserTypeを定義する

以下のissueによると、ルートではないクエリーを定義する場合は、ObjectTypeというクラスを使って、実装したいクエリーの所有者の型をプログラム上で宣言する必要があるそうです。

github.com

今回はbooksの所有者はUserTypeなので、UserTypeを宣言します。

from ariadne import ObjectType

# ...

UserType = ObjectType("User")

ゾルバを定義する

後は同じようにリゾルバを実装してUserTypeに紐づけます。ルートのクエリーの場合との違いとしては、第一引数で所有者のオブジェクトを受け取ることができるようです。

from django.db.models import QuerySet

# ...

@UserType.field("books")
def resolve_user_books(
    user: User, _: GraphQLResolveInfo
) -> QuerySet[Book]:
    return user.books.all()

こちらもQuerySetからBookTypeのリストへの変換はAriadneがやってくれました。

スキーマUserTypeを紐づける

同様にスキーマUserTypeを追加して紐づけます。

schema = make_executable_schema(
    load_schema_from_path("schema.graphql"),
    [
        QueryType,
        UserType,
        MutationType,
        RegisterBookInputType,
    ],
)

これで以下のようにネストしたクエリでbooksも取得できるようになりました。

以上となります。

感想

スキーマファーストということで、コードファースト系のライブラリよりも書かなければならないコード量はだいぶ多いのかなと思っていましたが、意外とAriadneがDjangoのModelからGraphQLの型への変換を自動でやってくれたため、少ないコード量で実装できました。

AriadneのDjangoのサポート状況が芳しくないということが分かったのは残念でしたが、スキーマファーストの思想のGraphQLライブラリを触ってみたことはいい経験になりました。

Djangoのカスタムコマンドの実装をチームで最低限統一する方法の検討

はじめに

この記事はDjango Advent Calendar 2023の10日目の記事です。

Djangoバッチ処理を作る際には、カスタムコマンドを実装することが多いと思います。

カスタムコマンドで実装されたバッチ処理において、以下のような考慮の有無が実装担当者に依存していたり、されている場合でも実装方法は統一されていなかったりするということを業務で経験しました。

  • 開始と終了のログの出力
  • 想定外エラーのハンドリング
  • 多重起動の防止

そこで、本記事ではチームで最低限統一するために開発開始時点で打てる施策を考えてみたいと思います。

問題点

その前に先にあげたような事項が考慮されないと何が問題かをもう少し説明します。

開始と終了のログの出力

開始と終了のログがないと、そもそもバッチが実行されたかどうかが分かりません。これがないと最低限の監視ができないですし、バッチの実行時間を知りたい時などにも困ります。

想定外エラーのハンドリング

もちろん想定できる例外は個別にハンドリングした方がいい場合が多いですが、ネットワークエラーや考慮漏れによる想定外のエラーが発生することも想定されます。その場合に、処理がabortするのはよくありません。

多重起動の防止

バッチをcronなどで定期実行している場合、何らかの理由で実行時間が想定をオーバーして、前回のバッチが終了していないのに次回のバッチが起動してしまうということもありえます。バッチの性質によっては障害に繋がります。

実装担当者が「このバッチは多重起動してもOK」と分かった上で、チェックしていないのであればよいのですが、そもそもそういう処理が必要な場合があるという知識がなかったり、考慮を忘れていたりするのであれば問題です。

結論

結論としては、以下のようなベースコマンドを実装しておくとよいのではないかと思っています。

from django.core.management.base import BaseCommand

import logging
import sys
logger = logging.getLogger(__name__)


class BaseProjectCommand(BaseCommand):
    def command_name(self)->str:
        raise NotImplementedError("コマンド名を記述してください。")
    
    def start_log(self):
        raise NotImplementedError("開始ログは必ず出力してください。 (ex. hoge_command start.)")
    
    def end_log(self):
        raise NotImplementedError("終了ログは必ず出力してください。 (ex. hoge_command end.)")
    
    def process(self, *args, **options):
        raise NotImplementedError("ビジネスロジックを記述してください。")
    
    def check_is_already_running(self)->bool:
        raise NotImplementedError("多重起動チェック処理を記述してください。不要な場合はFalseを返してください。")

    def handle(self, *args, **options):
        if self.check_is_already_running():
            logger.warning("プロセスが既に存在するので起動しません。[name=%s]", self.command_name())
        self.start_log()
        try:
            self.process(*args, **options)
        except Exception:
            logger.exception("%s end with error.", self.command_name())
            sys.exit(1)
        self.end_log()

加えてチームのコーディングルールとして以下を定めます。

  1. カスタムコマンドは必ずこのベースコマンドを継承して実装する
  2. ビジネスロジックprocessメソッドに実装する
  3. 想定外エラーのハンドリングは個別のバッチでは実装しない

継承先は例えば以下のように実装します。

from .base_project_command import BaseProjectCommand

import logging
logger = logging.getLogger(__name__)


class Command(BaseProjectCommand):
    def command_name(self) -> str:
        return "test_batch"
    
    def start_log(self):
        logger.info("%s start.", self.command_name())
    
    def end_log(self):
        logger.info("%s end.", self.command_name())

    def check_is_already_running(self) -> bool:
        return False
    
    def process(self, *args, **options):
        print("test")

説明

command_name

親クラス側でコマンド名を取得するために実装しています。

開始ログ/終了ログ

親クラス側でそれぞれstart_logend_logというメソッドを定義して、NotImplementedErrorを送出することで実装が漏れていたら気づけるようにしています。

command_nameを実装しているので、ログ出力まで親クラス側で実装してしまうこともできますが、以下の理由より実装は個別のバッチに任せた方が任せた方がいいケースが多いのかなと思っています。

  • 開始ログ: オプションなども出力したい場合がある
  • 終了ログ: 成功件数や失敗件数なども出力したい場合がある

想定外エラーのハンドリング

バッチ個別のビジネスロジックprocessメソッドに実装してもらうことで、想定外のエラーのハンドリングを親クラスで共通化しています。これによって以下のようなメリットがあると考えられます。

  • 実装担当者によって想定外エラーのハンドリングがあったりなかったりするのを防げる & やり方も統一化できる
  • バッチ実装の度にエラーハンドリングするコストを減らせる(若干ですが)

多重起動の防止

親クラス側でcheck_is_already_runningというメソッドを定義して、NotImplementedErrorを送出しています。もちろん、多重起動しても問題ないバッチも多いので、その場合はFalseを返せばいいようにしています。

こうやっておくことで、「多重起動しても問題ないバッチか?」ということを考慮したりチーム内で議論するきっかけになったりする効果があると思っています。

多重起動の防止の具体的な方法については、各々のバッチの実行環境(1つのバッチサーバで動いている or スケールさせているなど)やどの程度厳しく防ぎたいかによると思うので、ここでは触れません。私は簡単にgrepでチェックする方法や、DBにステータスを持って管理する方法の経験があります。

参考

以下の記事はこの記事よりも大分レベルが高い話ですが、バッチ設計時の考慮事項がだいたい書かれていて勉強になります。 engineering.mercari.com

追記

同僚からフィードバックを頂きました。

サンプルコードでsys.exit(1)しているところは、CommandErrorをラップして送出するのでもいいそうです。 たしかに、公式ドキュメントを読むと内部でsys.exit()しておりデフォルトのリターンコードは1であると書かれています。

from django.core.management.base import CommandError

# ...

except Exception as e:
        logger.exception("%s end with error.", self.command_name())
        raise CommandError from e

初めて将棋の指導対局を受けてきた

初めて指導対局を受けてきた

以下のイベントに参加させて頂き、初めてプロ棋士指導対局を受けてきました。

会場は三軒茶屋将棋倶楽部で、指導してくださったのは宮田利男先生です。 (今、竜王戦に挑戦されている伊藤匠七段の師匠です)

connpass.com

対局開始まで

会場に着くと、中央を囲むように机が並べられており、その上に参加者分の将棋盤が置かれていました。(約15人)

指導を受ける側が玉を取るのがマナーということは事前に聞いていたのですが、先生の側のみ既に王将を取った状態で駒が配置されており、その心配はありませんでした。

早めに着いたので、指導を受ける側の駒並べをお手伝いさせて頂きました。

駒落ちについて

初の指導対局ということで、何枚落とすか(あるいは平手で教わるか)はいつどうやって決めるのか分かっていませんでした。

私の棋力的には駒落ちで教わるのが妥当なのはわかっていましたが、平手でしか指したことがなく、銀多伝などの駒落ち定跡を全く知らないため、下手に慣れない手合いにするより平手で教わったほうが勉強になるのでは?という気もして揺れ動いていました。

「いつどうやって決めるのか」に関しては、指導対局開始のタイミンングで先生が最初に一巡して、一人一人希望の手合いを聞いて回っていました。 駒落ち希望の人にはその場で駒を落としていました。

私は最初に聞かれたので「どうしよう」と逡巡していたら、流れで平手で教わることになってしまいました。

対局の振り返り

最終盤はこのようになり、終盤の入り口でしたが、懇親会の会場を予約しているとのことで指し掛けになってしまいました。

終盤も教わりたかったので残念でしたが、ここまでを振り返りたいと思います。

駒組み

私は自信を持って指せる戦法が三間飛車しかないので、三間飛車にしました。 先生は4六銀左急戦でした。

先手が4五歩まで指した局面です。

基本的な形ですが、左銀の使い方と美濃囲いの上の歩をどの歩をどの順番で突くのがいいのか自信がなく指していました。(微妙な形の違いによって、別の仕掛けを誘発してしまったり同じ急戦定跡でも違う変化になったりする)

後から定跡書を確認したところ、変な手は指していなかったようですが、この辺はもっと自信をもって指せなければならない(=勉強不足)と感じました。

加えて、通常の四間飛車の定跡と比べて三間飛車かつ先後逆ということで、手得があるのでそれをどの手に使うのかという問題があります。

久保先生の『さばく!振り飛車教室』では4五歩をお勧めしていた記憶だったので、4五歩を指してみました。

ただ、4六に桂香を打たれるスペースができるので、自分の棋力で指しこなせるのかというのはあります。9八香とかの方が無難なのかもしれません。

仕掛け

先手が7八歩と指した局面です。

この定跡は左銀を飛車で狙われた時に逃げずに6五歩と突いて、角成りは桂ではなく飛車で取るということだけは覚えていたのですが、その先の変化は覚えていなかったので、まずはそこが反省点です。

藤井猛先生の『四間飛車を指しこなす本1』によると、ここでは部分的には7八歩は弱気であまりいい手ではなく、6七金と上がる方が良いそうです。

6七金は少し迷ったのですが、金が囲いから離れて負けを早めそうだなと思い、指せませんでした。

ただ、8六歩の突き捨てが入っているのが一番有名な変化とは違います。

突き捨てが入っているため、6七金にもやはり8六飛と回られてしまいます。

二枚飛車で攻められることを考えると、金は上がらない方がよいので、この局面では結果的に7八歩の方がマシだった可能性もありそうです。

どう指せばよかったか

同じく藤井猛先生の『四間飛車の急所 2 急戦大全【上】』によると、8六歩の突き捨てを入れてきた場合は、この局面に至る前の以下の局面で7四歩と垂らすのが部分的な手筋のようです。

ただ、これは自分では発見できない手なので、この機会に覚えようと思います。

中盤

先手が5九角と打った局面です。

次に4九飛成からの2枚替えを喰らっては終わりだと思い、それを防いだだけの手です。 この時、まだ周りに誰も終局した人がいなかったので、平手で教わっている手前、一番早く投了するのだけは避けたいという気持ちでした(汗)。

この局面では5二銀が入ったようです。

以下、一直線に取り合う変化は、最後に8五角という、王手で取られそうな金に紐をつける手があります。

ソフトで調べたところ、この局面では即詰みもあるようでしたが、それは私の棋力では読みきれないとしても、金に紐がついているので、3八金打で受かっていそうです。 (4六の角は取られるが、龍が急所から外れるかつ先手から厳しい手がたくさんある)

この変化が先手優勢になるため、恐らく銀を取り返されるのですが、5九に打つ駒が角から金になるだけでも全然違います。

この辺、2枚替えでダメと読みを打ち切ってしまうあたり、まだ読みが浅いんだなと反省しました。

感想

  • 1手1手時間が使える
    • 参加者が多かったので、1巡するまで考える時間がけっこうある。
  • 対面で教わるということで、ネット将棋と違って緊張感がある(=不利な局面でも投げやりに攻め合う手を心理的に指せない)
    • → 不利な局面で粘る手を指すというということを学べる
  • 貴重な機会なので、指して終わりではなく、このようにちゃんと振り返りをしようという気になる

というあたりが、指導対局をして頂けるメリットだなと思いました。(終局後にプロの先生に総括的なアドバイスを頂けるとさらに良いのでしょうが、今回はその時間はありませんでした)

宮田先生および企画してくださった方々に感謝を申し上げたいです。

盤面の画像について

Shogipicというクリエイティブ・コモンズ・ライセンスのサービスを使わせて頂きました。 shogipic.jp

DjangoでMySQLの全文検索機能を使う

はじめに

概要

この記事はDjango Advent Calendar 2021の19日目の記事です。
本記事ではDjangoMySQL全文検索機能を使う方法について紹介します。

前提として、DjangoMySQL全文検索機能を標準ではサポートしていません。従って、いくつかのノウハウが必要になります。

Djangoの公式ドキュメントには「Full text search」という項目がありますが、中身を読むとこれはPostgreSQL専用の機能です。
docs.djangoproject.com

検証に使用したバージョン

お断り

  • 本記事の内容を実務に投入したことはないので、そういったナレッジは話せません。

役に立つ可能性のある場面

先にまとめ

やること・気をつけることは大きく以下の4つです。

  • Migrationファイルに全文検索インデックス作成用のSQLコマンドを追加する。
  • Custom Lookupsを作成する。
  • 複数カラムで検索したいときの戦略を決める。
  • UnitTestにはTransactionTestCaseを使用する。

以下のリポジトリにデモを公開しています。
github.com

DjangoMySQL全文検索機能を標準でサポートしてない経緯

DjangoMySQL全文検索機能を標準ではサポートしていないと冒頭で書きました。

厳密には、古いバージョンのDjangoではsearchというMySQL全文検索機能用のlookup構文が標準で備わっていたようです。

しかし、

  • MySQL専用の機能である。
  • 機能がかなり制限されている。

という理由で1.10のリリースノートの時点で、deprecatedが宣言されていました。

docs.djangoproject.com

同リリースノートでは、代わりにCustom Lookupsを使うことが推奨されています。(以降で詳しく説明します。)

早速、やり方を説明していきます。

Migrationファイルに全文検索インデックス作成用のSQLコマンドを追加する

Modelを定義する

Modelの定義においては、全文検索インデックスを貼るフィールドだからといって、特別なことをする必要はありません。
以下のように普通にCharFieldTextFieldで宣言します。

class Novel(models.Model):
    # ...
    search_text = models.TextField() # 全文検索インデックスを貼るフィールド
    # ...
【補足】FTS_DOC_IDカラムについて

MySQL徹底入門 MySQL 8.0対応』に以下のような記述がありました。

フルテキストインデックスを使用する場合は、ドキュメントを一意に識別するためにFTS_DOC_IDという名前の列を上記の定義で作成します(名称もFTS_DOC_IDにする必要があります)。(…) FTS_DOC_ID列を明示的に作成しなかった場合は、自動的に非表示のFTS_DOC_ID列が作成されますが、明示的に作成したほうがパフォーマンスがよくなります。(p.137)

気になったので公式ドキュメントを確認したところ、FTS_DOC_IDについて以下のような記載がありました。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 15.6.2.4 InnoDB FULLTEXT インデックス

CREATE TABLE 時に FTS_DOC_ID カラムを定義すると、すでにデータがロードされているテーブルに全文インデックスを作成するよりもコストがかかりません。 データをロードする前に、テーブル上に FTS_DOC_ID カラムが定義されている場合は、新しいカラムが追加されるようにテーブルおよびそのインデックスを再構築する必要がありません。

公式ドキュメントによると、「パフォーマンスがよくなる」というのは、既にテーブルにレコードが存在する状態でインデックスを追加する際の話のようです。

多くのケースではメリットがなさそうかつ、テーブルにRDBの内部処理用のカラムを明示的に持つことによる保守性低下のデメリットの方が大きそうなので、本記事ではFTS_DOC_IDは定義しない方針とします。

Migrationファイルの編集

Djangoが生成したMigrationファイルに、migrations.RunSQL全文検索インデックス作成用のSQLコマンドを追加します。

class Migration(migrations.Migration):
    # ...
    operations = [
        migrations.CreateModel(
            name="Novel",
            fields=[
                # ...
                ("search_text", models.TextField()),
                # ...
            ],
            options={
                "db_table": "novel",
            },
        ),
        # 以下を追加
        migrations.RunSQL(
            "CREATE FULLTEXT INDEX search_text_fulltext_search_index"
            " ON novel (search_text)"
            " WITH PARSER ngram",
            "DROP INDEX search_text_fulltext_search_index ON novel",
        ),
    ]
説明
    raise IrreversibleError("Operation %s in %s is not reversible" % (operation, self))
django.db.migrations.exceptions.IrreversibleError: Operation <RunSQL 'CREATE FULLTEXT INDEX search_text_fulltext_search_index ON novel (search_text) WITH PARSER ngram'> in app.0002_novel is not reversible

Migrationを実行する

実行

Migrationを実行します。

$ python manage.py migrate
FULLTEXTインデックスの確認

以下のようにカラムにFULLTEXTインデックスが貼られていることを確認できます。

mysql> show indexes from  novel;
+-------+------------+-----------------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name                          | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+-------+------------+-----------------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| novel |          0 | PRIMARY                           |            1 | id          | A         |           0 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| novel |          1 | search_text_fulltext_search_index |            1 | search_text | NULL      |           0 |     NULL |   NULL |      | FULLTEXT   |         |               | YES     | NULL       |
+-------+------------+-----------------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.02 sec)

また、データを登録した後に、以下のコマンドを順番に実行すると、実際にバイグラムのインデックスが作成されていることが確認できます。(今回のデモアプリではDjango Adminから登録します。)

mysql> SET GLOBAL innodb_ft_aux_table= "demo_app/novel" ; -- DB名/テーブル名は読み替える。
mysql> SET GLOBAL innodb_optimize_fulltext_only=ON;
mysql> OPTIMIZE TABLE novel; -- テーブル名は読み替える。
mysql> SELECT * from INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE;

MySQL :: MySQL 8.0 リファレンスマニュアル :: 15.15.4 InnoDB INFORMATION_SCHEMA FULLTEXT インデックステーブル

例えば夏目漱石の『草枕』の冒頭を登録して、上記のコマンドで確認した結果は以下です。

mysql> select * from INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE;
+--------+--------------+-------------+-----------+--------+----------+
| WORD   | FIRST_DOC_ID | LAST_DOC_ID | DOC_COUNT | DOC_ID | POSITION |
+--------+--------------+-------------+-----------+--------+----------+
| 、こ   |            2 |           2 |         1 |      2 |       31 |
| う考   |            2 |           2 |         1 |      2 |       37 |
| えた   |            2 |           2 |         1 |      2 |       43 |
| がら   |            2 |           2 |         1 |      2 |       25 |
| こう   |            2 |           2 |         1 |      2 |       34 |
| た。   |            2 |           2 |         1 |      2 |       46 |
| なが   |            2 |           2 |         1 |      2 |       22 |
| ら、   |            2 |           2 |         1 |      2 |       28 |
| りな   |            2 |           2 |         1 |      2 |       19 |
| を登   |            2 |           2 |         1 |      2 |       13 |
| 山路   |            2 |           2 |         1 |      2 |        7 |
| 登り   |            2 |           2 |         1 |      2 |       16 |
| 考え   |            2 |           2 |         1 |      2 |       40 |
| 草枕   |            2 |           2 |         1 |      2 |        0 |
| 路を   |            2 |           2 |         1 |      2 |       10 |
+--------+--------------+-------------+-----------+--------+----------+
15 rows in set (0.01 sec)

Custom Lookupsを作成する

次にCustom Lookupsを作成します。
Custom Lookupsという機能は、私はこれまで使ったことがなかったのですが、Django ORMの表現を独自拡張できるものという理解です。

一般的なCustom Lookupsの追加の方法はDjangoの公式ドキュメントに記載があります。
Custom Lookups | Django documentation | Django

Custom Lookupsを定義する

前提として、素のMySQLにおける全文検索を利用したクエリーは以下のように書きます。

mysql> SELECT id, title FROM novel WHERE author_name = "夏目漱石" and MATCH(search_text) AGAINST("考えた" IN BOOLEAN MODE);
+----+--------+
| id | title  |
+----+--------+
|  1 | 草枕   |
+----+--------+
1 row in set (0.01 sec)


上記のMATCH({検索対象カラム}) AGAINST("{検索キーワード}" IN BOOLEAN MODE)の部分を表現するためのsearchというMySQL全文検索用のCustom Lookupsを定義します。

demo/app/lookups.py

from django.db import models


class Search(models.Lookup):

    lookup_name = "search"

    def as_mysql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params
        return "MATCH (%s) AGAINST (%s IN BOOLEAN MODE)" % (lhs, rhs), params
説明

Lookupを登録する

作成したsearchDjangoに登録して使えるようにします。

demo/app/lookups.py

from django.db import models


@models.CharField.register_lookup
@models.TextField.register_lookup
class Search(models.Lookup):
    # ...

demo/app/apps.py

from django.apps import AppConfig


class AppConfig(AppConfig):
    # ...
    def ready(self):
        from . import lookups
説明
  • 公式のサンプルでは、親クラスのFieldに直接登録していますが、今回対象となるフィールドは文字列系のフィールドだけなので、CharFieldTextFieldにのみ登録します。
  • Lookupを登録する方法は、関数で登録する方式とdecorator方式がありますが、decorator方式の方が凝集度が高いので、後者を採用しました。

Lookupを使う

これで以下のようにDjango ORMでMySQL全文検索機能を使えるようになります。

novels = Novel.objects.filter(author_name="夏目漱石").filter(search_text__search="考えた")

【念の為】SQLインジェクション安全なことの確認

念の為、Custom Lookupsを使った場合はSQLに渡したパラメータがエスケープされていることを確認しておきます。
デモアプリの画面から以下のような文字列を入力してみます。
hoge' IN BOOLEAN MODE) or 1 = 1 --
パラメータがエスケープされていれば検索結果が0件になるはずです。

結果

検索結果は0件となり、Debug Toolbarより、発行されたSQLエスケープされていることが確認できました。

f:id:kamatimaru:20211212231037p:plain

f:id:kamatimaru:20211212231051p:plain

複数カラムで検索したいときの戦略を決める

実際のアプリケーションでは検索対象としたいカラムが一つだけことは少ないと思います。

今回のデモアプリでも小説(novel)を検索したいので、タイトル(title)と内容(content)で全文検索したいです。

このような場合の戦略について考えます。

前提

素のMySQLでは2つ以上のカラムを全文検索できる

前提として、素のMySQLでは、FULLTEXTインデックスが貼られていれば、以下のように2つ以上のカラムであっても全文検索できます。

CREATE FULLTEXT INDEX title_content_fulltext_search_index  ON novel (title, content) WITH PARSER ngram;
SELECT id, title FROM novel WHERE MATCH(title, content) AGAINST("テスト" IN BOOLEAN MODE);

注意点としては、FULLTEXTインデックスを貼ったカラムに対してMATCHで指定するカラムに過不足があるとエラーになります。

mysql> SELECT id, title FROM novel WHERE MATCH(content) AGAINST("テスト" IN BOOLEAN
ERROR 1191 (HY000): Can't find FULLTEXT index matching the column list

titlecontentに複合でFULLTEXTインデックスを貼っているのに、contentしか指定していないので、エラーになっている。

DjangoCustom Lookupsの制約

対して、DjangoCustom Lookupsでは、公式ドキュメントを読んだ限り以下のような複数のカラムを対象としたSQLは表現できなそうです。

SELECT id, title FROM novel WHERE MATCH(title, content) AGAINST("テスト" IN BOOLEAN MODE);

従って、DjangoCustom Lookupsを使いたいのであれば、このようなSQLは諦めます。

戦略

以上を踏まえた上で、以下の2つの戦略が考えられそうです。

  • 全文検索専用のカラムを定義して、そこに検索対象としたいカラムの値を全て突っ込む。
    • → Custom Lookupsを使う。
  • ORMで表現せずに生のSQLを書く。
全文検索専用のカラムを定義する

本記事およびデモアプリで採用している戦略です。
今まで出てきたsearch_textは、実は全文検索専用のフィールドとして想定していました。

実際にデモアプリでは、以下のようにDjango Adminから登録する際に、titlecontentを突っ込んでいます。

demo/app/admin.py

class NovelAdmin(admin.ModelAdmin):
    readonly_fields = ["search_text"]

    def save_model(self, request, obj, form, change):
        obj.search_text = f"{obj.title},{obj.content}"
        super().save_model(request, obj, form, change)

この戦略であれば、事実上、Django ORMでの複数フィールドの検索を実現できます。
※ 人によってはフレームワークの制約でカラムが増えるのが気になるかもしれません。

ちなみに、上記のサンプルコードのように「,(カンマ)」で連結すると、バイグラムの場合「,山」のようなゴミインデックスができるのではないか?と気持ち悪く感じる方もいるかもしれませんが、「,(カンマ)」はストップワード扱いされるのでこのようなインデックスは作成されません。(検証済み)

MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10.8 ngram 全文パーサー
→ 「ngram パーサーのストップワード処理」のセクションを参照

ORMで表現せずに生のSQLを書く。

もちろんSQLインジェクション対策はするという前提で、生のSQLを書くという選択肢もあるのかなと思います。

※ 私自身はDjangoで開発していて、生のSQLを書く必要に迫られたことがないということもあり、本記事では深掘りしません。

UnitTestにはTransactionTestCaseを使用する

最後にUnitTestについてです。

前提として、MySQL全文検索インデックスはコミット時に作成されます。
stackoverflow.com

これを踏まえた上で、django.test.TestCaseは1つのテストメソッドの実行中にコミットを行いません。

some database behaviors cannot be tested within a Django TestCase class. For instance, you cannot test that a block of code is executing within a transaction, as is required when using select_for_update(). In those cases, you should use TransactionTestCase.

従って、全文検索機能のテストではdjango.test.TransactionTestCaseを使用する必要があります。

TransactionTestCaseを使用すると、コミットする分テストの実行時間が遅くなるので、必要な箇所だけ使用するのがよいと思います。

これは成功する

from django.test import TransactionTestCase

from .factories import NovelFactory
from app.models import Novel


class TestSearchNovel(TransactionTestCase):
    def test_it(self):
        NovelFactory(
            title="草枕",
            search_text="山路を登りながら、こう考えた。",
        )
        novels = Novel.objects.filter(search_text__search="山路")
        self.assertEqual(len(novels), 1)
        self.assertEqual(novels[0].title, "草枕")

これは失敗する

from django.test import TestCase

from .factories import NovelFactory
from app.models import Novel


class TestSearchNovel(TestCase):
    def test_it(self):
        NovelFactory(
            title="草枕",
            search_text="山路を登りながら、こう考えた。",
        )
        novels = Novel.objects.filter(search_text__search="山路")
        self.assertEqual(len(novels), 1)
        self.assertEqual(novels[0].title, "草枕")
Traceback (most recent call last):
  File "/path/to/django_mysql_full-text-search_demo/demo/app/tests/test_queries.py", line 14, in test_it
    self.assertEqual(len(novels), 1)
AssertionError: 0 != 1

おわりに

記事は以上です。

読んでいただいた方ありがとうございます。

私自身が以前に趣味で開発したアプリでMySQL全文検索機能をDjangoで使おうとしたところ、WEB上の情報が断片的だったり古かったりして苦労したので、この機会にまとめてみました。