Thymeleafでレイアウト共通化

SpringBoot+Thymeleafでレイアウトを共通化する場合の方法について。

Thymeleafは別テンプレートの部品を読み込むというフラグメント式があり、一般的には各画面のテンプレートが共通のhead部分や画面共通のヘッダー、フッター部分を取り込むという形になる。それだと全体構成を決める共通のレイアウトという感じではない。

そこで、別の方法として、共通のテンプレートから、各画面のコンテンツのテンプレートを読み取るという方法でやるのがいい。

1.フラグメント式

Thymeleafでは、フラグメント式を使って、他のテンプレート内の要素を取り込むことができる。

【Thymeleaf 3.0 日本語ドキュメント】チュートリアル

(4.5 フラグメント)

https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf_ja.html#%E3%83%95%E3%83%A9%E3%82%B0%E3%83%A1%E3%83%B3%E3%83%88

(8 テンプレートレイアウト)

https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf_ja.html#%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%83%AC%E3%82%A4%E3%82%A2%E3%82%A6%E3%83%88

2.一般的な方法

一般的には各画面のテンプレートが共通のhead部分や画面共通のヘッダー、フッター部分を取り込むという形になる。以下ページのようなやり方が普通かと。

https://fintan-contents.github.io/spring-crib-notes/latest/html/web/view/thymeleaf-page-layout.html

それだと全体構成を決める共通のレイアウトではなく、各画面が共通部品を使うという形になる。

Layout Dialectというのがあるようだが、やはりhtml,body等を記述していて、コンテンツ部分だけを書くというイメージではなく、あまり使うメリットを感じない。

https://www.thymeleaf.org/doc/articles/layouts.html

※「4.Thymeleaf Layout Dialect」を参照

3.レイアウトの共通化

Djangoでは、テンプレートを継承することができて、 baseテンプレートで全体の構成を決めて、継承した各画面ではコンテンツ部分だけを書けばいいという作りになっている。

Thymeleafで、Djangoに近い実装をする方法として、以下のようにする。

3.1 実現方法

共通のレイアウトから、コンテンツ部分は各画面のレイアウトを取り込むという方法で実現する。

3.2 実装例

1)レイアウト側の実装

仮に、layout/base.htmlのような名前で、共通テンプレートを作成し、その中にhead、ページヘッダー、ページフッター、ナビ部分のようなものを定義する。レイアウトにすべて書くよりは、レイアウトで各部分をフラグメント式にて読むようにするのが良い。

メインのコンテンツ部分は以下のように、定義する。divのidを仮に「main」としている。

【共通レイアウト:layout/base.html】

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

: ※head部分

<body>

:  ※共通ヘッダー部分  

    <!--/* ページのメイン部分 */-->
    <div id="main" th:replace="__${contentTemplate}__"></div>

: ※共通フッター部分

</body>
</html>

2)各画面の実装

各画面のテンプレート(パーツ)は、メイン部分だけを以下のように記述すればいい。

【各画面テンプレート:仮にcontent/_test.html】

: ※ここに別要素が入ってもいい

<div id="main" xmlns:th="http://www.thymeleaf.org">
: ※メイン部分
</div>

: ※ここに別要素が入ってもいい

1)レイアウトのリプレースはファイルの読み込みのため、div部分の前後に別の要素があっても、またmainのdivが無くても問題なく、自由な記述ができる。

前後部分だけでなく、head部分と、bodyの最後尾にJavaScriptを入れたい場合には、フラグメント式を使って3分割し、レイアウト側でそれぞれの場所で読み取るようにするのが良い。

3)コントローラの実装

コントローラ側では、従来はテンプレート名を返しているのを、以下のように変更する。

【コントローラ】

