DjangoでRailsのようなログ出力設定をする

背景

Djangoでviewを実装し、ぽちぽち動かしているときに、どんなクエリが実行されているかを確認できるとうれしいと思いました。
Railsだとデフォルトでそのようになっているのですが、Djangoは設定が必要だったので設定してみました。

設定

設定内容は以下になります

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
}
},
'loggers': {
'django.db.backends': {
'level': 'DEBUG',
'handlers': ['console'],
},
}
}

設定内容を詳しくみていきます

設定の詳細

まず公式ドキュメントを確認します
ロギング | Django ドキュメント | Django
公式ドキュメントの内容に沿って確認していきます

LOGGING

公式ドキュメントには

n order to configure logging, you use LOGGING to define a dictionary of logging settings. These settings describe the loggers, handlers, filters and formatters that you want in your logging setup, and the log levels and other properties that you want those components to have.

と記載されており、LOGGINGというdictionaryの定数を定義することでロギングの設定ができることがわかります。

LOGGING内の要素

version

versionについて、公式ドキュメントには記載がありませんが、例に記載されているように記述しておきます。
試しにversionを削除したところ、

1
2
3
web_1  |   File "/usr/local/lib/python3.10/logging/config.py", line 498, in configure
web_1 | raise ValueError("dictionary doesn't specify a version")
web_1 | ValueError: dictionary doesn't specify a version

というエラーが出てしまいました。なので、記述しておきます。

disable_existing_loggers

disable_existing_loggersについて、公式ドキュメントには以下のように記載されています。

LOGGINGdisable_existing_loggers キーの値を True にすると、全てのデフォルトの設定が無効になります(キーが存在しない場合は dictConfig のデフォルトになります)。このため、 ’disable_existing_loggers’: True を使う場合は注意してください。 True を設定する必要は殆どないでしょう。 disable_existing_loggersFalse に設定して、デフォルトのロガーの一部、または全てを定義しなおすこともできます。あるいは、 LOGGING_CONFIGNone に設定して、 ロギングの設定を自分で行うこと も出来ます。

基本的にはFalseで良さそうですね。

handlers

[公式ドキュメント](ロギング | Django ドキュメント | Django)に記載があります。

Handlerはloggerから指定されます。今回の例で言うと、console
という名のハンドラを定義し、ログレベルと、ログを処理するハンドラクラスを指定します。

上記で指定しているlogging.StreamHandlerについてはPythonの公式ドキュメントに記載がありました
logging.handlers — ロギングハンドラ — Python 3.10.0b2 ドキュメント

StreamHandler クラスの新たなインスタンスを返します。 stream が指定された場合、インスタンスはログ出力先として指定されたストリームを使います; そうでない場合、 sys.stderr が使われます。

docker-compose upで動作させている時は、実行しているコンソールが標準出力なので、そのコンソールにログが出力されると言うことですね

loggers

[公式ドキュメント](ロギング | Django ドキュメント | Django)に記載があります

キー(今回でいうところのdjango.db.backends)は、パッケージのパスを指定します。そのパッケージ内で出力されたログは、ロガーで指定したログレベルと比較して同等以上の場合にロガーを通じてハンドラに渡されます。

以上で設定はすべて見終わりました。

詳細なログ設定

公式ドキュメントにあった詳細なログ設定も記載しておきます。パッケージごとに細分化されています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'filters': {
'special': {
'()': 'project.logging.SpecialFilter',
'foo': 'bar',
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'console': {
'level': 'INFO',
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
'formatter': 'simple'
},
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler',
'filters': ['special']
}
},
'loggers': {
'django': {
'handlers': ['console'],
'propagate': True,
},
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': False,
},
'myproject.custom': {
'handlers': ['console', 'mail_admins'],
'level': 'INFO',
'filters': ['special']
}
}
}

フォーマッタは見やすくするために利用することもあるかもしれません。

動作確認

設定前と設定後でどのようにコンソールの出力が変わったか見てみます。

設定前

1
2
3
4
5
web_1  | Starting development server at http://0.0.0.0:8000/
web_1 | Quit the server with CONTROL-C.
web_1 | [08/Feb/2022 11:36:59] "GET /accounts/login/?next=/ HTTP/1.1" 200 1892
web_1 | [08/Feb/2022 11:37:05] "POST /accounts/login/?next=/ HTTP/1.1" 302 0
web_1 | [08/Feb/2022 11:37:05] "GET / HTTP/1.1" 200 1176

設定後

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
web_1  | Starting development server at http://0.0.0.0:8000/
web_1 | Quit the server with CONTROL-C.
web_1 | [08/Feb/2022 11:39:16] "GET /accounts/login/ HTTP/1.1" 200 1892
web_1 | (0.001)
web_1 | SELECT VERSION(),
web_1 | @@sql_mode,
web_1 | @@default_storage_engine,
web_1 | @@sql_auto_is_null,
web_1 | @@lower_case_table_names,
web_1 | CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
web_1 | ; args=None; alias=default
web_1 | (0.000) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None; alias=default
web_1 | (0.001) SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`email` = 'test@example.com' LIMIT 21; args=('test@example.com',); alias=default
web_1 | (0.001) SELECT (1) AS `a` FROM `django_session` WHERE `django_session`.`session_key` = 'xxxxxxxxxxxxxxxxxxxxxxx' LIMIT 1; args=('xxxxxxxxxxxxxxxxxxxxxxx',); alias=default
web_1 | (0.000) INSERT INTO `django_session` (`session_key`, `session_data`, `expire_date`) VALUES ('xxxxxxxxxxxxxxxxxxxxxxx', 'yyyyyyyyyyyyyyyyyyyy', '2022-02-22 02:39:29.502823'); args=('xxxxxxxxxxxxxxxxxxxxxxx', 'yyyyyyyyyyyyyyyyyyyy', '2022-02-22 02:39:29.502823'); alias=default
web_1 | (0.004) UPDATE `auth_user` SET `last_login` = '2022-02-08 02:39:29.509067' WHERE `auth_user`.`id` = 1; args=('2022-02-08 02:39:29.509067', 1); alias=default
web_1 | (0.001) UPDATE `django_session` SET `session_data` = 'zzzzzzzzzzzzzzzzzzzzzzzz', `expire_date` = '2022-02-22 02:39:29.515114' WHERE `django_session`.`session_key` = 'xxxxxxxxxxxxxxxxxxxxxxx'; args=('zzzzzzzzzzzzzzzzzzzzzzzz', '2022-02-22 02:39:29.515114', 'xxxxxxxxxxxxxxxxxxxxxxx'); alias=default
web_1 | [08/Feb/2022 11:39:29] "POST /accounts/login/ HTTP/1.1" 302 0
web_1 | (0.001) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None; alias=default
web_1 | (0.001) SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-02-08 02:39:29.536012' AND `django_session`.`session_key` = 'xxxxxxxxxxxxxxxxxxxxxxx') LIMIT 21; args=('2022-02-08 02:39:29.536012', 'xxxxxxxxxxxxxxxxxxxxxxx'); alias=default
web_1 | (0.001) SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 21; args=(1,); alias=default
web_1 | [08/Feb/2022 11:39:29] "GET / HTTP/1.1" 200 1176

