Rails6.1で、セッションをキャッシュとは別のmemcachedへ保存する

Rails6.1でセッションをキャッシュとは別のmemcachedへ保存しようとした時、色々調べたことをメモに残します。

 
目次

 

環境

 

デフォルトのセッションストレージ

まずは、デフォルトのセッションストレージ CookieStore を確認してみます。
2.3 セッションストレージ | Rails セキュリティガイド - Railsガイド

 

Railsアプリの作成

Railsアプリをゼロから作成してきます。今回は rails_dalli_sample とします。

% rails new rails_dalli_sample

 
今回の動作確認ではwebpacker使いません。そのため、Gemfileでコメントアウトしておきます。

source 'https://rubygems.org'
...
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
#gem 'webpacker', '~> 5.0'  # コメントアウト

 
また、Railsでの memcached クライアントは Dalli gem のため、Gemfileに追加します。
petergoldstein/dalli: High performance memcached client for Ruby

gem 'dalli'

 
改めて bundle install します。

% bundle install

 
続いて、ControllerとViewを生成します。

今回は home Controller に index メソッドをもたせます。

% bin/rails g controller home index --helper=false --assets=false
Running via Spring preloader in process 21510
      create  app/controllers/home_controller.rb
       route  get 'home/index'
      invoke  erb
       exist    app/views/home
      create    app/views/home/index.html.erb
      invoke  test_unit
      create    test/controllers/home_controller_test.rb

 
作成したControllerの index メソッドで、セッションに値を入れます。
5.1 セッションにアクセスする | Action Controller の概要 - Railsガイド

今回は key を foo 、値を bar とします。

class HomeController < ApplicationController
  def index
    session[:foo] = 'bar'
  end
end

 
Viewは自動生成のものを流用します。

ただ、今回はwebpackerを使わないため、layoutファイル (app/views/layouts/application.html.erb) から javascript_pack_tag タグの部分をコメントアウトしておきます。

<%#= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

 
また、これは任意ですが、手元のRailsが複数ある場合はRailsの起動ポートを変えておきます。
rails s 時のデフォルトのポート番号を変更する - Qiita

# config/puma.rb
port ENV.fetch("PORT") { 3700 }

 

動作確認

準備ができたので、Railsを起動します。

% bin/rails s
=> Booting Puma
=> Rails 6.1.3.2 application starting in development 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.3.2 (ruby 3.0.1-p64) ("Sweetnighter")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 21698
* Listening on http://127.0.0.1:3700
* Listening on http://[::1]:3700
Use Ctrl-C to stop

 
ブラウザで http://localhost:3700/home/index へアクセスすると、Cookieに暗号化された値が保存されています。

 

キャッシュの保存先を変更するための準備

セッションの保存先を変える前に、まずはRailsのキャッシュの保存先をmemcachedへと移動してみます。

 

フラグメントキャッシュを使うために erb を修正

今回はフラグメントキャッシュを有効化してみます。

フラグメントキャッシュとして保存するよう、 View ( app/views/home/index.html.erb ) に追記します。

<% cache 'my_cache' do %>
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>
<% end %>

 

環境ごとのDB設定を追加

今回は色々なパターンを確認することから、いくつもの環境設定を用意します。

DBは使わないものの、環境に応じた設定がされていないとエラーになることから、以下の設定を config/database.yml の末尾へ追加します。

cache:
  <<: *default
  database: db/development.sqlite3

cache_port:
  <<: *default
  database: db/development.sqlite3

memd_session:
  <<: *default
  database: db/development.sqlite3

memd_session_port:
  <<: *default
  database: db/development.sqlite3

session_cache_store:
  <<: *default
  database: db/development.sqlite3

 

docker composeによる memcached を用意

次に、memcachedを用意します。

今回は docker compose を使って memcached を3つたてます。

service名 mac上のポート 用途
default 11211 デフォルトポートで起動するmemcached
cache 17001 Cache用memcached
session 17002 Session用memcached

 
docker-compose.ymlはこんな感じです。

version: "3"
services:
  default:
    image: memcached
    ports:
      - 11211:11211
  cache:
    image: memcached
    ports:
      - 17001:11211
  session:
    image: memcached
    ports:
      - 17002:11211

 
docker compose で memcached を起動しておきます。
Compose CLI Tech Preview | Docker Documentation

% docker compose up -d

 

memcached の中身を確認するPythonスクリプトを作成

memcachedの中身の確認ですが、RubyMineではさくっとできなかったため、別の方法を探してみました。

Pythonのライブラリを探したところ

を組み合わせれば良さそうでした。

そこで、コマンドライン引数としてポートを渡せば中身を確認できるようなスクリプト

import sys
from pymemcache.client.base import Client
from memcached_stats import MemcachedStats

if __name__ == '__main__':
    port = sys.argv[1]
    mem = MemcachedStats('localhost', port)

    client = Client(f'localhost:{port}')
    for key in mem.keys():
        print(client.gets(key))

を作りました。

これで準備は完了です。

 

デフォルトポートの memcached へキャッシュを保存

環境ファイルの作成

まずは、docker compose 上にある、デフォルトポートの memcached へキャッシュを保存してみます。

今回の記事では色々なパターンを試すことから、 config/environments ディレクトリの中に、各パターンの環境を作成します。
3.20 Rails環境を作成する | Rails アプリケーションを設定する - Railsガイド

今回は development.rb をコピーし、各パターンの環境を作成します。

ここでは、 cache.rb を作成し、デフォルトポートの memcached へキャッシュを保存する設定を行います。

まずは既存の設定

# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp', 'caching-dev.txt').exist?
  config.action_controller.perform_caching = true
  config.action_controller.enable_fragment_cache_logging = true

  config.cache_store = :memory_store
  config.public_file_server.headers = {
    'Cache-Control' => "public, max-age=#{2.days.to_i}"
  }
else
  config.action_controller.perform_caching = false

  config.cache_store = :null_store
end

を削除します。

次に、デフォルトポートの memcached を使うよう設定します。
2.5 ActiveSupport::Cache::MemCacheStore | Rails のキャッシュ機構 - Railsガイド

config.cache_store = :mem_cache_store

 

動作確認

準備ができたので、 環境 cache を指定して起動します。
1.2 rails server | Rails のコマンドラインツール - Railsガイド

% bin/rails s -e cache

 
ブラウザで http://localhost:3700/home/index へアクセスした後、 memcached の状態をPythonスクリプトで確認します。

ポート 11211 のmemcached のみ、データが格納されていました。

% python display_memcached.py 11211
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x171622552901.4518201:\x10@expires_in0', b'1')
...

 
次の確認を行う前に、すべての memcached を再起動し、Pythonスクリプトでデータがないことを確認しておきます。

なお、以降の確認後も同じ作業を実施するものとします。

 

キャッシュをキャッシュ用memcachedへと移動

続いて、キャッシュをキャッシュ用memcachedへと移動します。

環境ファイルの作成

ホストとポートを指定するには、 config.cache_store の設定を追加すれば良さそうです。

