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をアクセス不可にする場合は、以下が参考になるかもしれません。

 

不要なリソースの削除

(2023/1/4 追記)

Cloud Runへデプロイした場合、Cloud StorageやArtifact Registryに都度オブジェクトが生成されます。

ただ、そのまま放置しておいてもデータは削除されず課金されてしまいます。

そこで、過去のデプロイデータなど不要なものを削除します。

 

Cloud Storage

Cloud Storageには Cloud Runへデプロイする際の Cloud Build データが生成されます。

ただ、Artifact Registryへの生成が済んでしまえばCloud Buildデータは不要になるようです。
Cloud Functions for Firebaseの利用で、異様にGCPのStorageが消費されると思ったら..

そのため、Cloud Build用のバケット (***_cloudbuild) のオブジェクトを削除するようにします。

ただ、手動で削除するのは手間なので、簡単に設定できるCloud Storageのライフサイクルルールでオブジェクトを削除するようにします。

上記参考記事に従い、以下を設定します。

項目 内容
アクション オブジェクトの削除
オブジェクトの条件 オブジェクトが生成されてから1日以降

 

Artifact Registry

こちらもビルドされたデータが残り続けるため、定期的に削除します。

ただ、Cloud Storageとは異なり、簡単に設定できるものがないことから、公式ドキュメントでは gcr-cleaner が案内されています。
イメージを管理  |  Artifact Registry のドキュメント  |  Google Cloud

gcr-cleaner については、以下の記事で詳しく解説されていました。
Cloud Build+Cloud Runでできた不要なContainer Registryを自動削除する(gcr-cleaner) - くらげになりたい。

ただ、現時点ではアプリのデプロイ回数が少ないことから、まだ gcr-clerner の設定はせず、手動で削除するようにしています。

 

その他

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

 
最後になりましたが、長い間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

#pyconjp PyCon JP 2022に参加しました

10/14(金)・15(土)に、TOC有明コンベンションホールで開催された「PyCon JP 2022」に参加しました。
PyCon JP 2022

 
リアル参加は2019年以来でした。無事に参加できてよかったです。
#pyconjp PyCon JP 2019に参加しました & 発表しました - メモ的な思考的な

今年も動画はすでに公開されています。ありがとうございます。
PyCon JP 2022 - YouTube

ここでは参加したメモを残します。誤りがありましたらご指摘ください。

 
目次

 

参加したトーク

今年は両日ともお昼ごはんから参加しました。

トークの資料は PyCon JP 2022のタイムテーブル からリンクが貼られています。ただ、後で自分が見返しやすいよう、このブログでもリンクを貼っておきます。

 

1日目

Sphinxを通して考える、「拡張」の仕方

Sphinxを通して考える、「拡張」の仕方 / First approach for development sphinx extension - Speaker Deck
Kazuya Takei (attakei) 氏

 
Sphinxの拡張はどこまでできるのかが気になって参加しました。

トークでは、拡張できる箇所として

  • 入力
  • 出力
  • 内部

に加え、イベントも追加できるとの解説がありました。

かなり柔軟に拡張できるような印象を受けるとともに、Sphinxがその柔軟さをどのように実現しているのかが気になりました。そのため、どこかでソースコードを読んでみたくなりました。

 

イベント駆動アーキテクチャについて

Event-driven architecture - Speaker Deck
Masataka Arai 氏

 
「イベント駆動アーキテクチャ」についてきちんと理解したかったため参加しました。

トークでは、イベント駆動アーキテクチャとして

  • Callback
  • Subject
  • Topic

があるという基本的なところから、キューの監視やリトライなどの運用についても解説がありました。

また、今回のイベント駆動アーキテクチャの考え方は、Python以外の環境でも役立ちそうでした。そこで、再度発表資料を読んだり、参考資料にあった「エキスパートPythonプログラミング 改訂3版」を読もうと思いました。

 

Python3.11新機能asyncio.TaskGroup()と2022年asyncioの"Hello-ish world"

Python3.11新機能asyncio.TaskGroup()と2022年asyncioの"Hello-ish world" - Speaker Deck
Junya Fukuda 氏

 
asycio まわりは全然追っていないため、どんな機能が追加されるのか気になって参加しました。

トークは 3.10と3.11での書き方を比べながらの解説がとてもわかりやすかったです。 asyncio.TaskGroup は例外・キャンセルを扱う時の実装でお世話になりそうだなーと思いながら聞いていました。

 
あと、発表前の接続トラブルにも焦ることなく発表している姿が印象に残りました。すごい。

 

Fast API と学ぶ WebRTC

20221014_Fast API と学ぶ WebRTC - Google スライド
Takayuki Kawazoe 氏

 
WebRTC という単語は聞くものの、実際にどんなものなのかを知りたくて参加しました。

トークでは、「WebRTCは Web Real-Time Communication 用のAPI群」という説明から始まり、各技術要素の解説や、Fast APIを使ったデモまでありました。

