django-vite を使って、Django の template 上で React を動かしてみた

これは Djangoのカレンダー | Advent Calendar 2022 - Qiita の12/6分の記事です。

フロントエンドのビルドツール Vite を使って、ReactをDjangoで動かす方法を調べたところ、awesome-viteのページに django-vite の記載がありました。

 
そこで、フロントエンドとして React + Vite 、バックエンドとして Django + Django REST framework な構成でTODOアプリを作って試してみたため、メモを残します。

なお、記事が長いため、記事ではソースコードを省略している部分が多々あります。もしソースコードの詳細を確認したい場合はGithubを参照ください。

 
目次

 

環境

  • WSL2
  • Django環境
  • React 環境
    • React 18.2.0
    • React Router 6.4.4
    • Vite 3.2.3
    • TypeScript 4.6.4

 
なお、 django-vite を使った場合のシステム構成は書籍「 現場で使える Django REST Framework の教科書 (Django の教科書シリーズ) | 横瀬 明仁 」のチュートリアル同様、

  • Django
  • Django REST framework (DRF)
    • フロントエンド向けWeb API
  • React + React Router + axios
    • フロントエンドのルーティングやDRFのWeb APIへのリクエス

となります*1

 

バックエンド(Django + DRF)のセットアップ

まずはバックエンドから作成していきます。なお、ソースコードは後述の場所で公開していますので、バックエンドの作成では省略します。

モデルを含む task アプリと、APIapi アプリを作成します。

$ pip install django djangorestframework django-vite

$ django-admin startproject config .

$ python manage.py startapp api
$ python manage.py startapp task

 
taskアプリを実装します。今回はadminサイトでデータ登録するため、 admin.py も実装しておきます。

  • models.py
  • admin.py

 
apiアプリも実装します。

  • serializers.py
  • views.py
  • urls.py

 
configディレクトリにも修正を加えます。

  • urls.py

 
ファイルの実装が終わったところでマイグレーションを実行します。

$ python manage.py makemigrations
$ python manage.py migrate

 
adminサイトでデータ登録するためのユーザーを作成します。

$ python manage.py createsuperuser
Username (leave blank to use 'thinkami'): admin
Email address: admin@example.com
Password: pass12345
Password (again): pass12345
Superuser created successfully.

 
Djangoを起動し、adminサイトでデータを登録します。

$ python manage.py runserver

# http://localhost:8000/admin/ にアクセスして、データ登録

 
データ登録後、curlで動作確認を行います。レスポンスがあればOKです。

$ curl http://localhost:8000/api/tasks/
[{"id":1,"content":"買い物","updated_at":"2022-11-28T20:36:19.668279+09:00"}]

 

フロントエンドのセットアップ

Viteを使ってReactをセットアップ

django-viteのREADMEによると、Viteを使ってReactをセットアップするところまでは、通常のVite + Reactのセットアップで良いようです。
https://github.com/MrBin99/django-vite#vitejs

 
そこで、Viteを使って frontend ディレクトリの中にReactアプリをセットアップしていきます。

$ npm create vite@latest
Need to install the following packages:
  create-vite@3.2.1
Ok to proceed? (y) y
✔ Project name: … frontend
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Scaffolding project in path/to/django_vite/static...

Done. Now run:

  cd static
  npm install
  npm run dev

 
あとは画面にあるように、 npm installnpm run dev を実行した後、ターミナルに表示されたURLへアクセスして、Vite + React 画面が出ればOKです。

 

frontendディレクトリの整理

以下の設定ファイルを、 frontend ディレクトリからプロジェクトルートディレクトリへと移動します。

  • package.json
  • package-lock.json
  • tsconfig.json
  • tsconfig.node.json
  • vite.config.ts

 
また、以下の不要なディレクトリファイルは削除します。

  • node_modules/
  • public/
  • index.html

 
整理後、ルートディレクトリで npm install しておきます。

 

DjangoとReactを連携して、開発環境で動かす

ここまでで一通りセットアップできたので、DjangoとReactが連携するアプリを作っていきます。

 

vite.config.tsでNode.jsの path モジュールを import できるようにする

以下を参考に、Node.jsの path モジュールを import できるようにします。
TypeScriptのモジュールのインポートには import を使う|まくろぐ

$ npm install --save-dev @types/node

 

Djangoと連携できるよう vite.config.ts を設定

Viteの設定ファイルである vite.config.ts を定義します。

 
設定ファイル全体は以下のとおりです。

import { defineConfig } from 'vite'
import { resolve } from 'path'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],

  // 開発環境のmain.tsxが置いてある場所
  root: resolve('./frontend/src'),

  // Djangoでの静的ファイル配信設定である STATIC_URL と同じになるよう設定
  base: '/static/',

  server: {
    host: 'localhost',
    port: 5173,
    open: false,
    watch: {
      usePolling: true,
      disableGlobbing: false,
    },
  },
})

 

Vite側と連携できるよう、Djangoの設定ファイルを修正

Djangoの設定ファイルは開発環境向けと本番環境向けとで分けます。

今回は開発環境で動作を確認してから本番環境の設定を行うことにします。

そこで、まずは開発環境向けの設定のみを行います。

 

開発環境向け

django-viteまわりの設定

django-viteのREADMEに従い設定します。

いくつか設定項目はありますが、開発環境向けとしては以下の項目を settings.py に追加すれば良さそうです。

ちなみに、Viteは3系から開発サーバの起動するポートが 5173 へと変更になりましたが、django-viteでのデフォルトポートは今のところ 3000 のままのようです。
Vite 3.0 no longer defaults to port 3000 · Issue #51 · MrBin99/django-vite

そのため、 DJANGO_VITE_DEV_SERVER_PORT の設定も必要です。

# 開発環境の場合、この値は参照されないので設定不要
# ただ、キーや中身がないと以下のエラーになるため、中身は適当な値を設定しておく
# AttributeError: 'Settings' object has no attribute 'DJANGO_VITE_ASSETS_PATH'
DJANGO_VITE_ASSETS_PATH = BASE_DIR

# HMRするためDebugと同じにしておく
DJANGO_VITE_DEV_MODE = DEBUG

# Vite.jsがv3系からポートが変更になったので対応
DJANGO_VITE_DEV_SERVER_PORT = 5173

 

CORSの設定

開発環境では、ReactとDjangoでは別オリジンになります。

 
そのため、 django-cors-headers をインストールしてCORSの設定を追加します。
adamchainz/django-cors-headers: Django app for handling the server headers required for Cross-Origin Resource Sharing (CORS)

INSTALLED_APPS = [
    ...
    'corsheaders',
    ...
]

MIDDLEWARE = [
    ...
    'corsheaders.middleware.CorsMiddleware',  # 追加
    'django.middleware.common.CommonMiddleware',
    ...
]

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

 

templateで if debug をするための設定

後述しますが、templateでは開発環境のみ設定するタグやスクリプトがあります。

そのためには、Djangoのtemplateで if debug を使うことで、開発環境のみレンダリングするようにします。

 
また、templateで debug の値を使えるようにするには、

  • template の context_processors'django.template.context_processors.debug' を追加
  • INTERNAL_IPS で使用するIPアドレスを定義

