Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
2
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

@Shirataki2

Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携

はじめに

この記事は前回Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)の続きです.

本記事では,Botが大規模になるにつれて必要となるであろうデータベースとの連携を行います.データベースを利用してサーバーごとに接頭文字を変える機能$prefixを実装します.

全7回を予定しており現在5記事まで執筆を終えています.

  1. Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.py
  2. Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)
  3. Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携
  4. Pythonで始める録音機能付きDiscord Bot: (4) 音楽ファイルを再生する
  5. Pythonで始める録音機能付きDiscord Bot: (5) Discord APIを直接操作する

環境変数を利用する

ここまで,Botのトークンはソースコード内にべた張りしていましたがこれでは,GitHub等で第三者と共有したいときにきわめて不便です.なので,Docker Composeの機能を使いこれらを環境変数としてまとめます.

まずは,プロジェクトルートに.envというファイルを作り,そこに環境変数を登録します.

./.env
BOT_TOKEN=NDIAHJffoaj.adwdeg....

ここではBOT_TOKEN=トークンとしています.こうして登録された環境変数をDockerコンテナ上で利用可能にするためにはdocker-compose.dev.ymlを編集します.

./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のトークン部分を以下のように変更します.

./src/app/dbot/__main__.py
from dbot.core.bot import DBot
import os
DBot(os.environ["BOT_TOKEN"]).run()

これで知られたくない環境変数などの情報を別ファイルのまとめることで,このファイルのみ第三者に非公開の形式でアップロードすることで目的が達成されるようになりました.例えばこの.envをGitHubにプッシュしたくなければ新たに.gitignoreというファイルを作成し,.envを追加することでGitの監視対象にならずリモートにもプッシュされません.

.gitignore
.env

コンテナを再起動し,正常に起動できていたら成功です.環境変数ファイルを編集した場合,反映されるのはコンテナの再起動後となるので注意してください.

データベースを利用しよう

Botが複雑になるにつれてサーバーごとに何かしらのデータを保存し,それを活用できるようにしたいといった場合が出てくることでしょう.それを実現する手段として,例えばCSVファイルを用意してそれを直書きするといった方法が考えられますが,ユーザからのリクエストが非同期的に来ることを考えると色々問題があります.そこで今回はデータベースを利用してデータを保存するようにしてみましょう.

データベースの作成

ここではMySQLを利用しますが,お気に入りのデータベースエンジンが別にあれば何でもOKです.MySQLサービスもDockerコンテナとして立ててしまいましょう.docker-compose.dev.ymlを以下のように編集します.以下の書き方はこの記事に倣って書いています..gitignoreを作成している方は/dbを除外するようにします.

./docker-compose.dev.yml
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に記述します.

./.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.pyalembic.iniです.alembic.iniは以下のようにmysql+pymysql://ユーザー名:パスワード@データベースのコンテナ名/データベース名という形式で指定します.

./src/app/alembic/alembic.ini
# 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までのパスは親ディレクトリあるので以下のように編集します

./src/app/alembic/env.py
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を編集します.

./src/app/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のインストールを行います.

./src/dev.dockerfile
# 前略
RUN pip install alembic

これでようやく準備が完了です.Dockerコンテナを起動するごとに,マイグレーションが実行されるようになります.

スキーマ変更を自動感知するようにできたので,これらを今度はBotから利用できるようにしましょう.

DBotとMySQLの橋渡し

データベースと接続するためのクラスを定義するため,./src/app/dbot/db.pyを作成します.

./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を作り以下のように編集します.

./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コマンドを実装

あらためて,接頭文字を変更するためには以下の手順を踏みます.

  1. [大前提]サーバーごとに接頭文字を変えられる仕様に変更する
  2. サーバーがBotを追加した際にテーブル"guild"に(サーバーID, "$")というレコードを追加する
  3. $prefix >のようなコマンド来た際にテーブル"guild"を変更する

サーバーごとに接頭文字を変える方法ですが,これは今までBotの__init__内でcommand_prefixで直に"$"と渡していた部分をNoneに変更し別途get_prefixというコルーチンを作成します.discord.pyはメッセージが打たれるごとにこのget_prefixをチェックするのでそこでサーバーのIDを取得しデータベースから情報を取得すればよいのです.

サーバーがBotを追加したイベントを受け取るためには前回紹介したイベントハンドラon_guild_joinを定義すればよいのでした.これらを考慮すると./src/app/dbot/core/bot.pyは以下のように変更できます.

./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を定義することにします.

./src/app/cogs/Utils.py
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が変更可能になりました:tada:

Image from Gyazo

prefixコマンドの引数に着目すると,第三引数にあたる位置に*がありますが,これもPythonの文法のひとつです.discord.pyではこの*を利用することで以下のような振る舞いになります.

Image from Gyazo

この通り,空白が間にあろうともそれら一帯を一つの引数と見なします.

参考: https://discordpy.readthedocs.io/ja/latest/ext/commands/commands.html#keyword-only-arguments

終わりに

これでデータベースと接続してより複雑なコマンドが作れるようになりました.

次回は音声の送信機能を実装します.

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
2
Help us understand the problem. What are the problem?