実行されているSQLがログに表示されるようになりました!

ログの種類によって色をわける

どうしてもRailsをベースに考えてしまい、ログの種類によって色が変わるのがいいと思って調べてみると、colorlogというパッケージが見つかりました。

Poetryでインストール後、LOGGINGを以下のように書き換えます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'color': {
'()': 'colorlog.ColoredFormatter',
'format': '%(log_color)s%(levelname)-8s %(message)s',
'log_colors': {
'DEBUG': 'bold_black',
'INFO': 'white',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'bold_red',
},
},
},
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'color',
}
},
'loggers': {
'django.db.backends': {
'level': 'DEBUG',
'handlers': ['console'],
},
}
}

formattersで指定したlog_colorsは例に載っていたものをそのまま利用しました。ここをお好みで調整すると色が変わります。

まとめ

開発デバッグ作業を効率よく行うには情報を増やすことが必要です。LOGGINGの設定であれば、一度行ってしまえばそのままずっと使えるのでしっかりと理解して設定するのがよいと思いました。

参考図書

データベース名を変更したらマイグレーションエラー MySQLdb._exceptions.DataError (1406, Data too long for column 'name') が発生したときの対処法

背景

Djangoでモデル実装をして、マイグレーションを実行しました。
その後、テータベース名が適切ではないという話になり、データベース名を変更することになりました。

其の際に思わぬエラーが発生したので記しておきます。

設定ファイルのデータベース名の変更

設定ファイル内でデータベース名の記載があったのは二つでした。

  • docker-compose.yml
  • front/settings.py

それぞれ新しいデータベース名に変更します。(frontはアプリケーション名)

新規データベースの作成

データベースのrenameができないということと、個々のテーブルを別データベースの下にrenameするのは数が多くて大変だったので、新規でデータベースを作成し、権限を付与した後にマイグレーションすることにしました。

データベースコンテナを起動した状態で、アプリケーションコンテナにログインして、mysqlコマンドでデータベースに接続しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ docker-compose run --rm web /bin/bash
Creating example_web_run ... done
root@ddad35e91809:/code# mysql -u root -p -h db
Enter password:
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MySQL connection id is 30
Server version: 5.7.36 MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> create database new_db;
Query OK, 1 row affected (0.002 sec)

MySQL [(none)]> grant all privileges on `new_db`.* to 'django'@'%';
Query OK, 0 rows affected (0.002 sec)

MySQL [(none)]> grant all privileges on `test_new_db`.* to 'django'@'%';
Query OK, 0 rows affected (0.002 sec)

MySQL [(none)]> exit
Bye
root@ddad35e91809:/code#
root@ddad35e91809:/code# exit
exit
$

マイグレーションでエラー発生

