はじめに
この記事は前回Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)の続きです.
本記事では,Botが大規模になるにつれて必要となるであろうデータベースとの連携を行います.データベースを利用してサーバーごとに接頭文字を変える機能$prefix
を実装します.
全7回を予定しており現在5記事まで執筆を終えています.
- Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.py
- Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)
- Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携
- Pythonで始める録音機能付きDiscord Bot: (4) 音楽ファイルを再生する
- Pythonで始める録音機能付きDiscord Bot: (5) Discord APIを直接操作する
環境変数を利用する
ここまで,Botのトークンはソースコード内にべた張りしていましたがこれでは,GitHub等で第三者と共有したいときにきわめて不便です.なので,Docker Composeの機能を使いこれらを環境変数としてまとめます.
まずは,プロジェクトルートに.env
というファイルを作り,そこに環境変数を登録します.
BOT_TOKEN=NDIAHJffoaj.adwdeg....
ここではBOT_TOKEN=トークン
としています.こうして登録された環境変数をDockerコンテナ上で利用可能にするためにはdocker-compose.dev.yml
を編集します.
version: "3.8"
services:
dbot:
build:
context: ./src
dockerfile: dev.dockerfile
tty: true
working_dir: /bot/app
entrypoint: bash ./entrypoint.dev.sh
env_file: # この行と
- .env # この行
volumes:
- ./src:/bot
env_file
に先ほどのファイルへのパスを渡すことで作成した環境変数がコンテナに渡されます.
そして,いままで直打ちしていた__main__.py
のトークン部分を以下のように変更します.
from dbot.core.bot import DBot
import os
DBot(os.environ["BOT_TOKEN"]).run()
これで知られたくない環境変数などの情報を別ファイルのまとめることで,このファイルのみ第三者に非公開の形式でアップロードすることで目的が達成されるようになりました.例えばこの.env
をGitHubにプッシュしたくなければ新たに.gitignore
というファイルを作成し,.env
を追加することでGitの監視対象にならずリモートにもプッシュされません.
.env
コンテナを再起動し,正常に起動できていたら成功です.環境変数ファイルを編集した場合,反映されるのはコンテナの再起動後となるので注意してください.
データベースを利用しよう
Botが複雑になるにつれてサーバーごとに何かしらのデータを保存し,それを活用できるようにしたいといった場合が出てくることでしょう.それを実現する手段として,例えばCSVファイルを用意してそれを直書きするといった方法が考えられますが,ユーザからのリクエストが非同期的に来ることを考えると色々問題があります.そこで今回はデータベースを利用してデータを保存するようにしてみましょう.
データベースの作成
ここではMySQLを利用しますが,お気に入りのデータベースエンジンが別にあれば何でもOKです.MySQLサービスもDockerコンテナとして立ててしまいましょう.docker-compose.dev.yml
を以下のように編集します.以下の書き方はこの記事に倣って書いています..gitignore
を作成している方は/db
を除外するようにします.
version: "3.8"
services:
dbot:
# 略
mysql:
image: mysql:8.0
restart: always
env_file:
- .env
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- ./db/data:/var/lib/mysql
- ./db/my.cnf:/etc/mysql/conf.d/my.cnf
- ./db/sql:/docker-entrypoint-initdb.d
先ほどの記事にあるようにMySQLのコンテナは環境変数に初めに作成するデータベースの名前や,ユーザーのパスワードを入力する必要があるためそれらをまとめて.env
に記述します.
BOT_TOKEN=...
MYSQL_ROOT_PASSWORD=supersecret
MYSQL_USER=docker
MYSQL_DATABASE=discord
MYSQL_PASSWORD=veryverysecret
ここまで作成し終えたら,./run.sh dev down
と./run.sh dev up -d mysql
を打ってデータベースだけ起動させておくようにしましょう.
データベースを操作するためにはSQLを操らなければいけませんが,極力データベースのスキーマだけを定義しマイグレーション作業などは自動で行った方が楽でしょう.
ORMとMigrationを使うには...
そこで今回はSQLのオブジェクト関係マッピング(ORM)であるSQLAlchemyを利用します.そして,SQLAlchemyを動かすにはデータベースを操作するクライアントが必要となりますが,ここでは非同期でノンブロッキングという要件を満たすクライアントであるaiomysqlを使用します.
そして,データベースのマイグレーションツールとしてPythonで書かれたAlembicを使用します.これら三者をまずはインストールします.
./src/app
で
$ pipenv install sqlalchemy aiomysql
$ pipenv install alembic --dev
を実行します.
インストール後,./src/app/dbot/models
フォルダを作成し以下のファイルを作成します.
__init__.py
model.py
model.py
を以下のように編集します.
from sqlalchemy import MetaData, Table, Column, BigInteger, String
meta = MetaData()
guild = Table(
"guild",
meta,
Column("id", BigInteger(), nullable=False, primary_key=True),
Column("prefix", String(8), server_default="$", nullable=False)
)
SQLAlchemy独特の文法ですがTableとColumnを組み合わせて一つのテーブルを定義しています.Tableの第一引数にはテーブル名,第三引数以降はテーブルの列情報を記しているのですが,第二引数のmetaの正体はなにかというとデータベースの定義情報がすべて格納されている変数に相当します.このmetaを外部に渡すことでSQLAlchemyで作成したデータベースの情報を利用できるようになります.
ここで作成したテーブルは,サーバーごとに接頭文字($
)を変えるためのテーブルです.
Alembicではこのmetaをもとにデータベースのマイグレーションを行います.すなわち,新たなテーブルを作成したい際,開発者はSQLを使って直にテーブルを作る必要がなく,スキーマの定義に専念できるようになります.
Alembicを利用するためにはalembic init
コマンドを打って初期設定を行う必要があります../src/app/alembic
フォルダを作成し,alembic init .
をそのフォルダ内で実行すると様々なファイルが生成されます.
編集するファイルはその中のうちenv.py
とalembic.ini
です.alembic.ini
は以下のようにmysql+pymysql://ユーザー名:パスワード@データベースのコンテナ名/データベース名
という形式で指定します.
# A generic, single database configuration.
[alembic]
# 略
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = mysql+pymysql://docker:veryverysecret@mysql/discord
# 略
env.py
は先ほどのmetaをインポートする必要があるのですが,dbotまでのパスは親ディレクトリあるので以下のように編集します
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
## 以下を追加 ##
import sys
import os
sys.path.append(os.pardir)
from dbot.models.model import meta
## ここまで ##
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# 略
## この値を変更する
target_metadata = meta
# 略
こうしたうえでalembic
フォルダ上でalembic revision -m "Init"
のように実行するとversions
フォルダ下に,metaを読み込んだ結果生成されたスキーマファイルが作成されます.
これを既存のデータベースにマイグレーションすればよいのですがそのためのコマンドはalembic upgrade head
です.これらのコマンドを実行するためにentrypoint.dev.sh
を編集します.
set -eu
cd alembic
alembic upgrade head
alembic revision --autogenerate
alembic upgrade head
cd ..
nodemon --signal SIGINT -e py,ini --exec python -m dbot
最後にdev.dockerfile
の最終行でalembic
のインストールを行います.
# 前略
RUN pip install alembic
これでようやく準備が完了です.Dockerコンテナを起動するごとに,マイグレーションが実行されるようになります.
スキーマ変更を自動感知するようにできたので,これらを今度はBotから利用できるようにしましょう.
DBotとMySQLの橋渡し
データベースと接続するためのクラスを定義するため,./src/app/dbot/db.py
を作成します.
import os
import asyncio
from aiomysql.sa import create_engine
class DB:
async def __aenter__(self, loop=None):
if loop is None:
loop = asyncio.get_event_loop()
engine = await create_engine(
user=os.environ["MYSQL_USER"],
db=os.environ["MYSQL_DATABASE"],
host="mysql",
password=os.environ["MYSQL_PASSWORD"],
charset="utf8",
autocommit=True,
loop=loop
)
self._connection = await engine.acquire()
return self
async def __aexit__(self, *args, **kwargs):
await self._connection.close()
async def execute(self, query, *args, **kwargs):
return await self._connection.execute(query, *args, **kwargs)
以上の実装はこちらの記事を参考にしています.見慣れない__aenter__
などというコルーチンはwith
とともに利用されます.aenterはa
(=async)+enter
という意味になるので,このデータベースに対する接続を得るためには
async with DB() as db:
db.execute("クエリ")
のようにすることで利用ができるようになります.
SQLなど触りとうない
最後に,いくらORMとはいえ極力SQLなんてものは書きたくありません.そこで,テーブルごとにデータのCRUD(作成/取得/更新/削除)ができるようなクラスを作成します../src/app/dbot/models/guild.py
を作り以下のように編集します.
from dbot.models import model
from dbot.db import DB
class CRUDBase:
@staticmethod
async def execute(query, *args, **kwargs):
async with DB() as db:
result = await db.execute(query, *args, **kwargs)
return result
class Guild(CRUDBase):
def __init__(self, guild_id):
self.guild_id = guild_id
async def get(self):
q = model.guild.select().where(self.guild_id == model.guild.c.id)
result = await self.execute(q)
return await result.fetchone()
async def set(self, **kwargs):
q = model.guild.update(None).where(
self.guild_id == model.guild.c.id
).values(**kwargs)
await self.execute(q)
return self
async def delete(self):
q = model.guild.delete(None).where(self.guild_id == model.guild.c.id)
await self.execute(q)
return self
@classmethod
async def create(cls, guild_id):
q = model.guild.insert(None).values(id=guild_id)
guild = cls(guild_id)
await cls.execute(q)
return guild
@staticmethod
async def get_all(cls):
q = model.guild.select()
results = await cls.execute(q)
return await results.fetchall()
SQLAlchemyのクエリの書き方の説明はこの記事の範疇外ですので省略しますが,SQLに似た文法でクエリを書くことができます.
これで開発中はいちいちSQLのことを気にせずawait Guild(guild.id).get()
のようにすれば,データベースから情報を取り出せるようになります.
$prefixコマンドを実装
あらためて,接頭文字を変更するためには以下の手順を踏みます.
- [大前提]サーバーごとに接頭文字を変えられる仕様に変更する
- サーバーがBotを追加した際にテーブル"guild"に(サーバーID, "$")というレコードを追加する
-
$prefix >
のようなコマンド来た際にテーブル"guild"を変更する
サーバーごとに接頭文字を変える方法ですが,これは今までBotの__init__
内でcommand_prefix
で直に"$"と渡していた部分をNone
に変更し別途get_prefix
というコルーチンを作成します.discord.pyはメッセージが打たれるごとにこのget_prefixをチェックするのでそこでサーバーのIDを取得しデータベースから情報を取得すればよいのです.
サーバーがBotを追加したイベントを受け取るためには前回紹介したイベントハンドラon_guild_join
を定義すればよいのでした.これらを考慮すると./src/app/dbot/core/bot.py
は以下のように変更できます.
import discord
from discord.ext import commands
from dbot.models.guild import Guild
import traceback
class DBot(commands.Bot):
def __init__(self, token):
self.token = token
super().__init__(command_prefix=None)
self.load_cogs()
async def get_prefix(self, message: discord.Message):
guild = await Guild(message.guild.id).get()
if guild:
print("サーバー:", message.guild.name)
print("接頭文字:", guild.prefix)
return guild.prefix
else:
guild = await Guild.create(message.guild.id)
guild = await guild.get()
print("サーバー:", message.guild.name)
print("接頭文字:", guild.prefix)
return guild.prefix
async def on_guild_join(self, guild: discord.Guild):
guild = await Guild.create(guild.id)
guild = await guild.get()
print("サーバー:", guild.name)
print("接頭文字:", guild.prefix)
# 略
行っていることは単純で,サーバーIDをもとにレコードの取得と挿入を行っています.
あとはコマンド$prefix
を実装するだけです.Utils
というCogを作成しそこに$prefix
を定義することにします.
import discord
from discord.ext import commands
from discord.ext.commands.errors import (
MissingPermissions,
MissingRequiredArgument
)
import random
from dbot.core.bot import DBot
from dbot.models.guild import Guild
class Utils(commands.Cog):
def __init__(self, bot: DBot):
self.bot = bot
@commands.command(ignore_extra=False)
@commands.has_permissions(administrator=True)
async def prefix(self, ctx: commands.Context, *, prefix: str):
if len(prefix) > 8:
return await ctx.send("Prefixは8文字以内である必要があります")
guild = await Guild(ctx.guild.id).get()
await Guild(ctx.guild.id).set(prefix=prefix)
await ctx.send(f"Prefixを{guild.prefix}から{prefix}に変更しました")
@prefix.error
async def on_prefix_error(self, ctx: commands.Context, error):
if isinstance(error, MissingPermissions):
return await ctx.send('管理者のみが実行可能です')
if isinstance(error, MissingRequiredArgument):
return await ctx.send('引数は新しいPrefixを8文字以内で渡してください')
raise error
def setup(bot):
return bot.add_cog(Utils(bot))
これでprefixが変更可能になりました
prefixコマンドの引数に着目すると,第三引数にあたる位置に*
がありますが,これもPythonの文法のひとつです.discord.pyではこの*
を利用することで以下のような振る舞いになります.
この通り,空白が間にあろうともそれら一帯を一つの引数と見なします.
参考: https://discordpy.readthedocs.io/ja/latest/ext/commands/commands.html#keyword-only-arguments
終わりに
これでデータベースと接続してより複雑なコマンドが作れるようになりました.
次回は音声の送信機能を実装します.