すれば良さそうです。
django.template.context_processors.debug | The Django template language: for Python programmers | Django ドキュメント | Django

 
なお、ルートディレクトリの templates ディレクトリに React を render するテンプレートを入れておくことから、 DIRS への追加も合わせて行っておきます。

TEMPLATES = [
    {
        ...
        'DIRS': [BASE_DIR / 'templates'],  # 追加
        ...
        'OPTIONS': {
            'context_processors': [
                ...
                'django.contrib.messages.context_processors.messages',
                'django.template.context_processors.debug',  # 追加
            ],
        },
    },
]

 

フロントエンドを実装

今回は frontend/src ディレクトリの中に、Reactアプリを実装していきます。

ライブラリのインストール

まずは必要なライブラリとして

  • React Router
    • フロントエンドでのルーティングを行うため
  • axios
    • バックエンドとの連携を行うため

をインストールします。

$ npm install axios
$ npm install react-router-dom

 

Reactアプリを実装

frontend/src/ ディレクトリの中に、以下の役割を持ったファイルを用意します。

なお、各ファイルの実装については、 django-vite だからといって特別なことをしていません。

そのため、ここでは省略します。詳細は公開したソースコードを参照してください。

ファイルパス 主な役割
main.tsx エントリポイントであり、App.tsxを読み込む
App.tsx pages/ 以下のコンポーネントに対するルーティングを定義
pages/Index.tsx 一覧ページ
pages/TaskDetail.tsx 詳細ページ
pages/TaskForm.tsx 登録ページ

 

Djangoの実装

Django template

django-viteのテンプレートタグを使う

今回は templates/index.html の上で django-vite のテンプレートタグ

  • {% load django_vite %}
  • {% vite_hmr_client %}
  • {% vite_asset '<path to your asset>' %}

を使ってReactアプリを動かすことにします。
MrBin99/django-vite: Integration of ViteJS in a Django project.

 
ちなみに、 vite_asset に指定する path to your asset は、Reactのエントリポイントのファイルを指定します。今回は main.tsx を指定します。

また、 main.tsx までのパス frontend/src については、 vite.config.ts にて root で設定しています。

export default defineConfig({
  // 開発環境のmain.tsxが置いてある場所
  root: resolve('./frontend/src'),
//...
}

 

@vitejs/plugin-react can't detect preamble に対応するコードを追加

Reactの場合、 django-vite のテンプレートタグを書いただけでは

Uncaught Error: @vitejs/plugin-react can't detect preamble. Something is wrong. See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201

のようなエラーが表示されて動作しません。

そのため、 django-vite のissueにあるコードを追加する必要があります。
Add tag to render the react-refresh script · Issue #15 · MrBin99/django-vite

なお、この問題に対するプルリクも存在しましたが、現時点ではマージされていません。
Add template tag to enable react-refresh by BrandonShar · Pull Request #53 · MrBin99/django-vite

 

templateのコード全体

上記の対応を踏まえたtemplateのコード全体は以下となります。

{% load django_vite %}
{% load static %}

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

      {% if debug %}
          <script type="module">
            import RefreshRuntime from 'http://localhost:5173/@react-refresh'
            RefreshRuntime.injectIntoGlobalHook(window)
            window.$RefreshReg$ = () => {}
            window.$RefreshSig$ = () => (type) => type
            window.__vite_plugin_react_preamble_installed__ = true
          </script>
          {% vite_hmr_client %}
          {% vite_asset 'main.tsx' %}

      {% else %}
          {% vite_asset 'main.tsx' %}
      {% endif %}

    <title>Django + Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

 

urls.pyへの追加

上記テンプレートをTemplateViewで描画するよう、config/urls.pyに追加します。

なお、 re_path を使い、Djangoでルーティングできない場合はフロントエンドにルーティングを任せています。

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    re_path('', TemplateView.as_view(template_name='index.html')),  # 追加
]

 

動作確認

DjangoとViteの開発サーバをそれぞれ起動します。

# Django
python manage.py runserer

# Vite
npm run dev

 
その後、 http://localhost:8000/tasks にアクセスするとTODOアプリが表示されます。

 

DjangoとReactを連携して、本番環境で動かす

開発環境での動作が確認できたので、次は本番環境で動作させてみます。

 

Vite側での作業

vite.config.ts にビルド設定を追加

開発環境ではReactアプリをViteの開発サーバから配信していました。

一方、 django-vite のREADMEにあるように、本番環境ではDjangoで配信することになります。

In production mode, assets are included as standard assets (no ViteJS webserver and HMR) like default Django static files. This means that your assets must be compiled with ViteJS before.

Configuration | Usage | MrBin99/django-vite: Integration of ViteJS in a Django project.

 
そこで、ViteでReactアプリをビルドするための設定を vite.config.ts に追加します。

rollupOptions.input にて、Reactのエントリポイントのファイルを指定します。
build.rollupOptions | ビルドオプション | Vite

また、 outDirコンパイル後のファイルの出力先を指定します。
build.outDir | ビルドオプション | Vite

全体はこんな感じです。

export default defineConfig({
  // ...
  build: {
    // コンパイル後の出力先。DJANGO_VITE_ASSET_PATHと一致させる
    outDir: resolve('./frontend/dist'),
    assetsDir: '',
    manifest: true,
    emptyOutDir: true,
    rollupOptions: {
      input: {
        main: resolve('./frontend/src/main.tsx'),
      },
      output: {
        chunkFileNames: undefined,
      },
    }
  }
})

 

ViteでReactアプリをビルド

ビルドの設定ができたため、Viteでビルドします。

$ npm run build

> static@0.0.0 build
> tsc && vite build

vite v3.2.4 building for production...
✓ 80 modules transformed.
../dist/manifest.json       0.21 KiB
../dist/main.3fce1f81.css   1.37 KiB / gzip: 0.71 KiB
../dist/main.f792177d.js    186.01 KiB / gzip: 61.81 KiB

 
すると、 build.outDir で指定したディレクトリにファイルが出力されます。

 
Vite側での作業は以上です。

 

Djangoの設定

settings_production.py の設定

今回は config/settings_production.py に本番環境用の設定を追加することにします。

まずは、本番環境なので以下の環境変数を設定します。

from .settings import *

ALLOWED_HOSTS = [
    'localhost',
    '127.0.0.1',
]

# 本番環境化
DEBUG = False
DJANGO_VITE_DEV_MODE = False

 
また、 django-cors-headers 向けの設定は不要なので空配列を指定しておきます。

CORS_ALLOWED_ORIGINS = []

 
本番環境では collectstatic コマンドを使って静的ファイルを収集するため、収集対象のディレクトリを設定します。

DJANGO_VITE_ASSETS_PATH でViteでビルドした後のファイルが含まれるディレクトリ(Viteの build.outDir で指定したディレクトリ) を指定します。
Configuration | MrBin99/django-vite: Integration of ViteJS in a Django project.

また、ビルドしたファイルを collectstatic の収集対象とするため、 STATICFILES_DIRSDJANGO_VITE_ASSETS_PATH を含めます。
STATICFILES_DIRS | 設定 | Django ドキュメント | Django