キャッシュの初期化時には、クラスタ内の全memcachedサーバーのアドレスを指定する必要があります。指定がない場合、memcachedがローカルのデフォルトポートで動作していると仮定して起動しますが、この設定は大規模サイトには向いていません。

Rails のキャッシュ機構 - Railsガイド

 
そのため、上記で作成した cache.rb をコピーした cache_port.rb ファイルをenvironments の中に用意し、以下の設定に書き換えます。

config.cache_store = :mem_cache_store, 'localhost:17001'

 

動作確認

環境を指定して起動します。

% bin/rails s -e cache_port

 
ブラウザで http://localhost:3700/home/index へアクセスした後、 memcached の状態をPythonスクリプトで確認すると、ポート 17001 のmemcachedにキャッシュが保存されていました。

# 空
% python display_memcached.py 11211

# 指定したポートの memcached にキャッシュが保存
% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')

# こちらも空
% python display_memcached.py 17002

 

セッションストレージをデフォルトのmemcachedへ移動

Rails6.1では dalli_store を指定しても動作しない

キャッシュの移動ができたため、次はセッションストレージを移動します。

設定方法については、DalliのWikiに記載がありました。
Caching with Rails · petergoldstein/dalli Wiki

 
そこで、memd_session.rb という環境ファイルを用意し

config.session_store = :dalli_store, 'localhost:11211'

と設定します。

続いて、

% bin/rails s -e memd_session

とした後、ブラウザでアクセスしてみましたが、セッションストレージはCookie Storeのままでした。

 
調べてみたところ、Rails 5.2でワーニングが出るようになり、:dalli_store から :mem_cache_store へと変更されたようです。

 

Rails6.1では mem_cache_store を指定する

memd_session.rb を修正します。

# 指定したポートにある memcached へCacheを保存
config.cache_store = :mem_cache_store, 'localhost:17001'

# セッションストアを memcached へ変更
config.session_store :mem_cache_store   # << 変更箇所

 

動作確認

再度 bin/rails s -e memd_session で起動し、ブラウザで http://localhost:3700/home/index へアクセスします。

Cookieのキー _rails_dalli_sample_session の代わりに _session_id がありました。また、値もセッションIDだけになっています。

 
続いて、memcachedの値を確認します。

フラグメントキャッシュは、指定通り 17001 ポートにありました。

% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554618.821114:\x10@expires_in0', b'5')
...

 
一方、セッションの値はデフォルトポートのmemcachedに入っていました。

\x07I"\x08foo\x06:\x06EFI"\x08bar\x06; のようにして、 foo=bar なセッションの値が確認できました。

% python display_memcached.py 11211
(b'\x04\x08{\x07I"\x08foo\x06:\x06EFI"\x08bar\x06;\x00TI"\x10_csrf_token\x06;\x00FI"1D7iFliPuRIrsP1D1GfYM0lr8WhM3xKdaPWDRod9Uhs0=\x06;\x00F', b'6')
...

 

セッションストレージをsession用のmemcachedへ移動

コードを読んで、設定方法を調査

ポートを指定する方法がRailsガイドでは分かりませんでした。

そこで、Dalliの公式Wikiにあったように、

config.session_store :mem_cache_store, 'localhost:17002'

と、第2引数にホストとポートを指定して起動してみたところ