:
@Controller
public class TestController {
 :
    @GetMapping(path = "/test")
    public String test(Model model) throws Exception {
        :
        model.addAttribute("contentTemplate", "content/_test");  // 各画面のテンプレート
        return "layout/base";    // 使用する共通レイアウト
    }
 :

共通レイアウトが一つしかないのであれば、各画面のテンプレートを戻り値にして、postHadlerでそれをモデルに設定して共通テンプレートを呼ぶようにするのも可能。

4.結論

フラグメントの使い方としては意図に合わないかもしないが、共通レイアウトから各画面レイアウトを読み込むとすることで、すっきりできる。

(2021/09/28 追記)

テンプレートのキャッシュは大丈夫?という疑問を頂いた。

https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf_ja.html#%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5

Thymeleafのソースを見ると、キャッシュのキーは以下となっており大丈夫だろうと思っているが、関係のあるソースをさっと読んだだけで、完全に理解しているわけでないので、「たぶん」という感じ。

https://github.com/thymeleaf/thymeleaf/blob/3.0-master/src/main/java/org/thymeleaf/cache/TemplateCacheKey.java

(2021/09/29 追記)

動作確認でキャッシュは問題なさそう。

PHPからのメール送信

WebサーバーのPHPプログラムから、ユーザーからの問い合わせ等をメール送信しているが、迷惑メールとなって届かないことが多い。最近、GMailのスパム判定がさらに厳しくなっているかんじがする。

1.従来の方法

PHP標準の以下のメール関数を使って実装。

(mail)https://www.php.net/manual/ja/function.mail.php

(mb-send-mail)https://www.php.net/manual/ja/function.mb-send-mail.php

SMTPサーバーの指定はできず、サーバーのメール転送エージェント(MTA:sendmailあるいはPostfix)から送信される。Webサーバーは、サービスのドメインのMXレコードに指定されているメールサーバー (以下「正式メールサーバー」) ではないので、SPFレコードにWebサーバーのアドレスを追加しても、最近は迷惑メールの判断されることが増えてきた。

2.改善策

サービス提供しているドメインの正式メールサーバー経由でメール送信するように修正した。方法として、1)メールリレーの指定をやってみたが、上手くいかない部分もあったので、2)STMPサーバーからの送信とした。

2.1 メールリレーの指定

PHPプログラムは変更せずに、サーバーのMTAから、正式メールサーバーを経由(メールリレー)して送る方法を試してみた。

CentOSでsendmailから、SMTP認証を利用して正式メールサーバー経由で送る場合の設定は、以下が参考になる。

https://access.redhat.com/ja/solutions/2733021

メールサーバーとしてXServerのメールサーバーを利用している場合、SPF_SOFTFAILとなる問題がある。

https://darekoi.blogspot.com/2018/05/xserver-spf.html

ウィルスチェックサーバーをSPFに追加すれば、改善するかもしれないが、保証はない。↓

(2021/08/14)

ウィルスチェックサーバーをSPFに追加してみたころ、以下となりGmailでは迷惑メールのまま送信できなかったが、他のメールには届いた。

tests=SPF_HELO_NONE,SPF_PASS

2.2 正式メールサーバーからの送信

PHPプログラムから、サーバーのMTAを経由せず、SMTP認証を利用して 正式メールサーバーから送るように変更した。

SMTP認証でのメール送信としては、PHPMailerが広く使われているよう。

https://github.com/PHPMailer/PHPMailer

検索すると、使い方について説明している日本語ページはあるが、古いものも多いので、正式なGitHubのサンプル見る方が良いと思う。英語が苦手でも、サンプルプログラム見れば使い方は簡単にわかる。

https://github.com/PHPMailer/PHPMailer#a-simple-example

ただし、サンプルは多言語に対応していないので、以下の追加は必要。

  $mail->CharSet = "utf-8";
  $mail->Encoding = "base64";

あと、正式版にする時には、以下を変更。

 //$mail->SMTPDebug = SMTP::DEBUG_SERVER; //デバッグ
 //$mail->SMTPDebug = SMTP::DEBUG_CONNECTION; //接続に失敗する場合のデバッグ
  $mail->SMTPDebug = SMTP::DEBUG_OFF;

3.結論

迷惑メールにならないサーバーPHPプログラムからのメール送信には、やはり正式にSMTP認証してメールサーバーから送るのが一番かと。DNSにSPF設定してとか、色々やったけど、さっさとプログラム直せばよかった。

[Django]django-compositepk-model