他に、 collectstatic の結果を入れておくディレクトリも STATIC_ROOT にて指定します。今回はルート直下の collected_static ディレクトリに入れるようにします。
STATIC_ROOT | 設定 | Django ドキュメント | Django

ちなみに、静的ファイルに関する設定の全体像は、以下の記事の図がわかりやすかったです。
Djangoにおける静的ファイル(static file)の取り扱い - Qiita

# collectstaticの結果を格納するディレクトリ
STATIC_ROOT = BASE_DIR / 'collected_static'

# 本番環境の場合、build.outDir と同じパスを指定する
DJANGO_VITE_ASSETS_PATH = BASE_DIR / 'frontend' / 'dist'
STATICFILES_DIRS = [DJANGO_VITE_ASSETS_PATH]

 

本番環境でも静的ファイルをDjangoの開発サーバから配信するよう urls.py に設定

色々準備するのが手間なので、今回はDjangoの開発サーバから静的ファイルを配信することにします。

(注意) 通常想定している本番運用とは異なるため、今回の記事を参考にして環境を構築する場合は、ここの設定はあるべき姿にしてください。

 
settings.pyで DEBUG=True であればDjangoの開発サーバから配信するため、今回はその設定を無理やり移植することで、 DEBUG=False のときもDjangoの開発サーバから配信できるようにします。

ソースコードを読むと django/conf/urls/static.py のこのあたりの実装を移植・修正すれば良さそうです。
https://github.com/django/django/blob/4.1.3/django/conf/urls/static.py#L10

なお、ルーティングの仕様によりtemplateへルーティングされるのを防ぐため、ルーティングの先頭に静的ファイルのルーティングを追加します。

static_patterns = [
    re_path(r"^%s(?P<path>.*)$" % re.escape(settings.STATIC_URL.lstrip("/")),
            serve,
            {'document_root': settings.STATIC_ROOT}),
] if not settings.DEBUG else []

urlpatterns = static_patterns + [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    re_path('', TemplateView.as_view(template_name='index.html')),
]

 

collectstatic の実行

設定が終わったので、 collectstatic を実行します。

なお、本番環境の設定ファイルを使って実行するよう --settings config.settings_production オプションを付けて実行します。

$ python manage.py collectstatic --settings config.settings_production

You have requested to collect static files at the destination
location as specified in your settings:

    path/to/todo_app_with_django_vite/collected_static

This will overwrite existing files!
Are you sure you want to do this?

Type 'yes' to continue, or 'no' to cancel: yes

3 static files copied to 'path/to/todo_app_with_django_vite/collected_static', 165 unmodified.

 

本番環境を起動して動作確認

ViteやDjangoの開発サーバを停止した上で、Djangoの本番環境を起動します。

$ python manage.py runserver --settings config.settings_production
Performing system checks...

System check identified no issues (0 silenced).
December 06, 2022 - 22:46:40
Django version 4.1.3, using settings 'config.settings_production'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

 
http://localhost:8001/tasks にアクセスすると、画面が表示されます。

React developer tools の色が青くなっており、ビルドしたReactで動いていることが分かります。

 

また、Djangoのログを見ると、Djangoから静的ファイルが配信されていることも分かります。

[06/Dec/2022 22:47:59] "GET /tasks HTTP/1.1" 200 422
[06/Dec/2022 22:47:59] "GET /static/main.3fce1f81.css HTTP/1.1" 200 1405
[06/Dec/2022 22:47:59] "GET /static/main.f792177d.js HTTP/1.1" 200 190476
[06/Dec/2022 22:47:59] "GET /api/tasks/ HTTP/1.1" 200 311
[06/Dec/2022 22:47:59] "GET /favicon.ico HTTP/1.1" 200 422

 
以上で、django-viteを使って、Djangoの template 上で React を動かすことができました。

 

ソースコード

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

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_todo_app_with_django_vite/pull/1

*1:akiyokoさんの記事 https://akiyoko.hatenablog.jp/entry/2022/12/01/064746 によると、DRFの本は今月改訂になるようです

Windows11で、Windows Terminalの設定からsettings.jsonを開く

Windows11でWindows Terminalをカスタマイズしようと考えました。

Web上でよく見かける手順は

  • Windows Terminalのプルダウンから 設定 を選択して、 settings.json を開く

でした。

そこで、

設定 を選択したところ、

のようにGUIが表示されてしまいました。

 
そこで、GUIではなく settings.json ファイルを開く方法を調べてみたため、メモを残します。

 
目次

 

環境

 

方法:Shiftを押しながら設定をクリックする

Microsoftのドキュメントに記載がありました。

Shift キーを押しながら Windows ターミナルのドロップダウン メニューの [設定] を選択し、既定のテキスト エディターで settings.json ファイルを開きます

Windows ターミナルのインストール | Microsoft Learn

 
試してみたところ、たしかに settings.json が開きました。

Google Apps Script を使って、Google Spreadsheet にあるデータを検索するWeb APIを作ってみた

Google Apps Script を使って、Google Spreadsheet にあるデータを検索するWeb APIが作れないか気になりました。

そこで、試してみたときのメモを残します。

 
目次

 

環境

 
また、今回は量がそこそこあるデータから検索してみるため、郵便番号データを使うことにします。
読み仮名データの促音・拗音を小書きで表記するもの - zip圧縮形式 日本郵便

その中の KEN_ALL.csv ファイルを Google Spreadsheet として保存し、Google Apps Script で検索することを考えてみます。

 

Google Apps Script で、Google Spreadsheet のデータを検索する方法

Google Apps Script で、Google Spreadsheet のデータを検索する方法を調べてみたところ、以下の2つがありました。

  • TextFinderを使って検索
  • QUERY関数を使ってデータを検索

 
それぞれどんな感じになるか、試してみます。

 

TextFinderを使って検索

TextFinderを使う場合、

という処理の関数を作れば良さそうでした。

function findByTextFinder(keyword) {
  // TextFinderを使う
  // https://developers.google.com/apps-script/reference/spreadsheet/sheet#createtextfinderfindtext

  const sheet = SpreadsheetApp.getActive().getSheetByName("KEN_ALL")

  const textFinder = sheet.createTextFinder(keyword)
  const items = textFinder.findAll()

  return items.map(item => {
    return {
      'zip_code': sheet.getRange(item.getRow(), 3).getValue(),
      'banchi': sheet.getRange(item.getRow(), 9).getValue()
    }
  })
}

 

QUERY関数を使ってデータを検索

Google Spreadsheet には QUERY 関数があります。
QUERY - Google ドキュメント エディタ ヘルプ

これを使うことで、SQLライクな文法のGoogle Visualization API のクエリ言語でデータを検索できます。
Query Language リファレンス(バージョン 0.7)  |  Charts  |  Google Developers

 
QUERY関数をGoogle Apps Script で使う方法がないかを調べたところ、セルの setValue() を使うと関数が使えそうでした。

setValue(value) 範囲の値を設定します。値は数値、文字列、ブール値、日付にできます。'=' で始まる場合、数式として解釈されます。