これでマイグレーションすれば大丈夫と思っていて、マイグレーションを実行しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
$ docker-compose run --rm web python manage.py migrate front
Creating example_web_run ... done
Operations to perform:
Apply all migrations: front
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying front.0001_initial... OK
Traceback (most recent call last):
File "/usr/local/lib/python3.10/site-packages/django/db/backends/utils.py", line 85, in _execute
return self.cursor.execute(sql, params)
File "/usr/local/lib/python3.10/site-packages/django/db/backends/mysql/base.py", line 73, in execute
return self.cursor.execute(query, args)
File "/usr/local/lib/python3.10/site-packages/MySQLdb/cursors.py", line 206, in execute
res = self._query(query)
File "/usr/local/lib/python3.10/site-packages/MySQLdb/cursors.py", line 319, in _query
db.query(q)
File "/usr/local/lib/python3.10/site-packages/MySQLdb/connections.py", line 254, in query
_mysql.connection.query(self, query)
MySQLdb._exceptions.DataError: (1406, "Data too long for column 'name' at row 33")

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
File "/code/manage.py", line 22, in <module>
main()
File "/code/manage.py", line 18, in main
execute_from_command_line(sys.argv)
File "/usr/local/lib/python3.10/site-packages/django/core/management/__init__.py", line 425, in execute_from_command_line
utility.execute()
File "/usr/local/lib/python3.10/site-packages/django/core/management/__init__.py", line 419, in execute
self.fetch_command(subcommand).run_from_argv(self.argv)
File "/usr/local/lib/python3.10/site-packages/django/core/management/base.py", line 373, in run_from_argv
self.execute(*args, **cmd_options)
File "/usr/local/lib/python3.10/site-packages/django/core/management/base.py", line 417, in execute
output = self.handle(*args, **options)
File "/usr/local/lib/python3.10/site-packages/django/core/management/base.py", line 90, in wrapped
res = handle_func(*args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/core/management/commands/migrate.py", line 277, in handle
emit_post_migrate_signal(
File "/usr/local/lib/python3.10/site-packages/django/core/management/sql.py", line 46, in emit_post_migrate_signal
models.signals.post_migrate.send(
File "/usr/local/lib/python3.10/site-packages/django/dispatch/dispatcher.py", line 170, in send
return [
File "/usr/local/lib/python3.10/site-packages/django/dispatch/dispatcher.py", line 171, in <listcomp>
(receiver, receiver(signal=self, sender=sender, **named))
File "/usr/local/lib/python3.10/site-packages/django/contrib/auth/management/__init__.py", line 83, in create_permissions
Permission.objects.using(using).bulk_create(perms)
File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 519, in bulk_create
returned_columns = self._batched_insert(
File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 1324, in _batched_insert
self._insert(item, fields=fields, using=self.db, ignore_conflicts=ignore_conflicts)
File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 1301, in _insert
return query.get_compiler(using=using).execute_sql(returning_fields)
File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/compiler.py", line 1441, in execute_sql
cursor.execute(sql, params)
File "/usr/local/lib/python3.10/site-packages/django/db/backends/utils.py", line 99, in execute
return super().execute(sql, params)
File "/usr/local/lib/python3.10/site-packages/django/db/backends/utils.py", line 67, in execute
return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
File "/usr/local/lib/python3.10/site-packages/django/db/backends/utils.py", line 76, in _execute_with_wrappers
return executor(sql, params, many, context)
File "/usr/local/lib/python3.10/site-packages/django/db/backends/utils.py", line 80, in _execute
with self.db.wrap_database_errors:
File "/usr/local/lib/python3.10/site-packages/django/db/utils.py", line 90, in __exit__
raise dj_exc_value.with_traceback(traceback) from exc_value
File "/usr/local/lib/python3.10/site-packages/django/db/backends/utils.py", line 85, in _execute
return self.cursor.execute(sql, params)
File "/usr/local/lib/python3.10/site-packages/django/db/backends/mysql/base.py", line 73, in execute
return self.cursor.execute(query, args)
File "/usr/local/lib/python3.10/site-packages/MySQLdb/cursors.py", line 206, in execute
res = self._query(query)
File "/usr/local/lib/python3.10/site-packages/MySQLdb/cursors.py", line 319, in _query
db.query(q)
File "/usr/local/lib/python3.10/site-packages/MySQLdb/connections.py", line 254, in query
_mysql.connection.query(self, query)
django.db.utils.DataError: (1406, "Data too long for column 'name' at row 33")
ERROR: 1

長々とエラーメッセージが出力されましたが、ポイントは

1
django.db.utils.DataError: (1406, "Data too long for column 'name' at row 33")

と思いました

状況確認

エラーメッセージで調べても、データが溢れてるんじゃない?とか、データ溢れた場合にエラーが発生しないようにするにはsql_mode=’’にすればいいよという内容しか出てきませんでした。

nameカラムの確認

nameというカラムを持つテーブルが複数あり、max_lengthなどを確認しましたが、だいたい128が設定されていました。そもそもマイグレーションではデータを入れることはないので、調べる箇所が検討違いでした。

マイグレーションの状況

テーブルが正常に作成されているかどうかを確認したのですが、正常に作成されていました。

テスト

変えたのはDB名のみです。なので、データベース名の長さが問題なのかと思い、長いデータベース名や短いデータベース名を試してみましたが、どの場合でもエラーが発生しました。ただ唯一、以前のデータベース名のみエラーが発生しないという状況でした。

解決方法

一旦全てのテーブルをロールバックしてから行ってみようと思い、アプリケーションすべてをロールバックしました。

その後、マイグレーションを行うとエラーは発生せず、マイグレーションも行われていました。

エラーメッセージからnameというカラムに問題があるのかと思ったのですが、一旦全てきれいにしてからマイグレーションを行えばデータベース名の変更は問題なさそうです。

ですが、もともとデータベースは新しく作成しているので、各アプリケーションのテーブルが影響しているとは考えづらいですね…

まとめ

データベース名の変更は極力行わない。行う場合は全てのアプリケーションのテーブルをロールバックしてから再度マイグレーションを行う。

参考図書

初期のモデル実装の調整方法

背景

実装初期、モデルの実装の調整は多く発生します。今行っているプロジェクトも業務内容が複雑で、なかなか細部まで理解するのが難しい状態です。

そう言った場合、テーブルの追加削除、カラムの追加削除、リレーションの設定等、たくさんの変更が入ります。

そういった作業をスムーズに行うために、モデルの修正->ロールバック->マイグレーションファイルへの適用->マイグレーション実行->データベース確認、の流れをまとめて記述しておきたいと思います。

ロールバック

すでにテーブルなど作成済の場合は、モデルの変更をマイグレーションファイルに適用する前にロールバックを行い、テーブルを削除しておきます。

1
2
3
4
5
6
7
$ docker-compose rum --rm web python manage.py migrate example_app zero
Creating example-app_web_run ... done
Operations to perform:
Unapply all migrations: example_app
Running migrations:
Rendering model states... DONE
Unapplying example_app.0001_initial... OK

これでテーブルは全て削除されました。

マイグレーションファイルへの適用

モデルを修正した後は、マイグレーションファイルへその変更を反映する必要があります。

しかし、マイグレーションファイルがある状態でマイグレーションファイルを作成しようとすると、新しいマイグレーションファイルができてしまいます。

運用フェーズに入っている場合はそれが正しいと思うのですが、初期のモデル調整で複数のマイグレーションファイルを作成したくないので、一旦マイグレーションファイルを削除します。

1
$ rm example_app/migrations/0001_initial.py

次にマイグレーション作成コマンドを実行します。

1
2
3
4
5
6
$ docker-compose run --rm web python manage.py makemigrations example_app
Creating example-app_web_run ... done
Migrations for 'example_app':
example_app/migrations/0001_initial.py
- Create model ...
(省略)

これで再度example_app/migrations/0001_initial.pyが作成されました。

マイグレーションの実行

マイグレーションファイルを作成したので、マイグレーションを実行します。

1
2
3
4
5
6
$ docker-compose run --rm web python manage.py migrate example_app
Creating example-app_web_run ... done
Operations to perform:
Apply all migrations: example_app
Running migrations:
Applying example_app.0001_initial... OK

マイグレーションが完了しました。

テーブルの確認

マイグレーションによりテーブルを作成したら、dhshellコマンドでモデルを変更した箇所が正しくテーブルに反映されているか確認します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ docker-compose run --rm web python manage.py dbshell
Creating example-app_web_run ... done
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MySQL connection id is 30
Server version: 5.7.36 MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [example_app]> show create table xxxx;

show create tableコマンドなどでテーブルを確認します。

修正したファイルのコミット(Git)

テーブルを確認し問題なければ修正したファイルをコミットします。モデルの修正点がわかりやすいようコミットコメントを記述します。

以上でモデルの修正が完了となります。

まとめ

アプリケーション開発初期はモデルの修正が頻繁に行われます。最初から完全なモデルを実装するのは難しいですからね。

修正・変更のログを取得しなくてもよい初期のモデル、テーブル構成はまっさらにしてから再作成するのがよいと思います。そうしないとマイグレーションファイルがどんどん増えて、煩雑になってしまいます。

参考図書

メールアドレスで認証するように変更する

背景

前回、Djangoが提供してくれている機能を用いてユーザー認証ページを作成しました。

ユーザー名はメールアドレスの場合が多いと思いますので、ユーザー名ではなくメールアドレスで認証できるように変更したいと思います。

認証バックエンドの追加

認証方法を変更するにはどのようにするかというと、ドキュメントに記載がありました。

このページの中の認証バックエンドを指定するをみると、認証バックエンドとして利用するクラスのリストはAUTHENTICATION_BACKENDSに定義されており、デフォルトでは

1
['django.contrib.auth.backends.ModelBackend']

となっているようです。確認してみます。

1
2
3
4
5
6
7
$ docker-compose run --rm web python manage.py shell
Creating example_web_run ... done
>>>
>>> from django.conf import settings
>>>
>>> settings.AUTHENTICATION_BACKENDS
['django.contrib.auth.backends.ModelBackend']

確かに設定されていました。

settings.AUTHENTICATION_BACKENDSはリストで、最初のクラスから順番にauthenticate()メソッドを呼び出し、最初に有効な認証と判断した段階で処理を終えるようです。

認証バックエンドの実装

では認証バックエンドを実装していきます。実装に必要な内容はドキュメントを参照します。

親クラスの指定

親クラスにはdjango.contrib.auth.backends.BaseBackendクラスを指定します。

デフォルトで指定されているクラスの親クラスも調べてみます。親クラスを参照するには__bases__メソッドを利用します。

1
2
3
4
5
6
7
$ docker-compose run --rm web python manage.py shell
Creating example_web_run ... done
>>>
>>> from django.contrib.auth.backends import ModelBackend
>>> ModelBackend.__bases__
(<class 'django.contrib.auth.backends.BaseBackend'>,)
>>>

デフォルトのModelBackendの親クラスもBaseBackendでした。

こちらのクラスを継承して実装してみようと思ったのですが、BaseBackendクラスの実装はほぼないので、自分で全て実装しないといけません。
https://github.com/django/django/blob/main/django/contrib/auth/backends.py

今回は認証部分(authenticateメソッド)のみを変更したいので、ModelBackendを継承して実装したいと思います。

空の実装で追加してみる

一旦認証バックエンドを追加しますが、試しに空の実装で追加してみます。accounts/backends.pyを作成し、以下のように実装します。

1
2
3
4
5
from django.contrib.auth.backends import ModelBackend


class EmailAuthenticationBackend(ModelBackend):
pass

そして、settings.pyにAUTHENTICATION_BACKENDSを定義します。

1
2
3
4
AUTHENTICATION_BACKENDS = [
'accounts.backends.EmailAuthenticationBackend',
'django.contrib.auth.backends.ModelBackend',
]

順番が重要なので、デフォルトの認証バックエンドよりも前にします。

動作確認

では動作確認を行います。ドキュメントによると、

ユーザーが一度認証されると、Djangoはどの認証バックエンドが認証に使用されたかを、ユーザーセッションに保持します

と記載されているので、sessionのデータを確認することでどの認証バックエンドが利用されたかがわかりそうです。

セッションはrequest.sessionでアクセスできます。ログイン後、適当なViewを作成し、request.sessionの中身を確認します。

1
2
3
4
5
from django.http import HttpResponse


def top(request):
return HttpResponse((',').join(request.session.keys()))

レスポンスを確認すると、

1
_auth_user_id,_auth_user_backend,_auth_user_hash

となりました。_auth_user_backendがいかにもそれっぽいですね。次はそのキーを指定します。

1
2
def top(request):
return HttpResponse(request.session['_auth_user_backedn'])

レスポンスは

1
accounts.backends.EmailAuthenticationBackend

となりました。新しく作成した認証バックエンドが利用されていることがわかりました。

メールアドレスで認証する

メールアドレスで認証するようauthenticate()メソッドを変更します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend

UserModel = get_user_model()

class EmailAuthenticationBackend(ModelBackend):
def authenticate(self, request, email=None, password=None, **credentials):
try:
user = UserModel.objects.get(email=email)
except UserModel.DoesNotExist:
return None
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user

こちらで実装が完了しました。

ただこれだけではメールアドレスで認証ができません。ログインフォームの改修が必要です。

ログインフォームの実装

ログインフォームについては実践Djangoのコードそのまま拝借します。以下のコードをaccounts/forms.pyとして保存します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from django import forms
from django.contrib.auth import authenticate, get_user_model
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _

UserModel = get_user_model()

class EmailAuthenticationForm(forms.Form):
email = forms.EmailField(max_length=254, widget=forms.TextInput(attrs={'autofocus': True}))
password = forms.CharField(label=_("Password"), strip=False, widget=forms.PasswordInput)

error_messages = {
'invalid_login': "メールアドレスまたはパスワードに誤りがあります",
'inactive': _("This account is inactive"),
}

def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user_cache = None
super().__init__(*args, **kwargs)

self.email_field = UserModel._meta.get_field("email")
if self.fields["email"].label is None:
self.fields["email"].label = capfirst(self.email_field.verbose_name)

def clean(self):
email = self.cleaned_data.get("email")
password = self.cleaned_data.get("password")

if email is not None and password:
self.user_cache = authenticate(self.request, email=email, password=password)
if self.user_cache is None:
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login',
params={"email": self.email_field.verbose_name}
)
else:
self.confirm_login_allowed(self.user_cache)

return self.cleaned_data

def confirm_login_allowed(self, user):
if not user.is_active:
raise forms.ValidationError(self.error_messages["inactive"], code='inactive')

def get_user_id(self):
if self.user_cache:
return self.user_cache.id
return None

def get_user(self):
return self.user_cache

URLの設定

フォームクラスを実装したら、そのフォームクラスを利用するようurls.pyを修正します。

accounts/urls.pyloginを以下のように修正します。

1
2
3
4
5
path('login/', LoginView.as_view(
form_class=EmailAuthenticationForm,
redirect_authenticated_user=True,
template_name='accounts/login.html'
), name='login'),

form_classの指定を追加しました。これで、テンプレートは変更することなく、フォームがメールアドレスをPOSTするフォームに変わります

from accounts.forms import EmailAuthenticationFormの1行も忘れずに

動作確認

/accounts/logout/にアクセスしてログアウト後、ログインフォームを表示し、メールアドレス、パスワードを入力してsubmitすると、TOPページに遷移することが確認できます。

メールアドレスで認証するよう実装することができました

まとめ

Djangoが提供してくれているユーザー認証の仕組みはそのまま使える感じだと思いますが、今回のようにメールアドレスで認証したい場合などは少し手を入れる必要があります。

ですが、それも決まったメソッドをオーバーライドするだけで非常に簡単に実装できました。

次回はユーザーモデルのカスタマイズを試してみたいと思います。

参考図書

Djangoでユーザー認証を実装する

背景

今実装しているアプリケーションでは、ユーザー認証機能が必須です。
実践Djangoのサンプルアプリケーションで、とても簡潔に実装されていたので、こちらにまとめておきます。

認証用アプリケーションの追加

まず認証用アプリケーションを追加します。

1
$ docker-compose run --rm web python manage.py startapp accounts

作成されたファイルを確認します。

1
2
3
4
5
6
7
8
$ find accounts -type f
accounts/migrations/__init__.py
accounts/models.py
accounts/__init__.py
accounts/apps.py
accounts/admin.py
accounts/tests.py
accounts/views.py

アプリケーションを追加するためにINSTALLED_APPSに追加します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ vi example/settings.py
...
...(省略)...

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_extensions',
'accounts.apps.AccountsConfig',
]
...(省略)...
...

アプリケーションの追加が完了しました。

URLの設定

Djangoが提供しているビューを利用して、認証用アプリケーションのURLを設定します。

Djangoはログイン用のビューとしてLoginView、ログアウト用のビューとしてLogoutViewというクラスを提供してくれています。

accounts/urls.pyを以下のように書き換えます。

1
2
3
4
5
6
7
8
9
10
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path

urlpatterns = [
path('login/', LoginView.as_view(
redirect_authenticated_user=True,
template_name='accounts/login.html'
), name='login'),
path('logout/', LogoutView.as_view(), name='logout')
]

pathの第3引数のname=はURLを逆引きするときの名前を指定します。

では作成したaccounts/urls.pyをプロジェクト配下のurls.pyで読み込むようにします。

1
2
3
4
5
6
7
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls')),
]

テンプレートの作成

ログイン画面のテンプレートaccounts/login.htmlをまだ実装してないので、こちらを実装します。

bootstrapを利用して見た目を調整する

見た目を整えるためにbootstrap5を利用しようと思います。Djangoで利用するにはdjango-bootstrap5というパッケージをインストールします。

パッケージのインストール

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ docker-compose run --rm web poetry add django-bootstrap5
Creating example_web_run ... done
Skipping virtualenv creation, as specified in config file.
Using version ^21.3 for django-bootstrap5

Updating dependencies
Resolving dependencies... (22.2s)

Writing lock file

Package operations: 3 installs, 0 updates, 0 removals

• Installing soupsieve (2.3.1)
• Installing beautifulsoup4 (4.10.0)
• Installing django-bootstrap5 (21.3)

インストール完了しました。

INSTALLED_APPSに追加する

利用するためにはINSTALLED_APPSへの追加が必要です。

1
2
3
4
5
INSTALLED_APPS = [
...(省略)...
'accounts.apps.AccountsConfig',
'django_bootstrap5'
]

テンプレートの追加

accounts/templates/accounts/login.htmlを追加します。

レイアウトファイルの追加

ページの統一感を持たせるためレイアウトファイルを追加します。templates/base.htmlというファイルを追加します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{% load static %}
{% load django_bootstrap5 %}
<html>
<head>
...(省略)...
</head>
<body>
...(省略)...
<main>
<div class="container">
{% block main %}{% endblock %}
</div>
</main>
</body>
</html>

accounts/login.htmlの追加

accounts/templates/accounts/login.htmlは先程作成したレイアウトファイルのmainブロックを上書きします。

1
2
3
4
5
6
7
8
9
10
11
12
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block main %}
<h2>ログイン</h2>
<form method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}" />
{% bootstrap_form form %}
{% bootstrap_button button_type="submit" content="ログイン" %}
</form>
{% endblock %}