Googleサーチコンソールを見ると、Djangoの複合キーについて調べて、以下ページにアクセスしてくれる人が多い。

そこで、GitHubで公開している複合主キー対応のパッケージについて、今更ながらREADMEの日本語版を。

https://github.com/Arisophy/django-compositepk-model

django-compositepk-modelパッケージ

複合主キーの対応のためのDjangoのModelクラスを拡張した CPkModelというクラスを提供する。Query クラスに対しても、複数カラムでのlookupsへの対応を追加したCPkQueryというサブクラスを提供する

本パッケージにより、複合主キーを使っているレガシーDBテーブルについて、サロゲートキーを追加することなくDjangoで扱えるようになる。

これはticket373に関する自分なりの解決方法です。

特徴

1. 利用方法が簡単

複合主キーを持つテーブルに対しては、基底クラスをModelクラスからCPkModelクラスに変更して、 Metaにunique_togetherを設定し、主キーを構成する各フィールド定義に primary_key=Trueを設定する。

(モデル定義の例)

from django.db import models
from cpkmodel import CPkModel

# Normal Model
#   primary_key is auto 'id'
class Company(models.Model):
    name = models.CharField(max_length=100)
    established_date = models.DateField()
    company_code = models.CharField(max_length=100)

    class Meta:
        db_table = 'Company'

# Child Model (CpkModel)
#   composite primary key: company_id, country_code
class CompanyBranch(CPkModel):
    company = models.ForeignKey(
        Company,
        primary_key=True,       # for CompositePK
        on_delete=models.CASCADE,
    )
    country_code = models.CharField(
        max_length=100,
        primary_key=True,       # for CompositePK
    )
    name = models.CharField(max_length=100)
    established_date = models.DateField()

    class Meta:
        managed = False  # for CompositePK *1
        db_table = 'CompanyBranch'
        unique_together = (('company', 'country_code'),)  # for CompositePK

それだけで、それ以外の追加定義、仮想フィールドの追加は必要ない。

*1:  primary_key=Trueが複数カラムに設定されていると、マイグレーションが失敗するので、 managed = Falseとしている。レガシーDBのテーブルは既に存在するか、手でCREATEされると思うので。マイグレーションが使いたければ、migrationする時にprimary_key=Trueとmanaged=Falseをコメントアウトすること。

2. Admin画面が使える

CPkModelを継承したモデルも、DjangoのAdmin画面で使える。 複合キーの値は、 カンマ区切り表示される。 Change(Update)とDeleteは問題なく動く。 Add(Create) に関しては、 CreateViewがキー項目のそれぞれに対してユニークチェックをするので、最初の子レコードは登録できるけど、追加の子レコードが登録できなくなるという問題がある。 これは、CreateViewの問題なので、QuerySetやModelのメソッドを使うプログラムは問題ない。

3. 複数カラムでのlookupsに対応

複合主キーに対応するため、CPkQueryで複数カラムを指定したlookup(以下)に対応している。

obj = CompanyBranch.objects.get(pk=(1,'JP'))
qs = CompanyBranch.objects.filter(pk='1,JP')
qs = CompanyBranch.objects.filter(**{'company,country_code':(1,'JP')})
qs = CompanyBranch.objects.filter(**{'company_id,country_code':'1,JP'})

カンマの入ったLHS(左辺)は、主キーだけでなく、他のカラムの組み合わせもOK。

qs = CompanyBranch.objects.filter(
    **{'country_code,name':'JP,Google'})

IN句に対しても、複数カラムが使える。PostgreSQLは問題ないが、SQLite3についてはIN句に対応されていない(※Djangoの問題)のでエラーになる。

qs = CompanyBranch.objects.filter(
    pk__in=[(1,'JP'),(1,'US'),(2,'JP'),])
qs = CompanyBranch.objects.filter(
    **{'country_code,name__in':[('JP','HONDA'),('CN','SONY'),]})

4. bulk_updateが使える(v1.0.2)

bulk_updateメソッドも、PostgreSQLについて利用可能。SQLite3はサポートされていないので、不可。

    Album.objects.bulk_update(
        albums, ['num_stars',])