https://developers.google.com/apps-script/reference/spreadsheet/range#setvaluevalue

 
そこで、

  • 検索結果を貼り付けるシート(結果シート)を用意
  • 結果シートの1つのセルに、 setValue() でQUERY関数の結果を貼り付け
  • 貼り付けたQUERY関数の結果を読み込み、レスポンスデータを作成

を行う関数を用意すれば良さそうでした。

function responseByQueryFunction(keyword) {
  // QUERY関数を使う
  // setValueに関数を入れられるのでそれを利用する
  // https://developers.google.com/apps-script/reference/spreadsheet/range#setvaluevalue

  // `RESULT` シートに書き込み
  // WHEREの左辺で列を指定して検索できる
  const sheet = SpreadsheetApp.getActive().getSheetByName("RESULT")
  sheet.getRange(1, 1).setValue(`=QUERY(KEN_ALL!A:I,"WHERE I LIKE '%${keyword}%'")`)

  const results = []

  const lastRow = sheet.getLastRow()
  for (let i=2; i <= lastRow; i++) {
    const zip = sheet.getRange(i, 3).getValue()
    const banchi = sheet.getRange(i, 9).getValue()

    results.push({ zip, banchi })
  }

  return results
}

 
なお、実行後のRESULTシートはこんな感じになります。

 

Google Apps Scriptにて、JSONを返すAPIを作る

Google Apps ScriptでWeb APIを作る方法については、以前試したことがあったため、その時の実装を流用すれば良さそうでした。
SendGridのEvent Webhookでメールを識別するため、X-SMTPAPIヘッダのUnique Argumentsを使ってみた - メモ的な思考的な

 
また、クエリパラメータ finder の値により、TextFinderとQUERY関数のどちらを使うかを決められるようにしました。

function doGet(e) {
  const params = e.parameter
  const keyword = params.searchKey
  const finder = params.finder

  const results = finder === 'query' ? findByQueryFunction(keyword) : findByTextFinder(keyword)

  const response = ContentService.createTextOutput()
  response.setMimeType(ContentService.MimeType.JSON)
  response.setContent(JSON.stringify({data: results}))

  return response
}

 

動作確認

今回はブラウザで動作確認を行います。

そのため、まずはブラウザでアクセスできるよう、Google Apps Scriptをデプロイします。

次に、デプロイした後に表示されるURLにリクエストパラメータを加えて、ブラウザでアクセスします。

例えば、 極楽 という文字列が含まれるものをQUERY関数版で検索する場合のURLはこんな感じです。
https://script.google.com/macros/s/***-***-***/exec?searchKey=極楽&finder=query

 
ブラウザでアクセスして動作確認したところ、TextFinderとQUERY関数のいずれも、以下のような結果が得られました。

{
    "data": [
        {
            "zip_code": 230074,
            "banchi": "水沢極楽"
        },
        {
            "zip_code": 2830835,
            "banchi": "極楽寺"
        },
        {
            "zip_code": 2480023,
            "banchi": "極楽寺"
        },
        {
            "zip_code": 9301453,
            "banchi": "原(極楽坂)"
        },
        {
            "zip_code": 9300451,
            "banchi": "極楽寺"
        },
        {
            "zip_code": 4093811,
            "banchi": "極楽寺"
        },
        {
            "zip_code": 5013763,
            "banchi": "極楽寺"
        },
        {
            "zip_code": 5010605,
            "banchi": "極楽寺"
        },
        {
            "zip_code": 4650053,
            "banchi": "極楽"
        },
        {
            "zip_code": 4910144,
            "banchi": "浅井町極楽寺"
        },
        {
            "zip_code": 5220231,
            "banchi": "極楽寺町"
        },
        {
            "zip_code": 6120886,
            "banchi": "深草極楽寺町"
        },
        {
            "zip_code": 6120813,
            "banchi": "深草極楽寺山町"
        },
        {
            "zip_code": 6120027,
            "banchi": "深草極楽町"
        },
        {
            "zip_code": 6148222,
            "banchi": "内里極楽橋"
        },
        {
            "zip_code": 5960832,
            "banchi": "極楽寺町"
        },
        {
            "zip_code": 6392337,
            "banchi": "極楽寺"
        },
        {
            "zip_code": 8290323,
            "banchi": "極楽寺"
        }
    ]
}

 

ソースコード

ソースコード全体は以下の通りです。

function doGet(e) {
  const params = e.parameter
  const keyword = params.searchKey
  const finder = params.finder

  const results = finder === 'query' ? findByQueryFunction(keyword) : findByTextFinder(keyword)

  const response = ContentService.createTextOutput()
  response.setMimeType(ContentService.MimeType.JSON)
  response.setContent(JSON.stringify({data: results}))

  return response
}

function findByQueryFunction(keyword) {
  // QUERY関数を使う
  // setValueに関数を入れられるのでそれを利用する
  // https://developers.google.com/apps-script/reference/spreadsheet/range#setvaluevalue

  // `RESULT` シートに書き込み
  // WHEREの左辺で列を指定して検索できる
  const sheet = SpreadsheetApp.getActive().getSheetByName("RESULT")
  sheet.getRange(1, 1).setValue(`=QUERY(KEN_ALL!A:I,"WHERE I LIKE '%${keyword}%'")`)

  const results = []

  const lastRow = sheet.getLastRow()
  for (let i=2; i <= lastRow; i++) {
    const zip = sheet.getRange(i, 3).getValue()
    const banchi = sheet.getRange(i, 9).getValue()

    results.push({ zip, banchi })
  }

  return results
}

function findByTextFinder(keyword) {
  // TextFinderを使う
  // https://developers.google.com/apps-script/reference/spreadsheet/sheet#createtextfinderfindtext

  const sheet = SpreadsheetApp.getActive().getSheetByName("KEN_ALL")

  const textFinder = sheet.createTextFinder(keyword)
  const items = textFinder.findAll()

  return items.map(item => {
    return {
      'zip_code': sheet.getRange(item.getRow(), 3).getValue(),
      'banchi': sheet.getRange(item.getRow(), 9).getValue()
    }
  })
}

Railsにて、lock_versionカラムがあるモデルを同じ値で更新しても、lock_versionやupdated_atは更新されない

前回、Railsにて、同じ値でデータ更新した場合、タイムスタンプカラム( updated_at )が更新されないことを確認しました。
Railsにて、同じ値でデータ更新した場合、タイムスタンプカラム(updated_at)が更新されない - メモ的な思考的な

 
そんな中、 lock_version カラムがある場合の挙動に関するコメントをいただきました。

「そういえば、挙動で気になることがある」と感じたため、調べてみたときのメモを残します。

 
目次

 

環境

 
なお、今回使うモデルは、以下のコマンドで生成したものとします。

rails g model Apple name:string lock_version:integer

 

lock_versionカラムとは

最近 lock_version について同僚から教わったのですが、 lock_version という名前のカラムがあると、Railsが楽観的ロックを実行してくれます。
ActiveRecord::Locking::Optimistic

Railsガイドによると、 lock_version カラムがあるときの挙動は以下となるようです。