また、Pythonで WebRTC を扱う場合は aiortc がほぼ一択と分かったこともありがたかったです。
aiortc/aiortc: WebRTC and ORTC implementation for Python using asyncio

 

AST(Abstract Syntax Tree)に入門する

PyCon JP 2022/ASTに入門する - Speaker Deck
安本雅啓 氏

 
以前 astモジュールを扱ったこともあり、AST について理解を深めようと思い参加しました。

トークでは

  • CSTとASTの違い
  • PythonのAST
  • astモジュール

のそれぞれについて解説があり、とてもありがたかったです。

また、

  • darglint
    • docstringと実装の間で違いがあるかを検知するlinter
  • LibCST
    • Instagramの開発チームが作った、CST + AST + αな構文木を構築するライブラリ

の紹介もあり、色々すごいものがあるんだなーと思いました。

 

LT

  • プレゼン用PCの電源が落ちるハプニングがあっても続けられるLT
  • 電車への情熱があふれるLT

など、リアルで開催されるカンファレンスならではのLTという感じがあって楽しかったです。

 

2日目

Security Best Practices for Django Applications

Gajendra Deshpande 氏

 
Djangoのセキュリティまわりが気になったため参加しました。

トークでは、セキュリティ面で気にする事項とDjangoではどう対応すればよいかの解説がありました。

セキュリティに関するパッケージなど、いろいろな情報がスライドに色々記載されていたため、あとからスライドを見直したいと思いました。

 

Python Social Authで学ぶ、OAuth2.0認可コードフローにおける異常系への対処

Python Social Authで学ぶ、OAuth2.0認可コードフローにおける異常系への対処 - Speaker Deck
Yuuki Takahashi 氏

 
OAuth2.0の異常系がPython Social Authでどのように実装されているのか気になったので参加しました。

トークでは

  • Python Social Authの異常系を多くの例外として表していること
  • 各例外がどの異常系なのか、それぞれ解説があったこと

があり、とてもわかりやすかったです。

また、異常系の中でも注意するものとそうでないものの解説もありました。実際に運用してみた上での解説だと思うので、もし異常系を扱うときには参考になりそうです。

 

続・絵を読む技術 Pythonで読むイラストの心理戦略

続・絵を読む技術 Pythonで読むイラストの心理戦略 / The Art of Reading Illustrations 2nd - Speaker Deck
ひろさじ / Hirosaji 氏

 
前職で絵師さんが近くにいたこともあり、絵師さんにはどんな心理戦略があるんだろうと気になったため参加しました。

トークではイラストにおける「なにを伝えるか」について、それぞれ論理的な解説がありました。これを行っている絵師さん、改めてすごい思ったのでした。

また、各戦略についてPythonでさくっと分析していたのも印象に残りました。

他に、資料の最後の方に記載されているreferenceも充実しており、イラストやデザインまわりで気になることがあれば参照しようと思いました。

 

情報システム部門の業務におけるPythonの活用

情報システム部門の業務におけるPythonの活用 - Keito FUKUSHIMA - PyCon JP 2022 - Google スライド
Keito FUKUSHIMA 氏

 
情報システム部門ではどのようにPythonを活用しているのか気になったため参加しました。

スライドの冒頭でLANケーブル敷設の写真があり、「昔やったなー」と懐かしくなりました。

トークでは、社内でお使いの

について、Pythonスクリプトを書いて色々効率化している姿が分かりました。

また、質問コーナーで「GASでなくPythonを使っている理由は?」という質問に対し、「Pythonだと社内の有識者からレビューを受けられる」との回答がありました。たしかに社内でPython使いが多いのであれば、情報システム部門で使うものもPythonで書いた方が保守しやすくなるので、良いなーと思いました。

 

会場で見たネットワーク機器

一日目で会場を散策していたところ、ネットワーク機器が置いてあるとともに、ネットワーク図も公開されていました。

「ほー」とながめていたところ、スタッフの方から解説いただけました。ラズパイに乗ったRHEL 9.0でトラフィックを監視しているようでした。すごい。

 
二日目もネットワーク機器の元へ行ってみたところ、トラフィック監視の結果が分かりやすくグラフ化されていました。あっという間に改良されていてすごいと思いました。

 
カンファレンスのネットワークを支えていただき、ありがとうございました。

 

全体の感想

長い期間お会いできていなかった方々と直接お話できました。近況報告などお互い元気にやっていることがわかったのでよかったです。

また、レビューへ参加させていただいた書籍「Python実践レシピ」の著者のみなさまと直接お会いしたりお話したり、サインをもらったりしてました。こちらも嬉しかったです。

他にもご紹介いただいた方々と直接お話できるなど、Pythonが好きな人たちと交流を深められるので、リアル開催は本当にありがたいと感じました。

 
最後になりましたが、PyCon JP 2022をリアル開催として運営してくださったみなさま、参加者のみなさま、ありがとうございました。