作成したので、ログインページにアクセスしてみます。

テンプレート検索ディレクトリの追加

アクセスしてみるとTemplateDoesNotExist at /accounts/login/というエラーが発生してしまいます。理由はレイアウトファイルを置いたディレクトリはテンプレート検索パスに含まれていないため、テンプレートが見つからないからです。

templatesディレクトリをテンプレート検索パスに追加します。example/settings.pyTEMPLATESリストのDIRSを以下のように変更します。

1
'DIRS': [BASE_DIR / 'templates'],

再度アクセスすると、無事ログイン画面が表示されました!

動作確認

以前にpython manage.py createsuperuseradminユーザーを作成していたので、adminでログインできることを確認します。

ログインフォームにユーザー名とパスワードを入力してsubmitすると、認証は通ったようですが、/accounts/profile/というページにリダイレクトされ、Page not foundエラーが表示されてしまいました。

ログイン後のリダイレクトURL設定

ログイン後のリダイレクトURLはsettings.LOGIN_REDIRECT_URLで指定します。デフォルトの値を確認してみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ docker-compose run --rm web python manage.py shell_plus
Creating example_web_run ... done
# Shell Plus Model Imports
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, When
from django.utils import timezone
from django.urls import reverse
from django.db.models import Exists, OuterRef, Subquery
>>> settings.LOGIN_REDIRECT_URL
'/accounts/profile/'