楽観的ロックを使うには、テーブルにlock_versionという名前のinteger型カラムが必要です。Active Recordは、レコードが更新されるたびにlock_versionカラムの値を1ずつ増やします。更新リクエストが発生したときのlock_versionの値がデータベース上のlock_versionカラムの値よりも小さい場合、更新リクエストは失敗し、以下のようにActiveRecord::StaleObjectErrorエラーが発生します。

12.1 楽観的ロック(optimistic) | Active Record クエリインターフェイス - Railsガイド

 

Rails console で動作確認

Railsガイドの記載のうち

Active Recordは、レコードが更新されるたびにlock_versionカラムの値を1ずつ増やします

という挙動について、「同じ値で更新した場合も、 lock_version カラムの値は1増えるのかな?また、もし1増えるなら updated_at も更新されるのかな?」と気になりました。

そこで、Rails console を使って動作を確認してみます。

 

データ作成時

lock_versionupdated_at とも値が設定されました。

irb(main):001:0> Apple.create(name: 'フジ')
  TRANSACTION (0.0ms)  begin transaction
  Apple Create (0.2ms)  INSERT INTO "apples" ("name", "lock_version", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "フジ"], ["lock_version", 0], ["created_at", "2022-11-22 14:14:08.851151"], ["updated_at", "2022-11-22 14:14:08.851151"]]
  TRANSACTION (8.4ms)  commit transaction
=>
#<Apple:0x00007f7b57cda5a8
 id: 1,
 name: "フジ",
 lock_version: 0,
 created_at: Tue, 22 Nov 2022 14:14:08.851151000 UTC +00:00,
 updated_at: Tue, 22 Nov 2022 14:14:08.851151000 UTC +00:00>

 

同じ値でデータ更新

続いて、 name に同じ値を渡して更新してみたところ、SQLのUPDATE文は発行されませんでした。

# 取得
irb(main):002:0> a = Apple.find(1)

# 同じ値で更新
irb(main):003:0> a.update(name: 'フジ')
=> true

 
テーブルからデータを取得してみても、更新はありません。

# 取得
irb(main):004:0> a2 = Apple.find(1)
  Apple Load (0.1ms)  SELECT "apples".* FROM "apples" WHERE "apples"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=>
#<Apple:0x00007f7b57b34820

# 確認
irb(main):005:0> a2
=>
#<Apple:0x00007f7b57b34820
 id: 1,
 name: "フジ",
 lock_version: 0,
 created_at: Tue, 22 Nov 2022 14:14:08.851151000 UTC +00:00,
 updated_at: Tue, 22 Nov 2022 14:14:08.851151000 UTC +00:00>

 

別の値でデータ更新

続いて、 nameフジ から 名月 へと更新したところ、UPDATE文が発行されました。