`session_store': wrong number of arguments (given 2, expected 0..1) (ArgumentError)

というエラーになりました。

 
そこでRailsガイドのセッションストレージの記載

ActionDispatch::Session::MemCacheStore :データをmemcachedクラスタに保存する (この実装は古いのでCacheStoreを検討すべき)

5 セッション | Action Controller の概要 - Railsガイド

より、 ActionDispatch::Session::MemCacheStore の実装を見ると

# https://github.com/rails/rails/blob/v6.1.3.2/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb#L17

class MemCacheStore < Rack::Session::Dalli
# ...

となっていました。

次に Rack::Session::Dalli の実装を見てみると

# https://github.com/petergoldstein/dalli/blob/v2.7.11/lib/rack/session/dalli.rb#L10

module Rack
  module Session
    class Dalli < defined?(Abstract::Persisted) ? Abstract::Persisted : Abstract::ID
      attr_reader :pool, :mutex

      DEFAULT_DALLI_OPTIONS = {
        :namespace => 'rack:session',
        :memcache_server => 'localhost:11211'
      }
# ...

と、オプション :memcache_server としてセッション用のmemcachedを渡せそうでした。

 

設定

調査結果をもとに、環境ファイル memd_session_port.rb

# 指定したポートにある memcached へCacheを保存
config.cache_store = :mem_cache_store, 'localhost:17001'

# セッションストアを memcached へ変更し、ポートも指定する
config.session_store :mem_cache_store, memcache_server: 'localhost:17002'

と設定しました。

 

動作確認

上記で作成した環境ファイルを指定して

% bin/rails s -e memd_session_port

と起動すると

  • キャッシュはキャッシュ用memcached (17001ポート)
  • セッションはセッション用memcached (17002ポート)

に保存されていました。

# 無い
% python display_memcached.py 11211

# Cache用
% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x151622644680.48053:\x10@expires_in0', b'1')
...

# セッション用
% python display_memcached.py 17002
(b'\x04\x08{\x07I"\x08foo\x06:\x06EFI"\x08bar\x06;\x00TI"\x10_csrf_token\x06;\x00FI"1IseQM7XUAmDJwXYDllu0OwpS8DpoLCTRjATi8kIzOGE=\x06;\x00F', b'2')
...

 
想定通りの設定ができたようです。

 

キャッシュとセッションを同じ memcached へ保存する

当初の目的 セッションをキャッシュとは別のmemcachedへ保存する は達成したものの、先ほど見たRailsガイド

ActionDispatch::Session::MemCacheStore :データをmemcachedクラスタに保存する (この実装は古いのでCacheStoreを検討すべき)

5 セッション | Action Controller の概要 - Railsガイド

この実装は古いのでCacheStoreを検討すべき が気になりました。

また、「Action Controller の概要」にも

ユーザーセッションに重要なデータが含まれていない場合、またはユーザーセッションを長期間保存する必要がない場合 (flashメッセージで使いたいだけの場合など) は、ActionDispatch::Session::CacheStoreを検討してください。この方式では、Webアプリケーションに設定されているキャッシュ実装を利用してセッションを保存します。この方法のよい点は、既存のキャッシュインフラをそのまま利用してセッションを保存できることと、管理用の設定を追加する必要がないことです。この方法の欠点はセッションが短命になり、セッションがいつでも消える可能性がある点です。

5 セッション | Action Controller の概要 - Railsガイド

とありました。

そこで CacheStore も試してみます。

 

設定

Railsガイドによると、

config.session_store: セッションの保存に使うクラスを指定します。指定できる値は:cookie_store(デフォルト)、:mem_cache_store、:disabledです。:disabledを指定すると、Railsでセッションが扱われなくなります。デフォルトでは、アプリケーション名と同じ名前のcookieストアがセッションキーとして使われます。カスタムセッションストアを指定することもできます。

config.session_store :my_custom_store

カスタムストアはActionDispatch::Session::MyCustomStoreとして定義する必要があります。

3.1 Rails全般の設定 | Rails アプリケーションを設定する - Railsガイド

とありました。

 
ActionDispatch::Session::CacheStore はすでに存在していることから、Railsガイドに従って環境ファイル session_cache_store.rb

# 指定したポートにある memcached へCacheを保存
config.cache_store = :mem_cache_store, 'localhost:17001'

# セッションストアを "ActionDispatch::Session::CacheStore" にする
config.session_store :cache_store

# ちなみに、以下の書き方でも動作した
# config.session_store ActionDispatch::Session::CacheStore

に、キャメルケースをスネークケースに変換したシンボル :cache_store で指定します。

 

動作確認

上記で作成した環境ファイルを指定して

% bin/rails s -e session_cache_store

と起動してアクセス後に確認したところ、

# 無い
% python display_memcached.py 11211

# キャッシュとセッションが同居
% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@value{\x07I"\x08foo\x06:\x06EFI"\x08bar\x06;\x07TI"\x10_csrf_token\x06;\x07FI"1-z6lVxxcPeSrD6sBI0LsfxUWlmsIVr7ybExtJix2uX8=\x06;\x07F:\r@version0:\x10@created_atf\x161622879435.064419:\x10@expires_in0', b'2')
...
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622879435.008329:\x10@expires_in0', b'1')
...

# 無い
% python display_memcached.py 17002

と、キャッシュとセッションが同居していました。

 
ちなみに、

config.cache_store = :file_store, Rails.root.join('tmp', 'cache', 'files')
config.session_store :cache_store

とすると、キャッシュとはファイルが別だったものの、セッションもファイルストレージに保存されていました。

こんな感じです。

^D^Ho: ActiveSupport::Cache::Entry      :^K@value{^GI"^Hfoo^F:^FEFI"^Hbar^F;^GTI"^P_csrf_token^F;^GFI"1Va3rVZwaQqxekgbKJHpN6dyA5JEyFtrbrO9kWqmOdfs=^F;^GF:^M@version0:^P@created_atf^W1622870283.7916899:^P@expires_in0

 

ソースコード

Githubに上げました。
thinkAmi-sandbox/rails_session_of_memcached-sample

PyCharmで、anyenv + nodenv で構築した ReactとDjango REST Frameworkの両方をデバッグしてみた

今までPyCharmでDjango REST framework(以下、DRF)のデバッグを行ったことはありました。

そんな中、以前 React + TypeScript + DRFでアプリを作りました。

ただ、ReactとDRFをPyCharmだけでデバッグしたことがなかったため、どのように設定すればデバッグできるか試してみました。

 
目次

 

環境

 

DRFを起動する設定

PyCharmでDRFを開発していれば、以下の設定があるはず。。。

項目
Configuration Template Django Server
Name 任意 (backendなど)
Host localhost
Port 8000
Environment variables (デフォルト値)
Python interpreter Projectで使ってる venv のインタプリタ
Add content roots to PYTHONPATH チェックする
Add source roots to PYTHONPATH チェックする

 

yarnでReactを起動する設定

React側は

の2つが必要になります。

まずは、yarnでReactを起動する設定です。

項目
Configuration Template npm
Name 任意
package.json [Reactアプリのpackage.json forntend/package.json のパス
Command start
Node interpreter anyenv + nodenvで入れたNodeのパス (~/.anyenv/envs/nodenv/versions/<version>/bin/node )
Package manager yarn (~/.anyenv/envs/nodenv/versions/<version>/lib/node_modules/yarn)

 
yarnの設定後、実行ボタンをクリックし、Reactが起動すればOKです。

Runのログにはこんな感じで表示されます。

yarn run v1.22.10
$ react-scripts start
...
Starting the development server...

 

JavaScriptデバッグ設定

yarnでReactを起動する設定を行いデバッグ実行したとしても、まだブレークポイントでは停止しません。

そこで、JavaScriptデバッグ設定を追加します。

項目
Configuration Template JavaScript Debug
Name 任意
URL [Reactアプリを起動した時のURL ( http://localhost:3000 など)
Browser Chrome

 
以上で準備ができました。

 

動作確認

流れとしては、

  1. DRFデバッグ起動
  2. Reactを Run で起動
  3. JavaScript Debugを Debug で起動

の順で起動します。

また、DRFとReactの両方にブレークポイントを置いてみます。

 
3. の実行後、拡張機能が何もインストールされていない別のChromeウィンドウが起動します*1

試しに適当な値を入力し、「登録」ボタンを押してみます。 

 
まずはReactのところで止まりました。

f:id:thinkAmi:20210524211014p:plain

 
続行してみると、DRFの方でも止まりました。

f:id:thinkAmi:20210524211153p:plain

 

なお、Reactのコードを修正したところ、Chromeにも反映されました。

f:id:thinkAmi:20210524211405p:plain

 
これで良さそうです。

*1:今回はReactをポート3600で起動していますが、3000でも問題ないはず

rake -T では表示されないRakeタスクについて

Rakefileの中に定義されているRakeタスクを確認しようと、 rake -T したところ表示されないRakeタスクがあったため、メモを残します。

 

環境

  • rake 13.0.2

 

原因

ヘルプに書いてありました。

rake -T では desc がないRakeタスクは表示されないとのことでした。

% rake --help

-T, --tasks [PATTERN]            Display the tasks (matching optional PATTERN) with descriptions, then exit. -AT combination displays all of tasks contained no description.

 

確認

こんな感じでRakefileを作ってみました。

desc "シナノゴールド"
task :shinanogold do
    puts '黄色'
end

desc ""
task :fuji do
    puts ''
end

desc nil
task :orin do
    puts ''
end

task :pinklady do
    puts 'ピンク'
end

 
rake -T の場合、descがあるものだけ表示されました。

% rake -T
rake shinanogold  # シナノゴールド

 
一方、 rake -TA の場合、すべて表示されました。

% rake -AT
rake fuji         # 
rake orin         # 
rake pinklady     # 
rake shinanogold  # シナノゴールド

 
なお、 rake -TA では表示されませんでした。

% rake -TA #=> 何も表示されない

 
他に、 rake -P などでも表示されるようです。
ruby on rails - Why is rake db:migrate:reset not listed in rake -T? - Stack Overflow

今回はgrepを渡さなくても、全件表示されました。

% rake -P
rake fuji
rake orin
rake pinklady
rake shinanogold

 
また、D オプションと組み合わせた時はこんな感じでした。

% rake -D 
rake shinanogold
    シナノゴールド


% rake -AD
rake fuji

rake orin

rake pinklady

rake shinanogold
    シナノゴールド

Delayed Job を使って実行する処理を、RubyMineでデバッグをする

Rails + Delayed Job な環境で、Delayed Job を使って実行する処理がありました。

例えばこんな感じです。

# Delayed Job で実行
# call_heavy_api中で外部APIを呼んでいるが、その処理が重いとする
Task.delay.call_heavy_api({ foo: 'bar' })

 
そんな中、Delayed Job を使って実行する処理 (上記例では call_heavy_api の中身) をデバッグしたくなったところ、同僚からやり方を聞いたため、メモを残します。

 
目次

 

環境

  • macOS
  • RubyMine 2021.1.1

 

設定

Delayed Jobs は Rake タスクなので、以下の設定を RubyMine に行います。

  • Run > Edit Configurations ...
  • Rakeタスクを追加
    • Name: 任意
    • Configurationタブ
      • Task name: jobs:work
    • Bundlerタブ
      • Run the script in context of the bundle (bundle exec) にチェックを入れる
        • bundlerを使っているため

 

実際のアプリで確認

Rails + Delayed Job なアプリを作って確認してみます。

 

Railsアプリを作成
環境構築
# railsの準備
% bundle init

 
Gemfileを作成

# Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails"

 
APIRailsアプリを作成

% rails new delayed_job_debug --api

% cd delayed_job_debug

 
delayed_job_debugのGemfileに、Delayed JobをActiveRecordで使うためのgemを追記。

# delayed_job_debug/Gemfile

# ...
gem 'delayed_job_active_record'

 
インストール

bundle install

 
Delayed Job の準備をします。

% bin/rails g delayed_job:active_record
Running via Spring preloader in process 13171
      create  bin/delayed_job
       chmod  bin/delayed_job
      create  db/migrate/20210517154917_create_delayed_jobs.rb

% bin/rake db:migrate
Running via Spring preloader in process 13208
== 20210517154917 CreateDelayedJobs: migrating ================================
-- create_table(:delayed_jobs)
   -> 0.0035s
-- add_index(:delayed_jobs, [:priority, :run_at], {:name=>"delayed_jobs_priority"})
   -> 0.0013s
== 20210517154917 CreateDelayedJobs: migrated (0.0050s) =======================

 
config/application.rb にて、Active JobのバックエンドとしてDelayed Job を指定

# config/application.rb
config.active_job.queue_adapter = :delayed_job

 

アプリ追加

重いAPIを呼んでいるモデルを作ります。

今回はDBがなくても確認できるため、モデルファイル app/models/task.rb を作るだけにします。

class Task
  class << self
    # 外部APIの処理が重いとする
    def call_heavy_api(params)
      Rails.logger.warn(params)

      'success'
    end
  end
end

 
上記のメソッドを呼んでいるコントローラを作ります。

% bin/rails g controller tasks
Running via Spring preloader in process 13248
      create  app/controllers/tasks_controller.rb
      invoke  test_unit

 
生成されたファイル app/controllers/task_controller.rb に追記します。

class TasksController < ApplicationController
  def create
    Task.delay.call_heavy_api({ foo: 'bar' })

    render json: { status: 'SUCCESS', data: 'done' }
  end
end

 
config/routes へ追記します。

Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  post 'tasks/' => 'tasks#create'
end

 

Railsアプリの動作確認

実行します。
f:id:thinkAmi:20210519072647p:plain

 

TerminalからcurlでPOSTします。

% curl -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:3500/tasks/

{"status":"SUCCESS","data":"done"}

 
DBに値が入っています。

f:id:thinkAmi:20210519072635p:plain

 
Delayed Job をターミナルから実行します。

% bin/rake jobs:work

Running via Spring preloader in process 32682
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Starting job worker
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Job Task.call_heavy_api (id=3) RUNNING
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Job Task.call_heavy_api (id=3) COMPLETED after 0.0080
[Worker(host:kamijonoMacBook-Pro.local pid:32682)] 1 jobs processed at 12.7021 j/s, 0 failed

 
Sever development log に実行内容も表示されました。

[Worker(host:kamijonoMacBook-Pro.local pid:32682)] Job Task.call_heavy_api (id=3) RUNNING
{:foo=>"bar"}

 
確認できたため、TerminalでDelayed Job をキャンセルしておきます。

 

デバッグ実行
RubyMineの設定

メニューの Run > Edit Configurations... を選択します。

左上の + ボタンを押して Rake を選択し、以下の内容を追加します。

項目
Name 任意 (job)
Configuration - Task name jobs:work
Bundler - Run the script... チェックを入れる

 

動作確認

Terminalから curl で POST し、DBに保存されることを確認します。

% curl -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:3500/tasks/
{"status":"SUCCESS","data":"done"}

 
確認したいところにブレークポイントを設置します。

f:id:thinkAmi:20210519072617p:plain

 
先ほど作成した設定を選択し、デバッグ実行します。

なお、初回は ruby-debug-ide のインストールが提案されるため、インストールします。

f:id:thinkAmi:20210519072600p:plain

 
しばらく待つと、ブレークポイントで止まりました。変数の中身なども確認できます。

f:id:thinkAmi:20210519072544p:plain

 

ソースコード

確認したRailsアプリはGithubに上げました。
https://github.com/thinkAmi-sandbox/delayed_job_debug-sample

React + TypeScript + Django REST frameworkでToDo管理アプリを作ってみた

先日、大岡由佳さんの「りあクト! 第3.1版 (2020年12月26日 初版第1刷発行)」を読みました。書籍では理由や経緯などが書かれており、とてもためになりました。ありがとうございました。

 
そこで、Reactの理解を深めるために何か作ろうと考え、よくあるToDo管理アプリを作ってみたことから、その時のメモを残します。

 

目次

 

環境

  • React 17.0.2
  • TypeScript 4.1.2
  • Python 3.9.5
  • Django 3.2.2
  • Django REST framework 3.12.4
  • PyCharm Professional 2021.1.1

 
今回は各段階を追ってアプリを作成していきます。

アプリのイメージははこんな感じで、段階ごとの見た目はほとんど変わりません。

f:id:thinkAmi:20210510223401p:plain
ToDo管理アプリ

 

やることとやらないこと

やること
  • Reactまわり
    • TypeScriptで書く
    • React Router の5系で書く
    • Hooksを使う
  • Django REST frameworkまわり
    • ModelViewSetを使ってお手軽に書く

 

やらないこと

React & TypeScriptに慣れるため、以下をやらないことにしました。

 

環境構築

Python

最初のうちは Django REST frameworkを使いませんが、忘れないよう venv で環境を作っておきます。

% python --version
Python 3.9.5

% python -m venv env

 

CRAによる、React環境構築
% npx create-react-app frontend --template typescript

We suggest that you begin by typing:

  cd frontend
  yarn start

Happy hacking!

 

ESLint

CRAでESLintはインストールされているため、init します。

% yarn eslint --init

? How would you like to use ESLint? … 
❯ To check syntax, find problems, and enforce code style

? What type of modules does your project use? … 
❯ JavaScript modules (import/export)

? Which framework does your project use? … 
❯ React

? Does your project use TypeScript? › Yes

? Where does your code run? …  (Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser

? How would you like to define a style for your project? … 
❯ Use a popular style guide

? Which style guide do you want to follow? … 
❯ Airbnb: https://github.com/airbnb/javascript

? What format do you want your config file to be in? … 
❯ JavaScript

Checking peerDependencies of eslint-config-airbnb@latest
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

eslint-plugin-react@^7.21.5 @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint@^5.16.0 || ^6.8.0 || ^7.2.0 eslint-plugin-import@^2.22.1 eslint-plugin-jsx-a11y@^6.4.1 eslint-plugin-react-hooks@^4 || ^3 || ^2.3.0 || ^1.7.0 @typescript-eslint/parser@latest

? Would you like to install them now with npm? › No

 
エラーが出ました。

ESLint couldn't find the config "airbnb" to extend from. Please check that the name of the config is correct.

The config "airbnb" was referenced from the config file in "".

If you still have problems, please stop by https://eslint.org/chat/help to chat with the team.

error Command failed with exit code 2.

 
足りていないものを、yarnでインストールします。

% yarn add eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react-hooks @typescript-eslint/parser@latest

info All dependencies
├─ @typescript-eslint/eslint-plugin@4.22.1
├─ @typescript-eslint/parser@4.22.1
├─ eslint-config-airbnb-base@14.2.1
├─ eslint-config-airbnb@18.2.1
├─ eslint-plugin-import@2.22.1
├─ eslint-plugin-jsx-a11y@6.4.1
├─ eslint-plugin-react-hooks@4.2.0
└─ eslint-plugin-react@7.23.2

 
設定ファイルを追加・更新します。

全部載せると長いので、リンクをはっておくだけにします。

 

Prettier

インストールします。

% yarn add -D prettier eslint-config-prettier

info All dependencies
├─ eslint-config-prettier@8.3.0
└─ prettier@2.2.1

»  TypeSync v0.8.0
✔  2 new typings added.

📦 frontend — package.json (2 new typings added, 0 unused typings removed)
├─ + @types/prettier
└─ + @types/testing-library__jest-dom

✨  Go ahead and run npm install or yarn to install the packages that were added.


% yarn

success Saved lockfile.

 
設定ファイルを追加します。

 
ESLintとPrettierが衝突していないか確認します。

% npx eslint-config-prettier 'src/**/*.{js,jsx,ts,tsx}'
No rules that are unnecessary or conflict with Prettier were found.

 

package.jsonのscriptにeslintやprettierを追加します。

"fix": "npm run -s format && npm run -s lint:fix",
"format": "prettier --write --loglevel=warn 'src/**/*.{js,jsx,ts,tsx,gql,graphql,json}'",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}",
"lint:fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'",
"lint:conflict": "eslint --print-config .eslintrc.js | eslint-config-prettier-check",
"preinstall": "typesync"

 

PyCharmの設定

今回はProfessional版を使うので、prettier pluginを追加します。

その後、以下の設定を行います。

 

ESLintの設定

Languages & Frameworks > JavaScript > Code Quality Tools > ESLint

[x] Automatic ESLint configuration

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx,html,vue}

[ ] Run eslint --fix on save

 

Prettierの設定

Languages & Frameworks > JavaScript > Prettier

Prettier package: <プロジェクトの node_modules にある prettierを指定>

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx}

[x] On code reformat
[x] ON save

 
Prettierの設定ができているかは、index.tsx

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root'),
);

ワンライナーにした状態で保存した時に、上記のように修正されればOKです。

 

huskyとlint-staged

インストールします。

% yarn add -D husky lint-staged

 
package.jsondevDependencies の下に追加します。

"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
},
"lint-staged": {
  "src/**/*.{js,jsx,ts,tsx}": [
    "prettier --write --loglevel=warn",
    "eslint --fix"
  ],
  "src/**/*.{json}": [
    "prettier --write --loglevel=warn"
  ]
}

 

ディレクトリ構成

今回は段階を追ってアプリを作成していくため、

  • srcの下に component を作る
  • componentの下に、 v1 のような各段階ごとのcomponentを入れるディレクトリを作る

のようなディレクトリ構造としました。

 

第1段階:入力フォームとリスト表示を1つのコンポーネントに入れたバージョン

まずは入力フォームとリスト表示を1つのコンポーネントに入れてみます。

React Routerと使用するバージョンについて

各段階でURLを変えるため、React Routerを使います。

りあクト!では v5 と v6 の話がありましたが、今のところまだ v6 はリリースされていないため、今回は v5 系を使います。

 

インストール
% yarn add react-router react-router-dom

% yarn

 

App.tsx の実装

App.tsx にReact Routerの実装を行います。

今回は

な書き方とします。

import React, { VFC } from 'react';
import './App.css';
import { Switch, Route, Redirect } from 'react-router';
import V1Home from 'components/v1/V1Home';
import Home from './components/Home';

const App: VFC = () => (
  <div className="container">
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>

      <Route exact path="/v1">
        <V1Home />
      </Route>

      <Redirect to="/" />
    </Switch>
  </div>
);

export default App;

 

V1Home.tsx

React Routerから呼ばれるコンポーネントです。この中で V1FormList コンポーネントを呼び出しています。

import { VFC } from 'react';
import V1FormList from './V1FormList';

const V1Home: VFC = () => (
  <>
    <h1>タスクリスト</h1>
    <V1FormList />
  </>
);

export default V1Home;

 

V1FormList.tsx

全体像はこんな感じです。以降で詳しく見ていきます。

import { FormEvent, useEffect, useRef, useState, VFC } from 'react';

type Task = {
  id: number;
  content: string;
};

const V1FormList: VFC = () => {
  const [content, setContent] = useState('');
  const [tasks, setTasks] = useState<Task[]>([]);

  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>{task.content}</li>
        ))}
      </ul>
    </>
  );
};

export default V1FormList;

 

State Hookの利用

上記では、input要素で入力した値を保管するために State Hook を使っています。
ステートフックの利用法 – React

 
まずは入力値を入れておく State Hookを用意します。

const [content, setContent] = useState('');

 
次に、入力した値はリストとして表示したいため、リスト表示用の State Hook も用意します。

Stateの型は上記の content を配列で持つ string[] でも良いのですが、リスト表示する時の key を与えたいため、型を用意します。
リストと key – React

type Task = {
  id: number;
  content: string;
};

この型を使った State を用意します。

const [tasks, setTasks] = useState<Task[]>([]);

 
フォームで登録ボタンを押した時に、リスト表示用の State に値を追加する関数を用意します。

const addTask = (e: FormEvent) => {
  e.preventDefault();
  setTasks([...tasks, { id: tasks.length, content }]);
  setContent('');
};

ここで、addTask関数の引数は FormEvent型 を想定しています。
Forms and Events | React TypeScript Cheatsheets

また、 preventDefault() を使ってデフォルトのイベントをキャンセルし、バックエンドにリクエストが飛ばないようにします。

 
また、配列のStateでは、Array.pushが使えません。

そのため、スプレッド構文を使って、Stateに値を追加しています。

setTasks([...tasks, { id: tasks.length, content }]);

 
最後の setContent(''); は、リスト表示用Stateに値を入れたら画面のinput要素をクリアするために用意しています。

 
あとは

  • formの onSubmit に、追加用関数 addTask を割り当て
  • inputの onChange に、content Stateの更新処理を追加
  • inputの value に、 onChangeで更新した値を割り当て
  • ulの中で、 tasks Stateのデータを元に表示

とします。

return (
  <>
    <form onSubmit={addTask}>
      <input
        type="text"
        name="task"
        onChange={(e) => setContent(e.target.value)}
        value={content}
      />
      <button type="submit">登録</button>
    </form>

    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

 

フォーカスの指定

ここで、「登録ボタンを押した後、input要素にフォーカスを戻したい」という要望がある場合、どのように実装すればよいかを調べてみました。

りあクト!Ⅱの p192 より、Reactでは、 useRefuseEffect を使うと良さそうでした。

const taskRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
  taskRef.current?.focus();
});

 
ちなみに、型定義や useRefの引数から null を抜くと、以下のエラーとなりました。

TS2322: Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'LegacyRef<HTMLInputElement>| undefined'.
Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'RefObject<HTMLInputElement>'.
Types of property 'current' are incompatible.
Type 'HTMLInputElement | undefined' is not assignable to type 'HTMLInputElement | null'.
Type 'undefined' is not assignable to type 'HTMLInputElement | null'.

 
この taskRef を input に紐付けます。

<input
  ..
  ref={taskRef}
  ..
/>

 

動作確認

以下でReactが起動しますので、タスクを色々いれてみます。

% yarn start

 

これで第1段階が完成しました。コミットは こちら です。

 

第2段階:FormとListのコンポーネントを分離する

第1段階では ListForm.tsx の中に Form と List が含まれていました。

次はそれらを分離してみます。

 

Stateをどこに持たせるか

分離するにあたり、 State をどこに持たせるかを考えます。

content は Form でしか使っていませんが、 tasks は Form と List の両方で使っています。

Reactの流儀によると、

共通の親コンポーネントか、その階層構造でさらに上位の別のコンポーネントが state を持っているべきである

https://ja.reactjs.org/docs/thinking-in-react.html#step-4-identify-where-your-state-should-live

とのことなので、今回の共通の親である V2Home.tsxtasks Stateを持たせます。

const V2Home: VFC = () => {
  const [tasks, setTasks] = useState<Task[]>([]);

  return (
    <>
      <h1>タスクリスト (v2)</h1>
      <V2Form tasks={tasks} setTasks={setTasks} />
      <V2List tasks={tasks} />
    </>
  );
};

 

型定義

ここで、 Task 型はどのコンポーネントでも使うので、 src/components/Task.ts に型定義を置いておきます。

export type Task = {
  id: number;
  content: string;
};

なお、Task typeをV2Home.tsxに持たせ、

// Home
import V2Form from './V2Form';

// Form
import { Task } from './V2Home';

のように import したところ

ESLint: Dependency cycle detected.(import/no-cycle)

というESLintのエラーが出ました。

 
あとは、FormとListを分離します。

 

V2Form.tsx

このコンポーネントがpropsで受け取る型は

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

とします。

なお、setTasksの型 Dispatch<SetStateAction<Task[]>> については、PyCharmで提案されたものをそのまま入れました。

Formの全体像はこちら。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V2Form: VFC<Props> = ({ tasks, setTasks }) => {
  const [content, setContent] = useState('');
  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>
    </>
  );
};

 

V2List.tsx

Listも、分離したもの + propsの型定義を用意します。

type Props = {
  tasks: Task[];
};

const V2List: VFC<Props> = ({ tasks }) => (
  <>
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

これで第2段階が完成しました。コミットは こちら です。

 

第3段階:Django REST framework製バックエンドと連携する

ここまでは入力した値は State にしか保存していませんでした。そのため、リロードしたら消えてしまいます。

そこで次はDBを持つバックエンドを用意して、データを永続化します。

 

Django REST framework アプリの作成

今回はReactの習得がメインなので、バックエンドは慣れている Django REST framework (以降、DRF) で作ります。

今回はCRUDができれば良いので、ModelViewSetを使ってお手軽に作ります。

詳しい実装は省略しますが、気になる場合は以下の書籍を参考にしてください。
現場で使える Django REST Framework の教科書(第2版) - あきよこブログ(akiyoko blog) - BOOTH

 
DRFをインストールします。

(env) % pip install django djangorestframework
...
Successfully installed asgiref-3.3.4 django-3.2.2 djangorestframework-3.12.4 pytz-2021.1 sqlparse-0.4.1

 
Djangoプロジェクトとアプリを作ります。

(env) % django-admin startproject config .
(env) % python manage.py startapp task
(env) % python manage.py startapp apiv1

 

なお、DRFとReactを連携する際、DRFとReactが

  • Reactはポート3000で起動
  • DRFはポート8000で起動

するため、別オリジンとなります。

そこで、 django-cors-headers を追加でインストールと設定を行い、ReactからのDRFアクセスを許可します。
adamchainz/django-cors-headers: Django app for handling the server headers required for Cross-Origin Resource Sharing (CORS)

(env) % pip install django-cors-headers

 
ちなみに、手元にあるDRFの教科書(2019/9/22 v1.0.0)で使っていた 3.1.0では、別オリジンの設定名は CORS_ORIGIN_WHITELIST でしたが、今回使った 3.7.0 では CORS_ALLOWED_ORIGINS に変わっていました。

CORS_ALLOWED_ORIGINS = (
    'http://localhost:3000',
    'http://127.0.0.1:3000',
)

 
 
PyCharmにDjangoを認識させます。

# Languages & Frameworks > Django

[x] Enable Django support

Django project root : <root dir>
Settings: config/settings.py
Manage script: manage.py

 

準備ができたため、 このコミットのようなアプリを作ります。

アプリを作り終えたらマイグレーションします。

(env) % python manage.py makemigrations

(env) % python manage.py migrate

 
あとはDRFを起動し、Browsable APIhttp://localhost:8000/api/v1/tasks/ にアクセス、データの登録などができることを確認します。

 

axiosのインストール

ReactからDRFへリクエストを飛ばすために、axiosをインストールします。

% yarn add axios

% yarn

 

FormコンポーネントDRFと連携する

連携するため、

  • 初期値のロード
  • 登録ボタンを押すと、DRF側へリクエストが飛んで保存

の2つを実装します。

 

初期値のロード

初期値をロードする Effect Hook を用意します。
React Hooksでデータを取得する方法 - Qiita

useEffect(() => {
  console.log('called!');
  const fetchData = async () => {
    const response: AxiosResponse<ApiGetResponse> = await axios.get(API_URL);
    setTasks(response.data);
  };

  void fetchData();
}, [setTasks]);

 

登録ボタンを押すと、DRF側へリクエストが飛んで保存

ボタンを押したらリクエストを飛ばすため、addTask関数の中に処理を追加します。
reactjs - How to POST request using axios with React Hooks? - Stack Overflow

axiosを使って非同期でリクエストを飛ばすため、 async/awaitも使います。

const addTask = async (e: FormEvent) => {
  e.preventDefault();

  const requestData = { content };
  try {
    const response: AxiosResponse<ApiPostResponse> = await axios.post(
      API_URL,
      requestData,
    );

    setTasks([...tasks, { id: response.data.id, content }]);
    setContent('');
  } catch (error) {
    alert('エラーでした');
  }
};

 
なお、axiosを使う時の型ですが、AxiosResponseは

import axios, { AxiosResponse } from 'axios';

と、axios から importします。

また、axiosのレスポンスのdataの型は、

type ApiGetResponse = {
  id: number;
  content: string;
}[];

type ApiPostResponse = {
  id: number;
  content: string;
};

と用意し、

const response: AxiosResponse<ApiPostResponse> = await axios.post()

のように渡します。

 
これで第3段階も完成です。コミットは3つに分かれています。

 

第4段階:削除ボタンを付ける

今までは作成するだけだったので、削除ボタンを付けてみます。

Listの方に付けるため、まずは削除する関数を用意します。

removeTask関数の中では、

  • axiosで削除
  • tasks Stateに、引数で渡された task.id と一致しないものだけセット

します。

const removeTask = async (taskId: number) => {
  try {
    await axios.delete(`${API_URL}${taskId}/`);
    setTasks(tasks.filter((task) => task.id !== taskId));
  } catch (error) {
    alert('エラーでした');
  }
};

 
そして、Listのコンポーネントの中でも setTasks Hookを使うようになったため、型定義とpropsを追加します。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V4List: VFC<Props> = ({ tasks, setTasks }) => { ... }

 
これで完成です。コミットは こちら

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/todolist_by_react_ts_drf

また、今回実装した機能がまとまっているプルリクはこちらです。
https://github.com/thinkAmi-sandbox/todolist_by_react_ts_drf/pull/1

Python + openpyxlを使って、月末日を除く五十日始まりのカレンダーを作成してみた

Pythonの標準モジュール calendar では、カレンダーを作るための便利な関数が用意されています。
calendar --- 一般的なカレンダーに関する関数群 — Python 3.9.4 ドキュメント

例えば、 monthcalendar で年月を指定すると週ごとに日付リストが得られるため、これを元にしたカレンダーが作りやすいです。

>>> import calendar
>>> import pprint

>>> pprint.pprint(calendar.monthcalendar(2021, 4))
[[0, 0, 0, 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, 0, 0]]

 
ただ、この calendar モジュールではカレンダーの初日が1日に固定されます。

そんな中、五十日(ごとおび)始まりのカレンダーを作る機会があったため、メモを残します。

 

目次

 

環境

 

仕様

Wikipediaによると、五十日とは

五十日(ごとおび)とは、毎月5日・10日・15日・20日・25日と、30日または月末日のことである。

五十日 - Wikipedia

とのことです。

月により月末日が変わる点が厄介でしたが、今回は「月末の五十日は月初の五十日と1日しか異ならないし、月末始まりのカレンダーは使わない」ということだったので、月末の五十日は仕様対象外としました。

最終的には

  • カレンダーの初日に当たる年月日をコマンドラインから入力
  • カレンダーの罫線や週の数は不変のため、テンプレートとしてExcelファイルを用意し、そこに日付を埋めていく
  • 29日以降の日付を入力したらエラーにする
    • 月末の扱いが手間なので、一番日数が少ない2月に合わせた

という仕様としました。

なお、Excel製テンプレートはこんな感じです。

f:id:thinkAmi:20210429130754p:plain

 

プログラムの構成

大きく分けて

  • 年月日の入力
  • カレンダー用データの作成
  • Excelへの埋め込み

の3つの構成としました。

 

年月の入力

ここは普通に入力を受け取るだけです。

def input_values():
    print('開始する年を入力してください')
    yyyy = input()

    print('開始する月を入力してください')
    mm = input()

    print('開始する日を入力してください')
    dd = input()

    try:
        if int(dd) > 28:
            print('29日以降はサポート対象外です')
            return None
        return datetime.datetime(int(yyyy), int(mm), int(dd))
    except:
        print('日付ではありません')
        return None

 

カレンダー用データの作成

扱いやすそうだった、標準モジュール calendar.monthcalendar の戻り値の形式に合わせてデータを作成します。

まずは「開始日」〜「翌月の開始日-1日」を作成します。

# カレンダーの初日から最終日までの日付を作る
dates = []
while True:
    dates.append(current_date)
    current_date += datetime.timedelta(days=1)

    # ここまでで翌月の当日になっていたら、処理を終了する
    if start_at == current_date.day:
        break

# ここまでの結果 (2021/7/10始まりの場合)
# [
#   datetime.datetime(2021, 7, 10, 0, 0), ... , datetime.datetime(2021, 8, 9, 0, 0)
# ]

 
続いて、テンプレートよりカレンダーは日曜日始まりとする調整を行います。

カレンダー初日が日曜日でない場合は、標準モジュールのようにダミーの値を先頭に挿入します。今回は None を挿入しました。

# 先頭は日曜日始まり
weekday_of_first_day = dates[0].weekday()
if weekday_of_first_day != 6:  # 日曜日以外
    # 日曜日始まりでない場合、開始日の曜日以前はダミー(None)を入れておく
    for _ in range(weekday_of_first_day + 1):
        dates.insert(0, None)

# ここまでの結果
# [
#  None, None, None, None, None, None,
#  datetime.datetime(2021, 7, 10, 0, 0), ... , datetime.datetime(2021, 8, 9, 0, 0)
# ]

 
ここまででひと月分のカレンダーデータができました。

テンプレートに埋めやすくするため、カレンダーデータを週ごと(7要素ごと)に分割します。

なお、月によっては最後の週だけが7つの要素にならないことがあるため、以下の記事を参考に itertools.zip_longest を使って足りない分はNoneを埋め込みます。
リストをn個ずつのサブリストに分割 (Python) - おぎろぐはてブロ

dates_by_calendar = [item for item in itertools.zip_longest(*[iter(dates)] * 7)]

# ここまでの結果
# [
#  (None, None, None, None, None, None, datetime.datetime(2021, 7, 10, 0, 0)),
#  (datetime.datetime(2021, 7, 11, 0, 0), ... , datetime.datetime(2021, 7, 17, 0, 0)),
#  ...
#  (datetime.datetime(2021, 8, 8, 0, 0), datetime.datetime(2021, 8, 9, 0, 0), None, None, None, None, None)
# ]

 
後はこれを12ヶ月分繰り返します。

全体像はこんな感じです。

def create_calendar(current_date):
    start_at = current_date.day
    calendar = []

    for _ in range(12):
        # カレンダーの初日から最終日までの日付を作る
        dates = []
        while True:
            dates.append(current_date)
            current_date += datetime.timedelta(days=1)

            # ここまでで翌月の当日になっていたら、処理を終了する
            if start_at == current_date.day:
                break

        # 先頭を埋める
        # 先頭は日曜日始まり
        weekday_of_first_day = dates[0].weekday()
        if weekday_of_first_day != 6:  # 日曜日以外
            # 日曜日始まりでない場合、開始日の曜日以前はダミー(None)を入れておく
            for _ in range(weekday_of_first_day + 1):
                dates.insert(0, None)

        # リストを一週間ごと(7要素ごと)のリストへ分割し、最後の要素が足りない場合はNoneを入れる
        dates_by_calendar = [item for item in itertools.zip_longest(*[iter(dates)] * 7)]
        calendar.append(dates_by_calendar)

    return calendar

 

Excelへの埋め込み

openpyxlを使って埋め込みます。

Excleのセル数に合わせて細かいことをしていますが、コメント通りです。

def to_excel(calendar):
    wb = openpyxl.load_workbook('template_cal.xlsx')

    sheet = wb.copy_worksheet(wb['テンプレート'])
    sheet.title = '結果'

    plot(sheet, calendar)

    wb.save(f'cal_{datetime.datetime.now().strftime("%Y%m%d%H%M%S")}.xlsx')


def plot(sheet, calendar):
    for i, weeks_of_month in enumerate(calendar, 1):
        is_first_time = True

        mod_col = i % 3
        if mod_col == 1:  # 左端のカレンダーに入力
            pos_col = 2
        elif mod_col == 2:  # 中央のカレンダーに入力
            pos_col = 11
        else:
            pos_col = 20  # 右のカレンダーに入力

        if 1 <= i <= 3:  # 1行目のカレンダーに入力
            pos_row = 4
        elif 4 <= i <= 6:  # 2行目のカレンダーに入力
            pos_row = 13
        elif 7 <= i <= 9:  # 3行目のカレンダーに入力
            pos_row = 22
        else:
            pos_row = 31  # 4行目のカレンダーに入力

        # 一ヶ月のうちの一週間分の日付を取得する
        for row_index, current_week in enumerate(weeks_of_month):
            # 日付をセルに設定する
            for col_index, current_date in enumerate(current_week):
                if current_date:  # ダミーは印字しない
                    # そのカレンダーに初めて日付を設定する場合、タイトルも設定する
                    if is_first_time:
                        sheet.cell(row=pos_row-2, column=pos_col, value=f'{current_date.month}月')
                        is_first_time = False

                    sheet.cell(
                        row=pos_row+row_index, column=pos_col+col_index, value=current_date.day
                    )

 
これで必要な関数はできたため、 main 関数でそれぞれを呼び出せば完成です。

def main():
    input_date = input_values()
    if not input_date:
        print('終了します')
        return

    calendar = create_calendar(input_date)
    to_excel(calendar)
    print('作成しました')

 

動作確認

実際に動かしてみます。

% python run.py     
開始する年を入力してください
2021
開始する月を入力してください
7
開始する日を入力してください
10
作成しました

 
できあがりです。

f:id:thinkAmi:20210429132235p:plain

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/gotobi_calendar

SendGridでは、受信者メールアドレスの大文字小文字はどうなるか試してみた

メールアドレスの形式について調べる機会があったため、RFC5321(日本語訳)を見たところ、

動詞と引数の値(例えば RCPT コマンドにおける "TO:" または "to:" や拡張名キーワード)は大文字・小文字を区別されないが、メールボックスの local-part の指定が唯一の例外である(SMTP 拡張は大文字・小文字を区別する要素を明示的に規定してもよい)。つまり、コマンド動詞、メールボックスの local-part 以外の引数、自由形式のテキストは、その意味に影響を与えることなく、大文字、小文字、または大文字・小文字の任意の組み合わせで符号化されてよい(MAY)ということである。メールボックスの local-part は大文字・小文字を区別されなければならない(MUST)。したがって SMTP 実装は、メールボックスの local-part の大文字・小文字が保持されるよう注意しなければならない(MUST)。具体的にいうと、一部のホストにとってユーザー "smith" はユーザー "Smith" と異なるということである。しかしながら、メールボックスの local-part の大文字・小文字の区別の濫用は相互運用性を妨げるため、推奨されない。メールボックスドメインは通常の DNS 規則にしたがい、大文字・小文字を区別しない。

http://srgia.com/docs/rfc5321j.html#p2.4

とありました。

また、

「仕様としては、"区別する"だけど、メールボックスの運用実態としては"区別しない"運用になっている」

http://babyp.blog55.fc2.com/blog-entry-982.html

との記載もありました。

 
SendGridでどうなるか調べたところ、それらしいドキュメントが見当たりませんでした。

そこで今回、受信者のメールアドレスの大文字小文字がどうなるか試してみました。

 

目次

 

環境

前回の記事同様、Pythonスクリプトで試したため、以下の環境となります。

  • Python 3.8
  • sendgrid 6.6.0
  • smtpapi 0.4.7
  • python-dotenv 0.16.0

 

試したソースコード

SendGridのSMTPサーバを使って、以下のような 'send' 関数を用意しました。

後は呼び出し元で、受信者のメールアドレスを変えてみればよいとしました。

import os
import smtplib
from email.mime.text import MIMEText

from smtpapi import SMTPAPIHeader
from dotenv import load_dotenv
load_dotenv()


def send(to_email):
    server = create_smtp_server()

    from_email = os.environ['FROM_EMAIL']
    to_email = [to_email]

    body = 'hello'
    message = MIMEText(body)
    message['From'] = from_email
    message['TO'] = ','.join(to_email)

    message['Subject'] = 'メールドロップテスト'

    server.sendmail(from_email, to_email, message.as_string())
    server.quit()


def create_smtp_server():
    host = 'smtp.sendgrid.net'
    port = 587
    user = 'apikey'
    password = os.environ['SENDGRID_API_KEY']
    server = smtplib.SMTP(host, port)
    server.starttls()
    server.login(user, password)
    return server


if __name__ == '__main__':
    main()

 

正常に届くメールについて

存在するメールアドレスをすべて大文字で指定した FOO@EXAMPLE.COM 宛にメールを送信したところ、

となっていました。

 
また、 Foo@Example.com と大文字小文字を混ぜたところ、

となっていました。

正常に届くメールはドメイン部分は全て小文字になるようです。

 

Event Webhookで受け取った時の値について

次に、存在しないメールアドレス notfound@example.comBounceした時、SendGridのEvent Webhookではどのような値が返ってくるかを調べてみました。

メールアドレスに対し

  • すべて大文字
  • すべて小文字
  • 大文字小文字混在

の条件でそれぞれ送信したところ、

{status=5.1.1, smtp-id=xxx, ip=149.72.71.211, tls=0.0, 
reason=550 5.1.1 <notfound@example.com>: Recipient address rejected: User unknown in virtual mailbox table,
email=notfound@example.com, 
sg_event_id=xxx, event=bounce, 
sg_message_id=xxx, 
timestamp=1.617030019E9, 
type=bounce}

のようにすべて小文字 ( notfound@example.com ) となっていました。

 

まとめ

上記より、現時点のSendGridでは

  • 届くメール
    • ユーザ部分はそのまま・ドメイン部分は小文字化にて、メールが送信される
  • 届かないメール
    • Event Webhookのメールアドレスは、すべて小文字化される

のようです。

 

ソースコード

Githubに上げました。 lettercase の中が今回のファイルです。
https://github.com/thinkAmi-sandbox/sendgrid_event_webhook-sample