デフォルトでは/accounts/profile/になっていることがわかりました。こちらの設定を上書きします。

1
2
3
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'

LOGIN_URLはデフォルトと同じですが明示的に記載しておきます。LOGOUT_REDIRECT_URLはデフォルトでは指定されていませんでしたので/に指定しました。

再度/accounts/login/にアクセスします。先程すでに認証されているので、TOPページにリダイレクトされました。

まとめ

Djangoが提供しているビューを利用してユーザー認証を実装しました。テンプレートを作成したくらいで、ほとんど実装することなく認証ページを作成することができました。とても便利だと思います。

次回はメールアドレスで認証するように変更してみたいと思います。

参考図書

MySQLコンテナの文字コードをutf8mb4に設定する

背景

実践Djangoのサンプルアプリケーションを実装していて、フォームからマルチバイト文字を保存しようとしたところ、エラーが発生しました。

MySQLのオフィシャルコンテナは、そのままだとデフォルトの文字コードがutf8になっていないようなので、その設定方法をまとめておきます。

クライアント側の設定

クライアント側はDjangoなので、Djangoの設定になります。
プロジェクト/settings.pyのデータベース設定に、OPTIONSに文字コードを指定します。

1
2
3
4
5
6
7
8
9
10
11
12
13
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangosnippets',
'USER': 'django',
'PASSWORD': 'django',
'HOST': 'db',
'PORT': 3306,
'OPTIONS': {
'charset': 'utf8mb4'
}
}
}