制約

1. マイグレーション(table作成)

primary_key=Trueが複数カラムに設定されていると、マイグレーションが失敗するので、 managed = Falseとしている。 レガシーなテーブルは既に存在するか、手でCREATEされるので。マイグレーションが使いたければ、migrationする時にはprimary_key=Trueとmanaged=Falseをコメントアウトすること。

2. Admin画面でのCREATE(CreateViewの問題)

CreateViewがキー項目のそれぞれに対してユニークチェックをするので、レコードが一部しか登録できなくなる。 これは、CreateViewの問題なので、QuerySetやModelのメソッドを使うプログラムには問題ない。

3. 外部キー

孫テーブルへのリレーションをサポートするにはForeignKeyからCPkForeignKeyというクラスを作る必要があるが、まだ未対応。

4. SQLiteでIN句が使えない

Djangoのモデルは 、SQLiteのIN句に対応していない。

5. レコード登録は、saveよりCREATEが良い

INSERTについては、CPKModelのsaveを使うより、CPKQuerySetのcreateメソッドを使う方がいい。何故なら、Modelのsaveでは、キー値が設定されている場合、最初にUPDATEを行うので(その次にINSERTを実行)。 オプションでforce_insert=Trueを指定することで、避けることは可能ではあるが。

CompanyBranch.objects.create(**params)

 または 
 
obj = CompanyBranch(**params)
obj.save(force_insert=True)

インストール方法

pip install django-compositepk-model

リンク

https://code.djangoproject.com/ticket/373
https://code.djangoproject.com/wiki/MultipleColumnPrimaryKeys
https://gijutsu.com/2021/01/19/django-composite-primary-key/
https://gijutsu.com/2021/03/16/django-ticket373/

PostgreSQL更新後にポートがデフォルトに

CentOS7にyumで入れていたPostgreSQLについて、更新したところ、ポートがデフォルトに戻ってしまった。

サービス起動ファイルが更新されてしまったため、再度、修正した。

# systemctl stop postgresql
# systemctl disable postgresql 
# vi /usr/lib/systemd/system/postgresql.service  ※Environment=PGPORT=5432 を変更して、サービス再登録
# systemctl enable postgresql 
# systemctl start postgresql 
# systemctl status postgresql  ※ポート変更されていることを確認

しかし、そもそも起動ファイルが、何で定義ファイルを無視するようになってるんだ・・・。

[Django]ソース構成について

Djangoへの移行作業で、VisualStudioのサンプルコードを参考に開発を始めたので、アプリケーションの主なソース構成はいわゆるMVCパターンで以下となっていた。

ソースファイル備考
V:ビューapp/templates/
C:コントローラーapp/views.py
M:モデルapp/models.py
app/managers.py
DBデータの定義
Managerの拡張クラス。

移行するシステムは、それなりに大きくて、上記構成では各ファイルが大きくなってきて、分かりにくくなってきた。そこで、構成を見直した。

新しい構成としては、以下のようにしてみた。

階層ソースファイル備考
V:ビューapp/templates/
C:コントローラーapp/views.pyクラスビューを定義
C:コントローラーapp/contorollers.pyリクエストに応じて、プロシージャやサービスを呼び出す。
BP:プロシージャapp/procedures.pyサービスをシーケンス呼び出しする手順
BS:サービスapp/services.pyここがいわゆるビジネスロジック。ここが大きくなりそうなら、ディレクトリにしてファイル分割する。
DP:データプロバイダーapp/providers.pyデータへのアクセスを提供。SpringでいうRepositoryの位置づけ。
Model,Managerにはここからアクセスする。
M:モデルapp/models.pyDBデータの定義

Managerの拡張クラスを使わなくした理由は、個人的にDjangoのModelとMangerの関係が、Mangerに処理を定義していくと、Modelとの相互参照の問題も出てくるという作りがどうも好きになれないので。相互参照を避けるための方法は提供されているが・・・。

上記の構成にするため、簡単なフレームワークを作成した。まだ直さないといけない部分はあるが、とりあえずは、この方針で進めてみる。