irb(main):006:0> a2.update(name: '名月')
  Apple Update (0.2ms)  UPDATE "apples" SET "name" = ?, "updated_at" = ?, "lock_version" = ? WHERE "apples"."id" = ? AND "apples"."lock_version" = ?  [["name", "名月"], ["updated_at", "2022-11-22 14:16:24.189472"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
  TRANSACTION (8.3ms)  commit transaction
=> true

 
テーブルからデータを取得してみたところ、 lock_versionupdated_at が更新されています。

# 取得
irb(main):007:0> a3 = Apple.find(1)
  Apple Load (0.1ms)  SELECT "apples".* FROM "apples" WHERE "apples"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=>

# 確認
irb(main):008:0> a3
=>
#<Apple:0x00007f7b57cade90
 id: 1,
 name: "名月",
 lock_version: 1,
 created_at: Tue, 22 Nov 2022 14:14:08.851151000 UTC +00:00,
 updated_at: Tue, 22 Nov 2022 14:16:24.189472000 UTC +00:00>

Railsにて、同じ値でデータ更新した場合、タイムスタンプカラム(updated_at)が更新されない

Railsにはタイムスタンプカラム( created_at / updated_at )があり、各カラムは

  • データ作成時
    • created_atupdated_at が設定される
  • データ更新時
    • updated_at が更新される

という挙動になります。
2.2 スキーマのルール | Active Record の基礎 - Railsガイド

 
そんな中、「同じ値でデータ更新をした場合、 updated_at は更新されない」と同僚より教わったため、メモを残します。

 
目次

 

環境

 
なお、今回使うモデルは、以下のコマンドで生成したものとします。

$ rails g model Fruit name:string

 

Rails consoleで動作確認

Rails consoleで動作を確認してみます。

 

データ作成時

created_atupdated_at が設定されています。

# Rails consoleを起動
$ rails c
Loading development environment (Rails 7.0.4)

# データを作成
irb(main):001:0> Fruit.create(name: 'シナノゴールド')
  TRANSACTION (0.0ms)  begin transaction
  Fruit Create (0.2ms)  INSERT INTO "fruits" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "シナノゴ
ールド"], ["created_at", "2022-11-21 11:55:32.998457"], ["updated_at", "2022-11-21 11:55:32.998457"]]
  TRANSACTION (8.2ms)  commit transaction
=>
#<Fruit:0x00007fabed572df8
 id: 3,
 name: "シナノゴールド",
 created_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00,
 updated_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00>

 

同じ値でデータ更新

nameを シナノゴールド から シナノゴールド に更新してみたところ、SQLのUPDATE文が発行されていませんでした。

# データの取得
irb(main):002:0> f = Fruit.find(3)
  Fruit Load (0.2ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
=>
#<Fruit:0x00007fabed4c93c0

# データの確認
irb(main):003:0> f
=>
#<Fruit:0x00007fabed4c93c0
 id: 3,
 name: "シナノゴールド",
 created_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00,
 updated_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00>

# 同じ値でデータ更新しても、UPDATE文が発行されない
 irb(main):004:0> f.update(name: 'シナノゴールド')
=> true

# 再度データを取得
irb(main):005:0> f2 = Fruit.find(3)
  Fruit Load (0.1ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
=>
#<Fruit:0x00007fabed3d1378

# 中身を確認すると、updated_atはそのまま
irb(main):006:0> f2
=>
#<Fruit:0x00007fabed3d1378
 id: 3,
 name: "シナノゴールド",
 created_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00,
 updated_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00>

 

別の値でデータ更新

nameを シナノゴールド から シナノスイート に更新してみたところ、UPDATE文が発行され、 updated_at も更新されていました。

# シナノゴールドからシナノスイートに更新すると、UPDATE文が発行される
irb(main):007:0> f2.update(name: 'シナノスイート')
 TRANSACTION (0.1ms)  begin transaction
 Fruit Update (0.2ms)  UPDATE "fruits" SET "name" = ?, "updated_at" = ? WHERE "fruits"."id" = ?  [["name", "シナノスイ
ート"], ["updated_at", "2022-11-21 11:56:58.203100"], ["id", 3]]
 TRANSACTION (2.6ms)  commit transaction
=> true

# データの確認
irb(main):008:0> f3 = Fruit.find(3)
  Fruit Load (0.1ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
=>
#<Fruit:0x00007fabed254798
...
irb(main):009:0> f3
=>
#<Fruit:0x00007fabed254798
 id: 3,
 name: "シナノスイート",
 created_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00,
 updated_at: Mon, 21 Nov 2022 11:56:58.203100000 UTC +00:00>

 

同じ値で更新したときも、updated_atを更新したい場合

一方、「同じ値で更新したときも updated_at を更新したい」場合はどうすればよいか調べたところ、 assign_attributeshas_changes_to_save? メソッドと touch メソッドを組み合わせた

# saveメソッドを実行しない、 assign_attributesにて値を設定
f3.assign_attributes(name: 'シナノゴールド')

# 変更があるときはsave、変更がないときはtouchを実行
f3.has_changes_to_save? ? f3.save : f3.touch

にて実現できそうでした。

 

同じ値でデータ更新

こちらの場合は、 updated_at のみ更新されました。

# 同じ name を設定
irb(main):023:0> f3.assign_attributes(name: 'シナノゴールド')
=> nil

# touchが動く
irb(main):024:0> f3.has_changes_to_save? ? f3.save : f3.touch
  TRANSACTION (0.1ms)  begin transaction
  Fruit Update (0.2ms)  UPDATE "fruits" SET "updated_at" = ? WHERE "fruits"."id" = ?  [["updated_at", "2022-11-21 14:10:03.451663"], ["id", 3]]
  TRANSACTION (7.9ms)  commit transaction
=> true

# updated_at が更新されていることを確認
irb(main):025:0> f3
=>
#<Fruit:0x00007f9c310f5198
 id: 3,
 name: "シナノゴールド",
 created_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00,
 updated_at: Mon, 21 Nov 2022 14:10:03.451663000 UTC +00:00>

 

別の値でデータ更新

name をシナノスイートからシナノゴールドへ変更してみたところ、 update メソッドと同様の結果となりました。

# 現在の状態を確認
irb(main):019:0> f3
=>
#<Fruit:0x00007f9c310f5198
 id: 3,
 name: "シナノスイート",
 created_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00,
 updated_at: Mon, 21 Nov 2022 14:06:35.333012000 UTC +00:00>

# nameをシナノゴールドに変更 (この時点では保存しない)
irb(main):020:0> f3.assign_attributes(name: 'シナノゴールド')
=> nil

# saveが動く
irb(main):021:0> f3.has_changes_to_save? ? f3.save : f3.touch
  TRANSACTION (0.1ms)  begin transaction
  Fruit Update (0.2ms)  UPDATE "fruits" SET "name" = ?, "updated_at" = ? WHERE "fruits"."id" = ?  [["name", "シナノゴー ルド"], ["updated_at", "2022-11-21 14:07:47.869049"], ["id", 3]]
  TRANSACTION (8.2ms)  commit transaction
=> true

# nameとupdated_atが更新されていることを確認
irb(main):022:0> f3
=>
#<Fruit:0x00007f9c310f5198
 id: 3,
 name: "シナノゴールド",
 created_at: Mon, 21 Nov 2022 11:55:32.998457000 UTC +00:00,
 updated_at: Mon, 21 Nov 2022 14:07:47.869049000 UTC +00:00>

 

参考:Djangoの auto_now_add と auto_now の場合

Djangoの場合、 auto_now_addauto_now を使うことで、Railscreated_atupdated_at 相当の処理ができます。

ただし、Djangoauto_now では、変更がない場合もタイムスタンプが更新される仕様です。

 
例えば、以下のモデルがあるとします。

from django.db import models

class Fruit(models.Model):
    name = models.CharField('名前', max_length=255)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

 
このモデルに対して name を同じ値で更新しても、 updated_at は更新されます。

Django 4.1.4 の Django shellで試してみます。

# モデルの生成
>>> from myapp.models import Fruit
>>> Fruit.objects.create(name='シナノゴールド')
<Fruit: Fruit object (1)>
>>> f = Fruit.objects.get(id=2)
>>> f.created_at
datetime.datetime(2022, 11, 21, 13, 9, 0, 156105, tzinfo=datetime.timezone.utc)
>>> f.updated_at
datetime.datetime(2022, 11, 21, 13, 9, 0, 156135, tzinfo=datetime.timezone.utc)

# nameをシナノゴールドで更新
>>> f.name = 'シナノゴールド'
>>> f.save()

# created_atはそのままだが、updated_atは更新される
>>> f.created_at
datetime.datetime(2022, 11, 21, 13, 9, 0, 156105, tzinfo=datetime.timezone.utc)
>>> f.updated_at
datetime.datetime(2022, 11, 21, 13, 9, 55, 99880, tzinfo=datetime.timezone.utc)

Python + Django + Highcharts + Coogle Cloud Cloud Run + Cloud Storage + Litestream で食べたリンゴの割合をグラフ化してみた

今まで、「食べたリンゴの割合をグラフ化するアプリ」をHerokuで動かしていました。
Python + Django + Highcharts + Herokuで食べたリンゴの割合をグラフ化してみた - メモ的な思考的な

そんな中、個人的に使ってみたかったGoogle Cloudで動かせるか気になったので、必要な機能を検証してみました。

 
検証してみても特に気になるところはなかったため、本格的にGoogle Cloud へ移行しようと考えました。

そこで、上記の記事に加えて追加の移行作業を行ったため、内容をメモしておきます。

 
目次

 

環境

なお、以下の記事の作業は実施済とします。
Djangoアプリを、Coogle Cloud の Cloud Run + Cloud Storage + Litestream な環境で動かしてみた - メモ的な思考的な

 
また、移行前後で使っている機能は以下の通りです。

Herokuだと 役割 Google Cloudでは
Dyno Djangoアプリをホスト Cloud Run
Dyno 静的ファイルの配信 Cloud Storage
Heroku Postgres Djangoアプリのデータベース SQLite + Litestream + Cloud Storage
Heroku Scheduler ツイートを定期的に収集 Cloud Scheduler

 

どのリージョンを使うか検討し、us-west1 (オハイオ) を使う

高頻度で使うようなDjangoアプリではないため、一番安価かつ多機能なリージョンを選択しようと考えました。

Cloud RunやCloud Storageの無料枠を見ると、北米のリージョンに適用されそうでした。

また、Cloud Runにカスタムドメインを利用する場合は、特定のリージョンのみ可能そうでした。
Cloud Run のドメイン マッピングの制限事項 | カスタム ドメインのマッピング  |  Cloud Run のドキュメント  |  Google Cloud

そこで、

  • 北米のリージョン
    • そのうち、一番日本に近そうなリージョン
  • カスタムドメインが使える

を考慮して、 us-west1 (オハイオ) に各リソースを用意することにしました。

   

Djangoの静的ファイル用の設定を追加

今回、Djangoの静的ファイル(JavaScriptCSS)はCloud Storageから配信するため、設定を追加します。

 

Cloud Storageに、Djangoの静的ファイル用のバケットを作成

静的ファイル用のバケットは公開バケットとして作成します。

Litestream用のバケットの設定に加え、 allUsersStorage オブジェクト閲覧者 の権限を付与します。

もし、静的ファイル用のバケットを公開しない場合、デプロイして動作させると、ログに以下のエラーが出ます。

AttributeError: you need a private key to sign credentials.the credentials you are currently using <class 'google.auth.compute_engine.credentials.Credentials'> just contains a token. see https://googleapis.dev/python/google-api-core/latest/auth.html#setting-up-a-service-account for more details.

 

django-storageを使って、静的ファイルをCloud Storageから配信するよう設定

Google Cloudのチュートリアルの通り、 django-storages を使って静的ファイルをCloud Storageから配信できるよう設定します。
Cloud Run 環境での Django の実行  |  Python  |  Google Cloud

django-storages の公式ドキュメントに従い、 pip install django-storages[google] でインストールします。
Google Cloud Storage — django-storages 1.13.1 documentation

続いて、本番環境の settings.py に設定を行います。

# staticファイルの設定
DEFAULT_FILE_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage'
GS_BUCKET_NAME = os.environ['GS_BUCKET_NAME']
STATICFILES_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage'

# 公開バケットを使用
GS_QUERYSTRING_AUTH = False
GS_DEFAULT_ACL = None

 

Cloud Runの起動時にcollectstaticを実行するように設定を追加

Cloud Storageから配信できるよう、Cloud Runの起動時に collectstatic を実行し、静的ファイルをCloud Storageに置くようにします。

起動時に実行される run.sh に追記します。

python manage.py collectstatic --noinput --settings dj_ringo_tabetter.settings.production

 
なお、 --noinput オプションを追加していますが、これは「すでに静的ファイル置き場にファイルが存在する場合、以下のようなメッセージが出て入力待ちになる」のを回避するためです。

You have requested to collect static files at the destination
location as specified in your settings.

This will overwrite existing files!
Are you sure you want to do this?

Type 'yes' to continue, or 'no' to cancel

 

Secret ManagerにTwitter APIの情報などの秘匿情報を置く

外部公開を防ぎたいものを記載します。

なお、Cloud RunではSecret Managerの内容を環境変数に読み込むこともできるようです。
シークレットを使用する  |  Cloud Run のドキュメント  |  Google Cloud

ただ、ベストプラクティスを読むと、環境変数に読み込むのではなく、ライブラリを使って動的に取得したほうが良さそうです。
Secret Manager のベスト プラクティス  |  Secret Manager のドキュメント  |  Google Cloud

そこで、前回の記事を参考に、 google-cloud-secret-manager 経由でSecret Managerから値を取得するようにします。
Google Cloud Secret Manager にあるシークレットの値を、ローカル環境のPythonスクリプトで取得してみた - メモ的な思考的な

# Secret Managerから値を取得する関数
def fetch_secret_manager(self):
    if settings.DEBUG:
        client = secretmanager.SecretManagerServiceClient.from_service_account_json('gcp_credential.json')
    else:
        # Cloud Run上であれば、認証情報は取得できている
        client = secretmanager.SecretManagerServiceClient()

    path = client.secret_version_path(os.environ['GCP_PROJECT_ID'], 'twitter_tokens', 'latest')
    response = client.access_secret_version(name=path)
    value = response.payload.data.decode('UTF-8')

    # strからdictにする
    return json.loads(value)

 

秘匿するまでもないが環境変数として設定したいものはデプロイ時に設定する

Litestremaの REPLICA_URL など、秘匿するまでもないが環境変数として設定したいものはデプロイ時に設定するようにします。

そのため、デプロイ時に

gcloud beta run deploy ringo-tabetter \
  --set-env-vars REPLICA_URL=gcs://*** \

として環境変数を設定し、ソースコードでは os.environ環境変数から値を取得できるようにします。

 

Cloud Loggingへログを出力できるようにする

デフォルトの設定では、Cloud Loggingにログを出力できません。

そのままではトラブルシューティングがやりづらいので、以下を参考に google-cloud-logging を使ってCloud Loggingにログ出力できるようにします。

 
本番のsettings.pyに以下を追加します。

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'cloud_logging': {
            'class': 'google.cloud.logging.handlers.CloudLoggingHandler',
            'client': google.cloud.logging.Client(),
            'formatter': 'verbose'
        }
    },
    'loggers': {
        'django': {
            'handlers': ['cloud_logging'],
            'level': 'INFO'
        },
    }
}

 

Cloud Schedulerを設定する

Cloud Schedulerを使い、自分のツイートを定期的に収集します。

ただ、Heroku Schedulerとは異なり、Cloud SchedulerではDjangoのカスタムコマンドを実行できないことから

  • Cloud SchedulerからHTTPリクエストができるDjangoのエンドポイントを作成
  • Djangoのエンドポイントの中で、カスタムコマンドを実行

としました。

また、

  • Cloud Schedulerの設定
  • google-auth を使って、Cloud Schedulerのリクエストを受け付けるエンドポイント作成

については、以前の記事通りです。
Django + google-authで、Google Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイントを作成する - メモ的な思考的な

 
以前の記事と異なるのは、Djangoでリクエストを受け付けたら、 call_command でカスタムコマンドを実行する必要があることです。
Running management commands from your code | django-admin と manage.py | Django ドキュメント | Django

ソースコードはこんな感じです。

class GatherTweetView(View):
    """ Cloud Schedulerからのリクエストを受け付ける View """

    http_method_names = ['get']
    client_id = os.environ['SCHEDULER_CLIENT_ID']

    def get(self, request, *args, **kwargs) -> RingoJsonResponse:
        authz_header = request.headers.get('Authorization')
        received_id_token = authz_header.replace('Bearer', '').lstrip()

        try:
            # 認証
            id_token.verify_oauth2_token(received_id_token, requests.Request(), self.client_id)

            # tweetの収集
            call_command('gather_tweets')

            return RingoJsonResponse({
                'status': 'success'
            })
        except ValueError as e:
            print(e)
            return RingoJsonResponse({
                'status': 'unauthorized'
            })

 

Google Domainsで購入したドメインを設定

Herokuと異なり、Cloud RunではURLが ringo-tabetter-***-**.a.run.app と、ランダムな文字列が含まれてしまいます。

そこで、 Google Domains で購入したドメインサブドメインDjangoアプリを割り当ててみます。

 
Cloud Runをカスタムドメインで動かすため、Cloud Runの カスタムドメインの管理 にて マッピング を追加します。

以下の内容で設定します。

項目
マッピングするサービス Cloud Runで作成したringo-tabetterアプリを選択
確認済ドメイン thinkami.dev
サブドメインを指定 ringo-tabetter

 
設定するとGoogle DomainsのDNSに追加するDNSレコードの内容が表示されます。

そのDNSレコードをGoogle DomainsのDNSに設定し、しばらく待つとCloud Runへのマッピングが終わります。

 

専用のサービスアカウントを作成

デフォルトのサービスアカウントでは権限が広めなため、使うものだけに制限したサービスアカウントを新規作成します。

今回は、Cloud Run用とCloud Scheduler用の2つを用意し、それぞれのサービスに割り当てます。

なお、サービスアカウント作成後にサービスアカウントのロールを変更したい場合は、サービスアカウントのメニューではなく、IAMから行うようです。

 

Cloud Run用

以下のロールを持つサービスアカウントを作成します。

ロール名 目的 公式ドキュメント
Cloud Run管理者 Cloud Run実行 Cloud Run IAM roles  |  Cloud Run Documentation  |  Google Cloud
Secret Managerのシークレットアクセサー Secret Managerの読み取り IAM を使用したアクセス制御  |  Secret Manager のドキュメント  |  Google Cloud
ストレージ管理者 Litestreamなどで、バケット・オブジェクトの読み書き Cloud Storage に適用される IAM のロール  |  Google Cloud
ログ書き込み Cloud Loggingへの書き込み IAM によるアクセス制御  |  Cloud Logging  |  Google Cloud

 

Cloud Scheduler用

Google Cloud のリソースにアクセスするわけではないので、ロールの割当はせず、サービスアカウントのみ作成します。

 

Djangoのsettings.pyを修正

SECRET_KEY

Google Cloudへデプロイするにあたり、 SECRET_KEY を再生成することにしました。

そこで、以下のstackoverflowを参考に、Django shellにて SECRET_KEY を再生成しました。
Effects of changing Django's SECRET_KEY - Stack Overflow

再生成した SECRET_KEY は、デプロイ時に環境変数へ設定してDjangoアプリ起動時に読み込めるようにします。

 

Cloud Runへのデプロイコマンド

gcloud beta run deploy コマンドでデプロイします。

gcloud beta run deploy ringo-tabetter \
  --source .  \
  --set-env-vars REPLICA_URL=gcs://<Cloud Storageのバケットパス> \
  --set-env-vars SCHEDULER_CLIENT_ID=<Cloud SchedulerのクライアントID> \
  --set-env-vars GCP_PROJECT_ID=<Google CloudのprojectId> \
  --set-env-vars GS_BUCKET_NAME=<静的ファイルのバケット> \
  --set-env-vars TWITTER_USER_ID=<ツイート収集対象のTwitter User ID> \
  --set-env-vars DJANGO_SECRET_KEY=<シークレットキー> \
  --max-instances 1 \
  --execution-environment gen2 \
  --no-cpu-throttling \
  --allow-unauthenticated \
  --region us-west1 \
  --service-account <Cloud Runのサービスアカウント> \
  --project <Google CloudのプロジェクトID>

 

動作確認

https://ringo-tabetter.thinkami.dev にアクセスしたところ、グラフが表示されました*1

 
なお、「カスタムドメインだけアクセス可」とはしていないので、Cloud Runで払い出されたURLでもアクセスできます。
https://ringo-tabetter-syqtxyot6q-uw.a.run.app

Cloud Runから払い出されたURLをアクセス不可にする場合は、以下が参考になるかもしれません。

 

その他

今回やったことの範囲外かもしれませんが、以下の記事も参考になりました。

 
最後になりましたが、長い間Herokuにお世話になりました。ありがとうございました。

 

ソースコード

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

今回のプルリクはこちらです。
https://github.com/thinkAmi/dj_ringo_tabetter/pull/20

*1:リダイレクトされるので、実際には https://ringo-tabetter.thinkami.dev/hc/total が表示されます

Google Cloud Secret Manager にあるシークレットの値を、ローカル環境のPythonスクリプトで取得してみた

Google Cloud で機密情報を保管するときには Secret Manager が使えます。
Secret Manager  |  Google Cloud

ためしに、Google Cloud Console で Secret Manager に値を保存した後、ローカル環境のPythonスクリプトで Secret Manager の値を取得しようとしてみました。

まず公式ドキュメントを読んだところ、Pythonスクリプトでの実装は「シークレットの作成と読み取り」を同時に行っていました。
Secret Manager client libraries  |  Secret Manager Documentation  |  Google Cloud

そこで、シークレットの作成を別途行う時の実装について色々調べたため、メモとして残します。

 
目次

 

環境

  • WSL2上のUbuntu 22.04.1 LTS
  • Python 3.10.7
  • google-cloud-secret-manager 2.12.6
    • Python用のSecret Managerクライアントライブラリ

 

Windows上での作業

Google Cloud の Secret Manager まわりの設定

今回は、Windows上のブラウザにて、 Google Cloud の Console を使って設定します。

 

Secret Manager でシークレットを作成

まずは、Secret Manager APIを有効化し、シークレットを作成します。

今回は test というシークレットに ham という値を設定します。

項目
名前 test
シークレットの値 ham
このシークレットのロケーションを手動で管理する チェックしない
顧客管理の暗号鍵(CMEK)を使用する チェックしない
ローテーション期間を設定する チェックしない
有効期限を設定する チェックしない

 

サービスアカウントの作成とアクセスキーの生成

Secret Manager からシークレットを取得するため、新たに専用のサービスアカウントを作成します。

また、サービスアカウントで Secret Manager の値を読み取れるようなロールを割り当てます。

公式ドキュメントを読むと、 Secret Manager のシークレット アクセサー (roles/secretmanager.secretAccessor) を割り当てれば良さそうです。
Secret Manager のロール | ロールについて  |  IAM のドキュメント  |  Google Cloud

 
それでは作成していきます。

IAMのサービスアカウントから、 サービスアカウントの作成 を選択します。

項目
サービスアカウント名 python-secret-manager-test
サービスアカウントID python-secret-manager-test
ロール Secret Manger のシークレットアクセサー

 
続いて、サービスアカウントのアクセスキーを生成します。

キー タブから 鍵を追加 > 新しい鍵を作成 を選択し、キーのタイプを JSON として作成し、Windows上に JSON ファイルをダウンロードします。

 

WSL2上のUbuntuでの作業

Python環境を構築

WSL2上の適当なディレクトリにて、Python環境を作成します。

# 仮想環境を有効化
$ python -m venv env
$ source env/bin/activate

# PythonのSecret Managerクライアントライブラリをインストール
(env) $ pip install google-cloud-secret-manager

 

Pythonスクリプトを作成

今回は、Secret Managerクライアントライブラリの3つのメソッドを使って、シークレットの値を取得します。

 
スクリプトの全体はこんな感じです。

先頭の4つの定数は、適宜環境に合わせます。

from google.cloud import secretmanager

PROJECT_ID = 'your_project_id'
SECRET_ID = 'test'  # Secret Manager で設定したシークレットID
VERSION = 'latest'
CREDENTIAL_FILE_NAME = 'your_json_file.json'

def main():
    # clientを生成
    client = secretmanager.SecretManagerServiceClient.from_service_account_json(CREDENTIAL_FILE_NAME)

    # path を取得
    path = client.secret_version_path(PROJECT_ID, SECRET_ID, VERSION)

    # Secret Manager からシークレットを取得
    response = client.access_secret_version(name=path)

    # シークレットをデコード
    value = response.payload.data.decode('UTF-8')

    # 表示
    print(value)

if __name__ == "__main__":
    main()

 

JSONファイルをWSL2上にコピー

Windowsでダウンロードしたサービスアカウント用のJSONファイルを、WSL2上にコピーします。

WSL2上のコピー先は、上記で作成したPythonスクリプトと同じディレクトリにします。

 

Pythonスクリプトの実行

実行すると、Secret Managerに保存したシークレットの値 ham が取得できました。

$ python run.py
ham

 

その他

Cloud SDKの公式ドキュメントの場所について

最近場所が変更されたようです。
Productivity unlocked with new Cloud SDK reference docs | Google Cloud Blog