以上でクライアント側の設定は完了です。

サーバー側の設定

MySQLのオフィシャルコンテナのページを見ると、以下のように記載があります。

1
2
3
4
5
Configuration without a cnf file

Many configuration options can be passed as flags to mysqld. This will give you the flexibility to customize the container without needing a cnf file. For example, if you want to change the default encoding and collation for all tables to use UTF-8 (utf8mb4) just run the following:

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

設定ファイルをカスタマイズして読み込ませてもいいと思いますが、そうしなくていい方法を選ぼうと思います。コマンドのオプションで指定できるようなので、docker-compose.ymlにそのように記載します。

1
2
3
4
5
6
7
8
9
10
11
12
db:
image: mysql:5.7
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: 'django'
MYSQL_PASSWORD: 'django'
MYSQL_DATABASE: 'djangosnippets'
ports:
- 33060:3306
volumes:
- mysql:/var/lib/mysql

commandを記述して、起動コマンドを上書きします。

これでサーバー側の設定は完了です。

動作確認

では動作確認を行います。Djangoのコマンドでデータベースに接続します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ docker-compose run --rm web python manage.py dbshell
Creating djangosnippets_web_run ... done
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MySQL connection id is 24
Server version: 5.7.36 MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [djangosnippets]>

文字コードを確認します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MySQL [djangosnippets]> show variables like '%char%';
+--------------------------+----------------------------+
| Variable_name | Value |
+--------------------------+----------------------------+
| character_set_client | utf8mb4 |
| character_set_connection | utf8mb4 |
| character_set_database | utf8mb4 |
| character_set_filesystem | binary |
| character_set_results | utf8mb4 |
| character_set_server | utf8mb4 |
| character_set_system | utf8 |
| character_sets_dir | /usr/share/mysql/charsets/ |
+--------------------------+----------------------------+
8 rows in set (0.011 sec)

ちゃんとutf8mb4になっていました。

まとめ

MySQLコンテナの文字コードの設定方法を記述しました。

utf8mb4にしておくことで、絵文字も保存できるようになります。

開発環境を作る際に忘れてしまったらこのページを参照しようと思います。

参考図書

poetryを使ってDjango4.0にアップデートする

背景

2021年12月7日にDjango4.0がリリースされました。

リリースノート
https://docs.djangoproject.com/ja/4.0/releases/4.0/

今は3.2.9を利用していて、LTSなので使い続けることは可能なのですが、バージョンアップで後で苦労するのが大変そうだったので、今のうちにあげてみることにしました。

バージョン管理にPoertyを利用しているので、それを前提に記載していきます。

pyproject.tomlの変更

現在のバージョンの確認

現在のバージョンの確認をします

1
2
3
4
5
6
$ docker-compose run --rm web pip list
...
distlib 0.3.4
Django 3.2.9
django-extensions 3.1.5
...

現在は3.2.9です。

pyproject.tomlの確認

pyproject.tomlでの指定を確認します。

1
2
3
4
5
6
7
[tool.poetry.dependencies]
python = "^3.10"
boto3 = "^1.20.3"
Django = "^3.2.9"
mysqlclient = "^2.1.0"
requests = "^2.26.0"
platformdirs = "^2.4.0"

Djangoのバージョン指定を4.0.0にします。

poetryのバージョン指定

4.0系のバグフィックスバージョンは含めたいので、^4.0.0と指定します。
~4.0と書いてもいいのかもしれませんが、^4.0.0の方がわかりやすいかなと思います。

diffを取ると以下のようになりました

1
2
-Django = "^3.2.9"
+Django = "^4.0.0"

パッケージの更新

パッケージの更新にはpoetry updateコマンドを利用します。Djangoのみ更新したいので、パッケージも指定します。

1
2
3
4
5
6
7
8
9
10
11
$ docker-compose run --rm web poetry update Django
...
Updating dependencies
Resolving dependencies... (37.8s)

Writing lock file

Package operations: 0 installs, 1 update, 1 removal

• Removing pytz (2021.3)
• Updating django (3.2.9 -> 4.0)

Djangoが4.0にupdateされたと同時にpytzが削除されたようです。

poetry.lockの確認

poetry.lockのdiffも確認します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ git diff poetry.lock 
...
[[package]]
name = "django"
-version = "3.2.9"
-description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
+version = "4.0"
+description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"

[package.dependencies]
-asgiref = ">=3.3.2,<4"
-pytz = "*"
+asgiref = ">=3.4.1,<4"
sqlparse = ">=0.2.2"
+tzdata = {version = "*", markers = "sys_platform == \"win32\""}
...

Djangoのバージョン指定が変わっていることがわかります。また、pytzが削除されています。

pytzが削除されて、tzdataが追加されている件については、
https://docs.djangoproject.com/ja/4.0/releases/4.0/#zoneinfo-default-timezone-implementation
が関係していると思います。

この変更の理由については、以下のスライドがわかりやすいと思います。
https://www.slideshare.net/ryu22e/python-39zoneinfo

まとめ

まだアプリケーションを作り込んでいないので、バージョンをあげたことによる変化は感じていませんが、逆に早めにバージョンを上げることで、これ以降のバージョンアップもスムーズにできるというメリットがあると思います。

新しいバージョンが発表されたら早めに対応するのが良いと思います。

記事ページにJSON-LDを追加する

背景

サイトの成長を感じるために毎日Google Search Consoleを見ています。

どれくらい検索結果に表示されて、どれくらいクリックされているのかを確認するのが毎日とても楽しみにしているのですが、最近クリック率が徐々に下がってきてしまっています(6.0%->5.7%)

表示回数が増えるとクリック率も下がるかとは思うのですが、このままでは良くないと思い、json-ldを導入することにしました。

JSON-LDの仕様の確認

Googleの構造化データを含む非AMPページの箇所を確認します

https://developers.google.com/search/docs/advanced/structured-data/article

JSON-LDの箇所だけ抜き出すと

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "NewsArticle",
"headline": "Article headline",
"image": [
"https://example.com/photos/1x1/photo.jpg",
"https://example.com/photos/4x3/photo.jpg",
"https://example.com/photos/16x9/photo.jpg"
],
"datePublished": "2015-02-05T08:00:00+08:00",
"dateModified": "2015-02-05T09:20:00+08:00",
"author": [{
"@type": "Person",
"name": "Jane Doe",
"url": "http://example.com/profile/janedoe123"
},{
"@type": "Person",
"name": "John Doe",
"url": "http://example.com/profile/johndoe123"
}]
}
</script>

となっています。

これを各記事ページで動的に対応していきます。

項目の精査

それぞれ項目をどう設定するかを見ていきます。

@context

こちらは固定で https://schema.orgで良さそうです。

@type

こちらはArticleNewsArticleBlogPostingが選択できるようですが、一旦Articleにしておきます

headline

Headlineはtitleと同じでよいかなと思います。変数で指定します

image

記事内の画像の一覧を変数で指定します

datePublished

記事内の変数を参照します

datePublished

こちらも同じように記事内の変数を参照します

author

こちらは固定で@typeはPerson、nameは自分の名前、urlはaboutmeを指定します

ではパーシャルを作成していきます。

パーシャルの作成

themes/landscape/layout/_partial/ディレクトリの下にjson-ld.ejsというファイルを作成します。

中身は以下のようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "<%- post.title %>",
<% if (post.twitter_image){ %>
"image": [
"<%- post.twitter_image %>"
],
<% } %>
"datePublished": "<%- date(post.date, "YYYY-MM-DDTHH:mm:ss") %>",
"dateModified": "<%- date(post.updated, "YYYY-MM-DDTHH:mm:ss") %>",
"author": [{
"@type": "Person",
"name": "Motoaki Shibagaki",
"url": "https://book-reviews.blog/aboutme/"
}]
}
</script>

最初、"image"は複数設定できた方が良いかなと思い、記事内のymlで画像URLの配列を作成していたのですが、うまく文字列の配列を作成できず断念しました。こちらについてはまた時間を作ってチャレンジしたいと思います。

記事ページの判定

作成したパーシャルをheadタグ周りのパーシャルであるthemes/landscape/layout/_partial/head.ejsで読み込みます。

1
2
3
4
5
6
7
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<%- partial('google-analytics') %>
<%- partial('google-adsense') %>
<%- partial('json-ld') %>

しかし、このままでは、記事ページ以外でもjson-ldのスクリプトが表示されてしまいます。

ですので、記事ページの判定を行います。記事ページの判定にはis_post()という関数を利用します。

最終的にjson-ld.ejsは以下のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<% if (is_post()){ %>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "<%- post.title %>",
<% if (post.twitter_image){ %>
"image": [
"<%- post.twitter_image %>"
],
<% } %>
"datePublished": "<%- date(post.date, "YYYY-MM-DDTHH:mm:ss") %>",
"dateModified": "<%- date(post.updated, "YYYY-MM-DDTHH:mm:ss") %>",
"author": [{
"@type": "Person",
"name": "Motoaki Shibagaki",
"url": "https://book-reviews.blog/aboutme/"
}]
}
</script>
<% } %>

動作確認

では動作確認をします。

記事ページを表示させて、json-ldが表示されているかを確認します。記事ページはhttps://book-reviews.blog/error-creating-the-test-database-on-Django/で試しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Djangoでテスト実行時にテスト用データベース作成エラーが発生した時の対応",

"image": [
"https://book-reviews.blog/images/django-logo.png"
],

"datePublished": "2021-12-07T23:30:00",
"dateModified": "2021-12-07T23:30:00",
"author": [{
"@type": "Person",
"name": "Motoaki Shibagaki",
"url": "https://book-reviews.blog/aboutme/"
}]
}

大丈夫そうですね!

エラー発生

リリースしてから何日か後にサーチコンソールでエラーが発生しました。

解析不能な構造化データがあるというエラーでした。

原因を調べてみると、タイトルにダブルクォートが入っていて、タイトルの区切り文字として認識されてしまい、syntax errorになっていました。

一旦ダブルクォートを利用しないようにして対応しましたが、本当はもっといい方法があると思います。こちらも時間があるときに調査したいと思います。

まとめ

CTRを上げるために、JSON-LDを追加しました。結果はというと、かなり良い感じでCTRが上がっています(5.7% -> 6.5%)。

短期的に見ているので、実際はもう少し下がるのかなと思うのですが、効果が出ていて嬉しいです。

良い構造化データを作成するために、画像の複数指定や、タイトルの作成についてもう少し掘っていきたいと思います。

Djangoでテスト実行時にテスト用データベース作成エラーが発生した時の対応

背景

以前の記事でdocker-composeを利用してDjango+MySQLの環境を作成しました。

プロジェクトとアプリケーションを作成し、テストを実行するところで、以下のエラーが発生しました。

1
2
3
4
$ docker-compose run --rm web python manage.py test
Creating test database for alias 'default'...
Got an error creating the test database: (1044, "Access denied for user 'django'@'%' to database 'test_example'")
ERROR: 2

初めてのDjangoでのテスト実行ということもあってわからないことが多かったので調べた結果を記載していきます。

テスト用データベースについて

エラーメッセージを見て思ったのは、どのようにしてテスト用データベースの名前が決まっているのか?ということでした。公式ドキュメントを探していると見つかりました。

テストを書いて実行する | Django ドキュメント | Django

こちらによると、

テストデータベースのデフォルトの名前は、 DATABASES 設定内の各 NAME の値の前に test_ を付けたものになります。設定の DATABASES 内の TEST ディクショナリには、テストデータベースに対するいろいろな設定を書くことができます。例えば、別のデータベース名を指定したければ、 TEST ディクショナリの NAME に、 DATABASES の中から好きなデータベースを選んで指定することができます。

ということでした。TESTディクショナリを指定していないので、結果としては、test_exampleで良さそうです。

settings.pyは以下のようになっています。

1
2
3
4
5
6
7
8
9
10
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'example',
'USER': 'django',
'PASSWORD': 'django',
'HOST': 'db',
'PORT': 3306,
}
}

TESTディクショナリを設定する場合は

1
2
3
4
5
6
7
8
9
10
11
12
13
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'example',
'USER': 'django',
'PASSWORD': 'django',
'HOST': 'db',
'PORT': 3306,
'TEST': {
'NAME': 'custom_test_database'
}
}
}

となります。

現状の確認

データベースの確認

データベースが存在していないのか、存在しているがアクセスできないのかを確認したいと思います。まずはdbshellでアクセスしてみます。

1
2
3
4
5
6
7
8
9
$ docker-compose run --rm web python manage.py dbshell
MySQL[example]> show databases;
+---------------------+
| Database |
+---------------------+
| information_schema |
| example |
+---------------------+
2 rows in set (0.001 sec)

データベースがないように見えます。権限の問題かもしれないので権限を確認します。

権限の確認

次に権限を確認します。権限の確認にはshow grants forというSQL文があるので実行します。

1
2
3
4
5
6
7
8
MySQL[example]> show grants for 'django'@'%';
+-----------------------------------------------------+
| Grants for django@% |
+-----------------------------------------------------+
| GRANT USAGE ON *.* TO 'django'@'%' |
| GRANT ALL PRIVILEGES ON `example`.* TO 'django'@'%' |
+-----------------------------------------------------+
2 rows in set (0.001 sec)

GRANT USAGEは見慣れない権限なのですが、権限を与えないということのようです。全ての権限をなくしてからexampleデータベースにのみ全権限を与えているということのようです。

これでは現状がわからないのでrootで接続してみます。

webコンテナにログインしてからmysqlクライアントで接続します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ docker-compose run --rm web /bin/bash 
Creating example_web_run ... done
root@6e114e4f73b3:/code#
root@6e114e4f73b3:/code# mysql -u root -p -h db
Enter password:
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 5.7.36 MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]>

rootのパスワードはdocker-compose.ymlでMYSQL_ROOT_PASSWORD環境変数で指定したパスワードを入力しました。

ではデータベースを確認します。

1
2
3
4
5
6
7
8
9
10
11
12
13
MySQL [(none)]> show databases;
+---------------------+
| Database |
+---------------------+
| information_schema |
| example |
| mysql |
| performance_schema |
| sys |
+---------------------+
5 rows in set (0.010 sec)

MySQL [(none)]>

データベースはありませんでした。一旦作成してみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MySQL [(none)]> create database test_example;
Query OK, 1 row affected (0.003 sec)

MySQL [(none)]> show databases;
+--------------------------+
| Database |
+--------------------------+
| example |
| information_schema |
| mysql |
| performance_schema |
| sys |
| test_example |
+--------------------------+
6 rows in set (0.001 sec)

権限も付与します。

1
2
3
4
5
6
7
8
9
10
11
12
13
MySQL [(none)]> grant all privileges on `test_example`.* to 'django'@'%';
Query OK, 0 rows affected (0.003 sec)

MySQL [(none)]>
MySQL [(none)]> show grants for 'django'@'%';
+----------------------------------------------------------------------+
| Grants for django@% |
+----------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'django'@'%' |
| GRANT ALL PRIVILEGES ON `example`.* TO 'django'@'%' |
| GRANT ALL PRIVILEGES ON `test_example`.* TO 'django'@'%' |
+----------------------------------------------------------------------+
3 rows in set (0.001 sec)

権限の付与ができました。

テスト再実行

ではテストを再実行してみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker-compose run --rm web python manage.py test
Creating example_web_run ... done
Creating test database for alias 'default'...
Got an error creating the test database: (1007, "Can't create database 'test_example'; database exists")
Type 'yes' if you would like to try deleting the test database 'test_example', or 'no' to cancel: yes
Destroying old test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.006s

OK
Destroying test database for alias 'default'...

すでにデータベースがあるので削除していいかと聞かれてしまいました…
yesとタイプして先に進むと、無事テストが通りました。

もう一度実行してみます。

1
2
3
4
5
6
7
8
9
10
$ docker-compose run --rm web python manage.py test
Creating example_web_run ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.010s

OK
Destroying test database for alias 'default'...

今度は問題なくできました。

ということは、権限のみ与えれば良いということになります。

まとめ

Djangoのテストを実行するために、接続ユーザーに対してテスト用データベースへの権限を付与する必要があるということがわかりました。

新しい環境を作成したあとの初回は都度権限を付与するのはちょっと面倒な印象があります。MySQLコンテナでの/docker-entrypoint-initdb.dを利用した初期化がうまく使えないか検証してみようと思います。

docker-composeでMySQL認証エラーが出た時の対応

背景

以前の記事でdocker-composeを利用してDjango+MySQLの環境を作成しました。

今回、別のプロジェクトでDjango+MySQLのアプリケーションを作成することにしたので、久々環境を構築したところ、以下のエラーにハマったので備忘録として記載します。

エラー内容

エラーはこんな感じでした。

1
django-web-1  | django.db.utils.OperationalError: (1044, "Access denied for user 'django'@'%' to database 'django'")

認証に失敗しているようです…

docker-compose.ymlの確認

まずMySQLコンテナの認証を設定しているdocker-compose.ymlを確認します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
version: '3'

services:
db:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: 'django'
MYSQL_PASSWORD: 'django'
MYSQL_DATABASE: 'django'
ports:
- 3306:3306
volumes:
- mysql:/var/lib/mysql
web:
build: .
command: python3 manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
- site-packages:/usr/local/lib/python3.10/site-packages
- usr-local-bin:/usr/local/bin
ports:
- "8000:8000"
links:
- db

volumes:
site-packages:
driver: local
usr-local-bin:
driver: local
mysql:
driver: local

問題なさそうです。

Djangoのデータベース接続設定の確認

次に接続に行っているDjango側の設定を確認します。プロジェクト配下のsettings.pyを確認します。

1
2
3
4
5
6
7
8
9
10
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'django',
'USER': 'django',
'PASSWORD': 'django',
'HOST': 'db',
'PORT': 3306,
}
}

こちらも問題なさそうです。

原因

だいぶ長い間ハマって気づいたのは、mysqlのvolumeが意図しない動作を引き起こしているのではないかということでした。

一度volumeを削除してみます。

1
$ docker volume rm 該当のvolume

(volumeの確認はdocker volume lsで行いました)

削除後にdocker-compose upを行うと、認証のエラーはなくなり、無事アプリケーションが起動しました。

公式ドキュメントを確認

MySQLコンテナのドキュメントEnvironment Variablesを確認するとちゃんと書いてありました。

Do note that none of the variables below will have any effect if you start the container with a data directory that already contains a database: any pre-existing database will always be left untouched on container startup.

Google翻訳にすると

すでにデータベースが含まれているデータディレクトリでコンテナを起動した場合、以下の変数はいずれも効果がないことに注意してください。既存のデータベースは、コンテナの起動時に常に変更されません。

ということなので、認証情報を変更する場合は毎回volumeを削除する必要があるということでした。

まとめ

MySQLコンテナはvolumeを使っている場合、認証情報は環境変数で変更することはできないことがわかりました。(最初の1回だけ環境変数で指定)

認証情報だけではなく、データベース名なども変更する場合は、既存のvolumeを削除してから起動すると、環境変数で指定した値が反映されます。