RubyのStringScannerを使って、文字列をスキャンしてみた

最近、Rubyで文字列の字句解析を行う機会がありました。

何を使えばよいか考えたときに、最初に思い浮かんだのが Regexp#match でした。
class String (Ruby 3.3 リファレンスマニュアル)

 
他にもないか調べたところ、文字列スキャナクラス StringScanner がありました。
class StringScanner (Ruby 3.3 リファレンスマニュアル)

StringScannerは、Regexpと同じように正規表現を使うのに加え、

  • スキャンし終わった位置のインデックスであるスキャンポインタを持つ
  • bol? ã‚„ eos? でスキャンポインタが先頭・末尾であるかを示せる
  • rest でスキャンしていない残りの文字列を取得できる

などの機能もあるようでした。

また、以下のスライドのp13を読むことで、StringScannerの動作イメージがわきました。
Improved REXML XML parsing performance using StringScanner - Speaker Deck

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

 
目次

 

環境

 

StringScannerの各メソッドを使ってみる

StringScannerには色々なメソッドが用意されており、どんな挙動になるのかは前述のリファレンスマニュアルに詳しく書かれています。

 
そこで、まずは Hello, world! という文字列を対象にMinitestによるテストコードを書いて動作を確認していきます。

 
最初に StringScanner のインスタンスを生成します。

class StringScannerTest < Minitest::Test
  def test_hello_world
    text = 'Hello, world!'
    scanner = StringScanner.new(text)

 
続いて、 bol? メソッドを使い、スキャンポインタが先頭にあることを確認します。また、 eos? メソッドにてスキャンポインタが末尾にないことも確認します。

assert_equal true, scanner.bol?
assert_equal false, scanner.eos?

 

次に scan メソッドを使い、Hello という文字列にマッチすることを確認します。なお、 ここではPOSIX文字クラス [[:word:]] を使っています。
正規表現 (Ruby 3.3 リファレンスマニュアル)

assert_equal 'Hello', scanner.scan(/[[:word:]]+/)

 
残りの文字を rest メソッドで確認します。

assert_equal ', world!', scanner.rest

 
続く文字は , であるかを match? メソッドを使って確認します。

assert_equal true, scanner.match?(/[[:word:]+]/).nil?
assert_equal 1, scanner.match?(/,/)

 
次に skip_until で、空白が出てくるところまでスキャンポインタを移動します。

assert_equal 2, scanner.skip_until(/[[:space:]]/)

 
空白のところへスキャンポインタが移動しているので、残りは world! だけになります。

assert_equal 'world!', scanner.rest

 
残りについては skip メソッドでスキャンポインタを移動します。

assert_equal 6, scanner.skip(/[[:word:]]+!/)
assert_equal '', scanner.rest

 
これで文字列の末尾になりました。

assert_equal true, scanner.eos?

 

自分のりんごの記録を解析してみる

最近はBlueskyにて食べたりんごを記録しています。
https://bsky.app/profile/thinkami.bsky.social

そこで、過去の投稿を解析してみました。テストはパスしたので良さそうです。

text = '[リンゴ] 今日は `シナノドルチェ` を食べた。パリッとした食感で、果汁があふれ出た。酸味と甘味がしっかりとあっておいしかった。'
scanner = StringScanner.new(text)

# スキャンポインタを進めつつ、 "[リンゴ]" 始まりかを確認
unless scanner.skip(/\[リンゴ\]/)
  raise 'リンゴの投稿ではない'
end

# リンゴ名の前まで移動
unless scanner.skip(/[[:space:]]*[[:word:]]+[[:space:]]`/)
  raise 'リンゴ名がなさそう'
end

# リンゴ名を取得
unless scanner.match?(/[[:word:]]+`/)
  raise 'やっぱりリンゴ名がなさそう'
end
apple_name = scanner.scan(/[[:word:]]+/)

# 感想文の前まで移動
unless scanner.skip(/`[[:space:]]*[[:word:]]+[[:space:]]*。/)
  raise 'リンゴ名の一文が終わってなさそう'
end

# 感想を取得
impression = scanner.scan(/[[[:word:]]|[[:punct:]]]+/)
assert_equal true, scanner.eos?

assert_equal 'シナノドルチェ', apple_name
assert_equal 'パリッとした食感で、果汁があふれ出た。酸味と甘味がしっかりとあっておいしかった。', impression

 

ソースコード

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

Cloudflare Pages + TanStack Router + TanStack Query + CSS Grid Layout で、りんごの系譜図を作ってみた

以前、食べたりんごをグラフ化するアプリを作りました。
Cloudflare Pages・Workers + Hono + React + Chart.js で食べたリンゴの割合をグラフ化してみた - メモ的な思考的な

これで「今までどれくらい食べてきたのか」を知ることができ、とても便利です。

 
そんな中、いろいろなりんごを食べていくうちに、自分が好きなりんごの傾向がわかりました。どうやら、 千秋 を祖先に持っているりんごが好きなようです。

 
ただ、最近のりんごは世代を重ねているため、りんごの掛け合わせを見ても千秋が出てきません。

また、りんごの掛け合わせをすべて覚えているわけではないため、「千秋が祖先にいるりんご」というのがパッと出てきません。

これでは不便なのでどうすればよいか考えたところ、競馬の系譜図(血統表)みたいなものがあると良さそうと感じました。
例:Golden Apples | 優駿達の蹄跡

 
Webで「りんごの系譜図」を軽く探してみましたが、見当たりませんでした。

そこで、「ないなら作ろう」の精神で作ってみたことから、その時のメモを残します。

 
目次

 

作ったもの

記事が長くなったので、まずは作ったものを置いておきます。

 

デプロイ先

以前作ったアプリに系譜図機能を追加しました。そのため、以下のページから遷移することで系譜図が確認できます。
https://ringosky.thinkami.dev/genealogies

 
なお、このアプリは無料プランの Cloudflare Pages を使っています。

このアプリはそんなに流入が多くないと思うので落ちたりしないと思います。ただ、無料プランの上限を突破してしまった場合は動作しなくなります。

 

スクリーンショット

後述の通り、今回は「デザインをがんばらない」方針としたため、さっぱりとした画面になっています。

 

一覧画面

まずはこの画面から、自分が系譜図を知りたいりんごを探します。

系譜図を見たいりんごの行クリックすると、次の画面へと遷移します。

 

りんごの系譜図画面

以下は、シナノゴールドの系譜図です。URL的にはこちら。
https://ringosky.thinkami.dev/genealogies/shinano_gold

 
ちなみに、系譜図中のりんご名をクリックすると、そのりんごの系譜図へと遷移します。

上記例であれば、 フジ をクリックすると、 フジの系譜図が表示されます。

 

メニュー

左上のハンバーガーボタンをクリックすることで、メニューが表示されます。

 

環境

  • ローカルの開発環境
  • デプロイ先
    • Cloudflare
      • Pages
      • Workers
      • D1
      • KV
  • フロントエンド
    • React 18.3.1
    • Chart.js 4.4.2
    • react-chartjs-2 5.2.0
    • TanStack Router 1.38.1
    • TanStack Query 5.45.1
  • バックエンド
    • Hono 4.4.12
    • Drizzle ORM 0.31.2
    • Drizzle Kit 0.22.7
    • @atproto/api 0.12.23
      • Bluesky API用ライブラリ
  • 開発向けツール
    • Wrangler 3.83.3
    • Biome 1.8.1
      • Linter & Formatter

 

(現時点では) やらないこと

今回、「完成させること」をゴールにします。

そこで、現時点ではやらないことを明確にしておきました。

 

デザインはがんばらない

デザイン方面が得意ではないので、デザインを考え出すといつまでたっても完成しなさそうと考えました。

そこで、まずは動くものができればよいと考え、デザインはあとから適用することにしました。

ただ、見栄えをちょっとは良くしたい & 便利なコンポーネントを使いたいと考えました。そこで、MUIを使って最低限の見栄えとしました。
MUI: The React component library you always wanted

 

スマホには正式対応しない

デザインを適用するのと同時期に行えばよいと考え、今回スマホには正式な対応を行わないことにしました。

 

すべての品種に対して系譜図を作成しない

以下のサイトによると、日本にあるりんごの品種は2,000種類にもなるようです。
りんごの品種 | 青森りんご公式サイト(一社)青森県りんご対策協議会

最初から2,000種類の系譜図を作ろうとしても、データを用意するだけで時間がかかってしまいます。

そこで今回は小さく始めることにしました。具体的には、前回の記事で使った品種のみの系譜図を作ることにしました。
親子情報を持つテーブルとSQLの共通テーブル式(CTE)を使って、曽祖父母までの祖先を取得する - メモ的な思考的な

 

実装上のメモ

作りたいものは決まっているため、あとは実装すればよいだけです。

ただ、実装する上で考えたり調べたりしたことがあったため、メモとして残しておきます。

 

系譜図をCSS Grid Layout で作ることにした経緯

上記の「優駿達の蹄跡」サイトでは、HTMLの table タグを使って血統表を表現していました。

今回のアプリでもtableタグを使えば容易に実現できそうですが、将来のスマホ対応を考えると table タグは採用しづらいです。

 
次に、今回使うライブラリ MUI に含まれる Grid v2 component を使うことを考えました。
React Grid component - Material UI

MUIを使うのだからそのコンポーネントを使っても良さそうでしたが、MUIへの依存が強くなってしまうのが気になりました。

 
続いて、CSSを使って実現する方法を調べてみました。

すると、

あたりが使えそうでした。

引き続きMDNの以下の記事を読んだところ、系譜図は CSS Grid Layoutで作れば実現が容易かもと感じました。
グリッドテンプレート領域 - CSS: カスケーディングスタイルシート | MDN

 
ということで、CSS Grid Layoutの概念がまとまっている本を探したところ、「作って学ぶ HTML + CSS グリッドレイアウト」がありました。
作って学ぶ HTML + CSS グリッドレイアウト | エビスコム - EBISUCOM

この本の著者の記事を読んだところ、本にはCSS Grid Layoutが生まれた経緯から実際の使い方まで記述されているとわかりました。ほしかった本はこれでした。
CSS Grid を中心に据えたレイアウトの制御 ― Webの構築・実装におけるレイアウト手法を再確認&再検討してみた話 | エビスコム - EBISUCOM

実際に本を読んでみたところ、やはりCSS Grid Layoutを使うことで系譜図が実現できそうでした。

 
そこで、今回のアプリでは CSS Grid Layoutをメインに据え、

  • 系譜図は、CSS Grid Layoutの テンプレート で実装
  • メニューなどの配置は、CSS Grid Layoutの トラック で実装

としました。

 

CSS Grid Layoutまわり

ReactでCSS Grid Layoutのgrid-template-areasを指定する方法

Reactで CSS Grid Layoutのテンプレートを定義する方法を調べたところ、バッククォートを使って定義すれば良さそうでした。
css - How to use grid-template-areas in react inline style? - Stack Overflow

今回の場合、 div の style propsに

<div
  style={{
    display: 'grid',
    gridTemplateAreas: `
    "self p pp ppp"
    "self p pp pps"
    "self p ps psp"
    "self p ps pss"
    "self s sp spp"
    "self s sp sps"
    "self s ss ssp"
    "self s ss sss"
    `
  }}
>

のような感じで指定し、使う側では gridArea キーにそれぞれの値 (self や p など) を指定すれば実現できました。

 

ちなみに、CSS Grid Layoutの トラック をReactで実装する場合には、キーに対する値をそのまま設定すれば実現できました。

<header
  style={{
    display: 'grid',
    gridTemplateColumns: 'auto 1fr',
  }}
>

 

Cloudflare D1 (SQLite) まわり

INSERT OR REPLACE による upsert をする

前回の記事では、曽祖父母までの祖先を取得しました。
親子情報を持つテーブルとSQLの共通テーブル式(CTE)を使って、曽祖父母までの祖先を取得する - メモ的な思考的な

今回も、上記記事と同様の方法でデータを取得してりんごの系譜図を作成することから、

  • りんご情報を持つテーブル (apples)
  • りんごの親子情報を持つテーブル (genealogies)

の2テーブルを用意することにします。

 
ただ、前回の記事では SQLite + Drizzle ORM を使った環境でしたが、今回は Cloudflare D1 + Drizzle ORM な環境となります。

そのため、初期データをCloudflare D1へ投入する場合は、SQLファイルを書いてWranglerから実行する必要があります。
Build a Staff Directory Application | Cloudflare D1 docs

今後テーブル構造が変わる可能性があることから、できれば初期データはあとから更新しやすいよう upsert 的なSQLで用意したくなりました。

 
D1(SQLite)で upsert 的なことを行うには

の2つがありました。

今回は

  • 初期データはいわゆるマスタ系であり、投入後の更新は行わない
  • INSERT ... ON CONFLICT の場合、更新対象の列を指定する必要があるため、列が増えたときにSQLのメンテナンスが手間そう

と考え、 INSERT OR REPLACE を採用することにしました。

 
実際のSQLはこんな感じになりました。

INSERT OR REPLACE INTO apples (name, display_name) VALUES ('shinano_gold', 'シナノゴールド');
INSERT OR REPLACE INTO genealogies (child_name, pollen_name, seed_name) VALUES ('shinano_gold', 'senshu', 'golden_delicious');

 

Honoまわり

Honoで Path Parameter を指定する

Hono製のAPIで

  • /api/genealogies/shinano_gold
  • /api/genealogies/fuji

のように、 /api/genealogies/<動的な値> を指定したい場合、公式ドキュメントによると :apple_name のような記法を使えば良さそうでした。
Path Parameter | Routing - Hono

 
また、Path parameterの値を取り出すには、 c.req.param('apple_name') とすれば良さそうです。

 

TanStack Routerまわり

File-Based Routing時のディレクトリ構成について

今回の機能では、TanStack Router を使ってルーティングを行います。

TanStack Routerではルーティング方法を選べますが、今回はファイル置けばいい感じにルーティングしてくれる File-Based Routing を採用します。
File-Based Routing | TanStack Router React Docs

また、File-Based Routingでは、ファイルの置き方についてもいくつかパターンがありますが、今回はディレクトリによりファイルが整理される Directory Routes を採用します。

 
Directory Routesの場合、React向けのComponentやHookはどこに置くか悩みました。

そこで今回は以下の記事のように、 - をprefixとして付けた

  • -components
  • -api

のようなディレクトリを作り、その中にComponentやHookを置くことにしました。
TanStack Routerのディレクトリ設計 | TanStack Router(& Query)はSPA開発で求めていたものだった✨【Reactのルーティングとデータ取得】

 

automatic code-splittingを有効にする

TanStack Routerでは、

Code splitting and lazy loading is a powerful technique for improving the bundle size and load performance of an application.

という、 Code Splitting 機能があります。

 
Code Splitting を使えるようにする方法として、公式ドキュメントには

  • Using the .lazy.tsx suffix
  • Using Virtual Routes
  • Using automatic code-splitting

の3つの方法が記載されています。

 
今回のアプリはメンテナンスの手間を減らしたいことから、 automatic code-splitting を採用します。

なお、 automatic code-splitting の制約として

はあるものの、今回のアプリでは問題なさそうです。

 
 
そこで、

を行いました。

 

TanStack RouterとTanStack Queryを併用する方法について

TanStack Query の useSuspenseQuery を使ってデータを取得する

今回の機能ではデータ更新系が存在しません。

そんな状況で TanStack Router と TanStack Query を併用する方法を調べたところ、公式ドキュメントにサンプルがありました。
React TanStack Router Basic React Query Example | TanStack Router Docs

そこでは、 TanStack Query の useSuspenseQuery を使ってデータを取得していました。
useSuspenseQuery | TanStack Query React Docs

また、 useSuspenseQuery の引数 options には、最低限

  • queryKey
  • queryFn

の2つを持つオブジェクトを指定してあげれば良いようでした。

 
ちなみに、 useSuspenseQuery を使う上ではリクエストウォーターフォールに注意したほうが良いようです。

 

では、今回のアプリでの実装を見ていきます。

Hono製APIで Path Parameter を使っている場合、以下のような options を用意します。

const client = hc<GenealogyRouteResponseType>('')

const queryFn = async (appleName: string) => {
  const response = await client.api.genealogies[':apple_name'].$get({
    param: {
      apple_name: appleName,
    },
  })

  if (response.ok) {
    return await response.json()
  }
}

export const genealogyQueryOptions = (appleName: string) =>
  queryOptions({
    queryKey: ['fetchGenealogy', { appleName }],
    queryFn: () => queryFn(appleName),
  })

 
そして、コンポーネントの冒頭で、TanStack Router の Route.useParams() を使って Path Parameter の appleName を options に渡すことで、Hono製APIを呼ぶことができます。
useParams hook | TanStack Router React Docs

const GenealogyChartComponent = () => {
  const { appleName } = Route.useParams()
  const query = useSuspenseQuery(genealogyQueryOptions(appleName))
  // ç•¥
}

 

TanStack Query を TanStack Router の loader に指定する方法について

TanStack Router では loader を使うことで、ページのロードに合わせてデータ取得を行うことができます。
Data Loading | TanStack Router React Docs

今回の場合、データ取得については TanStack Query を使っていることから、両者を組み合わせて使うことができるか調べてみました。

すると、TanStack Router の公式ドキュメントに記載やサンプルがありました。

 

今回のアプリでの実装を見ていきます。

まずは、 crateRouter の context に TanStack Query の queryClient を渡します。

const queryClient = new QueryClient()
const router = createRouter({
  routeTree: routeTree,
  context: {
    queryClient,
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>
  </QueryClientProvider>,
)

 
続いて、 __root.tsx で createRootRouteWithContext を使い、 queryClient の型として QueryClient を渡します。

import type { QueryClient } from '@tanstack/react-query'
// ç•¥

export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
  {
    component: () => (
      <>
        <Outlet />
      </>
    ),
  },
)

 
最後に、 createFileRoute の loader で context を取得し、 TanStack Query の機能を使ってデータを取得します。

export const Route = createFileRoute('/genealogies/$appleName')({
  component: Component,
  loader: async ({ context: { queryClient }, params: { appleName } }) =>
    await queryClient.ensureQueryData(genealogyQueryOptions(appleName)),
})

 

今回のアプリを作っているときに、「MUIのコンポーネントに対して、TanStack Router のリンクを付与したい」ことがありました。

調べてみたところ、 TanStack Router の 1.28.1 から createLink 関数を使うことで、カスタムリンクを設定することができるようになったようです。
tanstack router - How to wrap a Link component with type safety? - Stack Overflow

また、公式ドキュメントには createLink + MUIコンポーネントを使ったサンプルコードもありました。
MUI example | Custom Link | TanStack Router React Docs

 

今回のアプリでの実装を見ていきます。

なお、今回は MUI の List コンポーネント関連を使って実装しています。
React List component - Material UI

最初に、createLink を使って、MUIのコンポーネント ListItem + ListItemButton をラップしたコンポーネントを用意します。

type MuiLinkProps = Omit<ButtonProps, 'href'>

const MuiLinkComponent = React.forwardRef<HTMLAnchorElement, MuiLinkProps>(
  (props, ref) => {
    return (
      <ListItem>
        <ListItemButton component={'a'} ref={ref} {...props} />
      </ListItem>
    )
  },
)

const CreatedLinkComponent = createLink(MuiLinkComponent)

export const RingoMenuItem: LinkComponent<typeof MuiLinkComponent> = (
  props,
) => {
  return <CreatedLinkComponent preload={'intent'} {...props} />
}

 
あとは、上記のコンポーネント RingoMenuItem を使い、 to props に遷移先を指定すればOKでした。

<List>
  <RingoMenuItem to={'/'}>合計数量</RingoMenuItem>
  <RingoMenuItem to={'/month'}>月別数量</RingoMenuItem>
  <RingoMenuItem to={'/genealogies'}>系譜図</RingoMenuItem>
</List>

 

MUIまわり

ハンバーガーアイコンをクリックしたらメニューを表示する

MUIの IconButton と Drawer を使って実現できました。

 
今回のアプリでの実装を見ていきます。

まずは Drawer でメニューを作ります。

type Props = {
  open: boolean
  handleClose: () => void
}

export const RingoMenu = ({ open, handleClose }: Props) => {
  return (
    <Drawer open={open} onClose={handleClose}>
      <Box>
        <List>
          <RingoMenuItem to={'/'}>合計数量</RingoMenuItem>
          <RingoMenuItem to={'/month'}>月別数量</RingoMenuItem>
          <RingoMenuItem to={'/genealogies'}>系譜図</RingoMenuItem>
        </List>
      </Box>
    </Drawer>
  )
}

 
あとは IconButton で state 管理して表示/非表示を切り替えます。

export const TitleWithMenu = ({ title }: Props) => {
  const [open, setOpen] = useState(false)
  const handleClose = () => setOpen(false)

  return (
    <>
      <IconButton onClick={() => setOpen(true)}>
        <MenuIcon />
      </IconButton>
      <RingoMenu open={open} handleClose={handleClose} />
    </>
  )
}

 

実際のデプロイ作業

ここではデプロイ作業の記録を残しておきます。

 

ringo-db ディレクトリでの作業

Drizzle-Kit によるマイグレーションファイル生成

Drizzle-Kit にてマイグレーションファイルを生成しておくことで、 Wrangler で適用できます。

$ bun drizzle-kit generate

 

ローカルのD1にマイグレーションを適用

Wrangler の --local フラグを利用して、ローカルのD1にマイグレーションを適用します。

$ wrangler d1 migrations apply ringodb --local

 

ローカルのD1に初期データを投入

こちらも --local フラグを利用してデータを投入します。

$ wrangler d1 execute ringodb --local --file=seed/apples_and_genealogies.sql --batch-size=1

 

本番のD1にマイグレーションを適用

続いて、 --remote フラグを使って、本番のD1にマイグレーションを適用します。

途中で適用の確認がされるので、 y を入力します。

$ wrangler d1 migrations apply ringodb --remote

Migrations to be applied:
┌──────────────────────────────┐
│ name                         │
├──────────────────────────────┤
│ 0001_youthful_beyonder.sql   │
├──────────────────────────────┤
│ 0002_fearless_gunslinger.sql │
└──────────────────────────────┘
✔ About to apply 2 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 3 commands in 0.9372999999999999ms
┌──────────────────────────────┬────────┐
│ name                         │ status │
├──────────────────────────────┼────────┤
│ 0001_youthful_beyonder.sql   │ ✅       │
├──────────────────────────────┼────────┤
│ 0002_fearless_gunslinger.sql │ 🕒️     │
└──────────────────────────────┴────────┘
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 2 commands in 0.5869ms
┌──────────────────────────────┬────────┐
│ name                         │ status │
├──────────────────────────────┼────────┤
│ 0001_youthful_beyonder.sql   │ ✅       │
├──────────────────────────────┼────────┤
│ 0002_fearless_gunslinger.sql │ ✅       │
└──────────────────────────────┴────────┘

 

本番のD1に初期データを投入

引き続き、本番のD1に初期データを投入しようとしたところ、エラーになりました。

$ wrangler d1 execute ringodb --remote --file=seed/apples_and_genealogies.sql --batch-size=1

✔ ⚠️ This process may take some time, during which your D1 database will be unavailable to serve queries.
  Ok to proceed? … yes
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
Note: if the execution fails to complete, your DB will return to its original state and you can safely retry.
🌀 File already uploaded. Processing.

✘ [ERROR] Cannot read properties of undefined (reading 'forEach')

If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose
Note that there is a newer version of Wrangler available (3.83.0). Consider checking whether upgrading resolves this error.
✔ Would you like to report this error to Cloudflare? … no
🪵  Logs were written to "***.log"

 
原因が分からないものの、エラーメッセージにある通り wrangler のバージョンがやや古いのは事実でした。

そこで、Bunにより wrangler のバージョンを上げてみます。
https://bun.sh/docs/cli/update

$ bun update wrangler
bun update v1.1.13 (bd6a6051)

installed [email protected] with binaries:
 - wrangler
 - wrangler2

11 packages installed [5.71s]

 
再度、本番のD1にデータを投入したところ、処理が成功しました。

$ wrangler d1 execute ringodb --remote --file=seed/apples_and_genealogies.sql --batch-size=1

 ⛅️ wrangler 3.83.0
-------------------

✔ ⚠️ This process may take some time, during which your D1 database will be unavailable to serve queries.
  Ok to proceed? … yes
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
Note: if the execution fails to complete, your DB will return to its original state and you can safely retry.
├ 🌀 Uploading ***.sql 
│ 🌀 Uploading complete.
│ 
🌀 Starting import...
🌀 Processed 34 queries.
🚣 Executed 34 queries in 0.00 seconds (0 rows read, 85 rows written)
   Database is currently at bookmark ***.
┌────────────────────────┬───────────┬──────────────┬────────────────────┐
│ Total queries executed │ Rows read │ Rows written │ Database size (MB) │
├────────────────────────┼───────────┼──────────────┼────────────────────┤
│ 34                     │ 0         │ 85           │ 0.22               │
└────────────────────────┴───────────┴──────────────┴────────────────────┘

 

ringo-db Worker のデプロイ

この記事ではふれていませんが、D1にテーブルを追加したのに合わせて、データを取得する処理を ringo-db Workerへ追加していました。

そこで、Workerアプリをデプロイします。

$ bun run deploy
$ wrangler deploy --minify

 ⛅️ wrangler 3.83.0
-------------------

Total Upload: 75.08 KiB / gzip: 21.05 KiB
Worker Startup Time: 4 ms
Your worker has access to the following bindings:
- D1 Databases:
  - DB: ringodb (***)
Uploaded ringo-db (2.87 sec)
Deployed ringo-db triggers (0.27 sec)
  https://ringo-db.dev-thinkami.workers.dev
Current Version ID: ***

 

ringo-web ディレクトリでの作業

ringo-web ディレクトリは Cloudflare Pages としてデプロイされています。

Cloudflare Workersと異なり、Cloudflare Pagesでは、mainブランチ以外での wrangler deploy は Preview としてデプロイされます。

 
そこで、まずは Preview で動作確認するためのデプロイを行います。

 

最初にアプリを作ったときと同様ビルド時に画面がハングするため、そのことを織り込んで作業します。

まずはフロントエンドからビルドします。 built in と表示されたらキャンセルします。

$ bun run build:fe

...
✓ built in 4.74s

 

続いて、バックエンドもビルドします。 built in と表示されたらキャンセルします。

$ bun run build:be
$ vite build
vite v5.3.1 building SSR bundle for production...
✓ 21 modules transformed.
dist/_worker.js  23.21 kB
✓ built in 205ms

 

最後にデプロイします。

$ bun run deploy
$ wrangler pages deploy dist
â–² [WARNING] Warning: Your working directory is a git repo and has uncommitted changes

  To silence this warning, pass in --commit-dirty=true


✨ Success! Uploaded 1 files (1 already uploaded) (2.56 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at ***
✨ Deployment alias URL: ***

 
動作確認したところ良さそうだったので、GitHub上でプルリク作成 & mainブランチへとマージします。

その後、 mainブランチに切り替え、再度、デプロイ作業として

  • bun run build:fe
  • bun run build:be
  • bun run deploy

を行います。

あらためて動作確認したところ、問題なく動いていました。

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi/cf_ringo_sky

今回のプルリクはこちら

親子情報を持つテーブルとSQLの共通テーブル式(CTE)を使って、曽祖父母までの祖先を取得する

以前、SQLのCTEを使って階層構造を持つデータから、祖先のデータを取得してみました。
django-cteと共通テーブル式(CTE)を用いた再帰クエリにより、階層構造を持つテーブルからデータを取得する - メモ的な思考的な

このときは django-cte の利用がメインだったため、祖先は1つ (りんごの種子親) だけを扱いました。

 

ただ、現実のりんごの祖先は単一ではなく、

  • 花粉親 (父)
  • 種子親 (母)

がそれぞれ存在します。また、当然ながら、それ以前の代のりんごも存在します。

例えば、シナノリップの場合、祖先をたどると以下のような感じになります。

  • シナノリップ
    • (父) シナノレッド
      • (父) ビスタベラ
        • (父) 不明
        • (母) 不明
      • (母) つがる
        • (父) 紅玉
        • (母) ゴールデンデリシャス
    • (母) 千秋
      • (父) ふじ
        • (父) デリシャス
        • (母) 国光
      • (母) 東光
        • (父) 印度
        • (母) ゴールデンデリシャス

 

そこで今回、「1つのりんごを指定したときに、曽祖父母までのりんご名をすべて取得して横持ちにする」を実装してみます。

例えば、シナノリップを指定した場合は、以下のような形で取得します。

りんご 父 父父 父父父 父父母 ... 母母母
シナノリップ シナノレッド ビスタベラ 不明 不明 ... ゴールデンデリシャス

 
目次

 

環境

まず、データの保管先とその取得方法ですが、今回は

  • 曽祖父母までの親子情報を持つRDB・テーブルを用意
    • RDBは手軽なSQLiteを使う
  • そのテーブルからSQLでデータを取得する

とします。

なお、テーブルの主キーに意味を持たせたほうがわかりやすそうと感じたため、テーブルの主キーは自然キーとします。

 
次に、動作確認を容易にしたいため、何かしらのORMを用意します。  
そこで、自然キーを主キーにできるORMを調べてみたところ、 drizzle-orm が自然キーを主キーにできそうでした。
Drizzle ORM - Indexes & Constraints

そこで、ORMは drizzle-orm を使うことにします。

 
ところで、drizzle-ormを使うときには何かしらのdriverが必要になります。

ただ、実行環境としてBunを選択すると、driverを追加することなくSQLiteを扱えます。
https://orm.drizzle.team/docs/get-started-sqlite#bun-sqlite

そこで、今回は実行環境としてBunを選択します。

 
以上を踏まえた、今回の環境は次の通りです。

  • Windows11 WSL2
  • SQLite
  • drizzle-orm 0.33.0
  • drizzle-kit 0.24.2
  • Bun 1.1.27

 

drizzle-ormまわりのセットアップ

SQLを試す前に、必要な環境をセットアップします。

# Bunのバージョンを確認
$ bun -v
1.1.27


# Bunプロジェクトを作成
$ bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (ringo_genealogies_with_drizzle): ringo_genealogies_with_drizzle
entry point (index.ts):

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts


# drizzle-ormをインストール
$ bun add drizzle-orm
bun add v1.1.27 (267afa29)

installed [email protected]

1 package installed [342.00ms]


# マイグレーションコマンドが使えるようになる、drizzle-kitもインストール
$ bun add -D drizzle-kit
bun add v1.1.27 (267afa29)

installed [email protected] with binaries:
 - drizzle-kit

16 packages installed [1.88s]

 
次に、drizzle-orm のSQLite向け設定ファイルを drizzle.config.ts として作成します。

import { defineConfig } from "drizzle-kit"

export default defineConfig({
  dialect: "sqlite",
  schema: "./src/schema/*",
  out: "./drizzle",
})

 
続いて、Bun用の設定ファイルを作成します。というのも、drizzle-orm の migrate コマンドについてはBunでそのまま使えないためです。

今回はBunの公式ドキュメントに従い、Bun向けのマイグレーションコマンドファイルを作成します。
https://bun.sh/guides/ecosystem/drizzle

これでBunでもマイグレーションが実行できるようになります。

import { migrate } from "drizzle-orm/bun-sqlite/migrator";

import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";

const sqlite = new Database("sqlite.db");
const db = drizzle(sqlite);
await migrate(db, { migrationsFolder: "./drizzle" });

 

テーブル設計

今回は

  • りんご情報を持つ apples テーブル
  • 親子関係を階層構造として持つ genealogies テーブル

の2つを用意します。

 
続いて、 genealogies の構造ですが、父母情報を縦持ち・横持ちのどちらにするかを考えます。

縦持ちの場合はこんな感じです。relationに 父 か 母 的な値を入れ、1つの子りんごに対し、2レコードで父母情報を管理します。

sqliteTable("genealogies", {
  parent_name: text('parent_name').notNull().references(() => apples.name),
  child_name: text('child_name').notNull().references(() => apples.name),
  relation: text('relation').notNull()
}, (table) => {
  return {
    pk: primaryKey({ columns: [table.parent_name, table.child_name, table.relation] })
  }

 
一方、横持ちはこんな感じで、1レコードで父母情報を管理します。

sqliteTable("genealogies", {
  child_name: text('child_name').notNull().references(() => apples.name),
  pollen_name: text('pollen_name').notNull().references(() => apples.name),
  seed_name: text('seed_name').notNull().references(() => apples.name),
}, (table) => {
  return {
    pk: primaryKey({ columns: [table.child_name, table.pollen_name, table.seed_name] })
  }
})

 
では、どうするか考えます。

初期データを用意する際に、前者だとデータ量が多くなる & うっかり親子関係を間違えやすそうと感じました。

また、一度りんごの親子関係を作ってしまえば、基本的に親子関係は不変と考えました*1。

そこで、今回は後者の「1レコードで父母情報を管理」とするテーブル設計としました。

 
ちなみに、これは以下の記事にある 閉包テーブル のような感じになりました。
MySQLで階層構造を扱うための再帰的なクエリの実装方法と実用例

 

drizzle-ormでのschema定義

テーブル設計ができたので、次はdrizzle-ormのschemaとして実装します。

まずは apples テーブルを表す apples.ts をディレクトリ scr/schema の中に用意します。

import {sqliteTable, text} from "drizzle-orm/sqlite-core";

export const apples = sqliteTable("apples", {
  name: text('name').primaryKey(),
  display_name: text('display_name').unique().notNull(),
  color: text('color').notNull()
})

 

続いて、親子関係を持つ genealogies テーブルを表す genealogies.ts も用意します。

import {primaryKey, sqliteTable, text} from "drizzle-orm/sqlite-core";
import {apples} from "./apples.ts";

export const genealogies = sqliteTable("genealogies", {
  child_name: text('child_name').notNull().references(() => apples.name),
  pollen_name: text('pollen_name').notNull().references(() => apples.name),
  seed_name: text('seed_name').notNull().references(() => apples.name),
}, (table) => {
  return {
    pk: primaryKey({ columns: [table.child_name, table.pollen_name, table.seed_name] })
  }
})

 

マイグレーションファイルの生成と適用

schemaファイルができたので、マイグレーションファイルを生成します。

$ bun run generate

 
続いて、マイグレーションファイルを適用します。Bunの場合は先ほど作成した migrate.ts を使って適用します。

$ bun run migrate.ts

 

sqlite.db が生成されました。中を見てみるとテーブルができていました。

 

seedファイルの生成と実行

毎回データ投入を行うのは手間なので、seedファイル( seed.ts )を用意します。

内容は以下ですが、記事の都合上一部を省略しています。全体像は以下のファイルを参照してください。
https://github.com/thinkAmi-sandbox/ringo_genealogies_with_drizzle/blob/main/seed.ts

import { db } from "./db";
import {apples} from "./src/schema/apples.ts";
import {genealogies} from "./src/schema/genealogies.ts";


await db.insert(apples).values([
  {
    name: "unknown",
    display_name: "不明",
    color: "不明"
  },
  {
    name: "shinano_sweet",
    display_name: "シナノスイート",
    color: "黃"
  },
  // ç•¥
]);

await db.insert(genealogies).values([
  {
    child_name: "unknown",
    pollen_name: "unknown",
    seed_name: "unknown"
  },
  {
    child_name: "shinano_sweet",
    pollen_name: "tsugaru",
    seed_name: "fuji"
  },
  {
    child_name: "tsugaru",
    pollen_name: "kougyoku",
    seed_name: "golden_delicious"
  },
  {
    child_name: "kougyoku",
    pollen_name: "unknown",
    seed_name: "unknown"
  },
  {
    child_name: "golden_delicious",
    pollen_name: "unknown",
    seed_name: "unknown"
  },
  // ç•¥
])


console.log(`Seeding complete.`);

 
続いて、seedを投入します。

$ bun run seed.ts

 

生SQLを用いたdrizzle-ormでの実装

再掲となりますが、今回は「1つのりんごを指定したときに、曽祖父母までのりんご名をすべて取得して横持ちにする」を実現したいです。これはSQLのCTE (共通テーブル式) を使えば良さそうです。

 
ただ、drizzle-orm ではまだCTEをサポートしていません。

drizzle-ormでは sql テンプレートを使って生SQLも扱えます。
Drizzle ORM - Magic sql`` operator

そこで、まずは以下のような生SQLを用意します。

WITH RECURSIVE ancestry AS (
    -- 最初に、検索したいりんごの情報を取得
    SELECT
        g.child_name,
        a.display_name as child_display_name,
        g.pollen_name,
        g.seed_name,
        0 AS generation
    FROM genealogies g
    INNER JOIN apples a
      ON g.child_name = a.name
    WHERE child_name = ${targetName}

    UNION ALL

    -- 再帰的に父母や祖父母、曽祖父母を取得
    SELECT
        g.child_name as child_name,
        apples.display_name as child_display_name,
        g.pollen_name as pollen_name,
        g.seed_name as seed_name,
        a.generation + 1
    FROM genealogies g
        INNER JOIN ancestry a 
            ON g.child_name = a.pollen_name OR g.child_name = a.seed_name
        INNER JOIN apples
            ON g.child_name = apples.name
    WHERE a.generation < 3 -- 曽祖父母まで取得する
)

-- 結果を横持ちで表示(重複して取得できてしまうので、DISTINCTが必要)
SELECT DISTINCT
    a.child_display_name AS "Apple Name",
    p.child_display_name AS "Father",
    pp.child_display_name AS "Grandfather (Father's side)",
    ppp.child_display_name AS "Great-Grandfather (Father's Father's side)",
    pps.child_display_name AS "Great-Grandmother (Father'sFather's side)",
    ps.child_display_name AS "Grandmother (Father's side)",
    psp.child_display_name AS "Great-Grandfather (Father's Mother's side)",
    pss.child_display_name AS "Great-Grandmother (Father's Mother's side)",
    s.child_display_name AS "Mother",
    sp.child_display_name AS "Grandfather (Mother's side)",
    spp.child_display_name AS "Great-Grandfather (Mother's side)",
    sps.child_display_name AS "Great-Grandmother (Mother's Father's side)",
    ss.child_display_name AS "Grandmother (Mother's side)",
    ssf.child_display_name AS "Great-Grandfather (Mother's Mother's side)",
    sss.child_display_name AS "Great-Grandmother (Mother's Mother's side)"
FROM
    ancestry a
-- 父系
-- 父親を結合
        LEFT JOIN ancestry p 
            ON p.child_name = a.pollen_name AND p.generation = 1
-- 父親の父親(祖父)を結合
        LEFT JOIN ancestry pp 
            ON pp.child_name = p.pollen_name AND pp.generation = 2
-- 父親の母親(祖母)を結合
        LEFT JOIN ancestry ps 
            ON ps.child_name = p.seed_name AND ps.generation = 2
-- 父親の父親の父親(曽祖父)を結合
        LEFT JOIN ancestry ppp 
            ON ppp.child_name = pp.pollen_name AND ppp.generation = 3
-- 父親の父親の母親(曽祖母)を結合
        LEFT JOIN ancestry pps 
            ON pps.child_name = pp.seed_name AND pps.generation = 3
-- 父親の母親の父親(曽祖父)を結合
        LEFT JOIN ancestry psp 
            ON psp.child_name = ps.pollen_name AND psp.generation = 3
-- 父親の母親の母親(曽祖母)を結合
        LEFT JOIN ancestry pss 
            ON pss.child_name = ps.seed_name AND pss.generation = 3
-- 母系
-- 母親を結合
        LEFT JOIN ancestry s 
            ON s.child_name = a.seed_name AND s.generation = 1
-- 母親の父親(祖父)を結合
        LEFT JOIN ancestry sp 
            ON sp.child_name = s.pollen_name AND sp.generation = 2
-- 母親の母親(祖母)を結合
        LEFT JOIN ancestry ss 
            ON ss.child_name = s.seed_name AND ss.generation = 2
-- 母親の父親の父親(曽祖父)を結合
        LEFT JOIN ancestry spp 
            ON spp.child_name = sp.pollen_name AND spp.generation = 3
-- 母親の父親の母親(曽祖母)を結合
        LEFT JOIN ancestry sps 
            ON sps.child_name = sp.seed_name AND sps.generation = 3
-- 母親の母親の父親(曽祖父)を結合
        LEFT JOIN ancestry ssf 
            ON ssf.child_name = ss.pollen_name AND ssf.generation = 3
-- 母親の母親の母親(曽祖母)を結合
        LEFT JOIN ancestry sss 
            ON sss.child_name = ss.seed_name AND sss.generation = 3
WHERE
    a.generation = 0;

 

この生SQLをdrizzle-ormで実行するために、以下のような index.ts を用意します。

なお、 <上記のSQLをそのまま貼る> の記載など、全体を見たい場合は以下のソースコードを参照してください。
https://github.com/thinkAmi-sandbox/ringo_genealogies_with_drizzle/blob/main/index.ts

import {db} from "./db.ts";
import {sql} from "drizzle-orm";

# ここではシナノリップの系統データを取得する
const targetName = 'shinano_lip'

const result = await db.all(
  sql`<上記のSQLをそのまま貼る>`
)

console.log(result)

 

動作確認

先ほど実装した index.ts を実行したところ、シナノリップの祖先を取得できました。

$ bun run index.ts 

[
  {
    "Apple Name": "シナノリップ",
    Father: "シナノレッド",
    "Grandfather (Father's side)": "ビスタベラ",
    "Great-Grandfather (Father's Father's side)": "不明",
    "Great-Grandmother (Father'sFather's side)": "不明",
    "Grandmother (Father's side)": "つがる",
    "Great-Grandfather (Father's Mother's side)": "紅玉",
    "Great-Grandmother (Father's Mother's side)": "ゴールデンデリシャス",
    Mother: "千秋",
    "Grandfather (Mother's side)": "ふじ",
    "Great-Grandfather (Mother's side)": "デリシャス",
    "Great-Grandmother (Mother's Father's side)": "国光",
    "Grandmother (Mother's side)": "東光",
    "Great-Grandfather (Mother's Mother's side)": "印度",
    "Great-Grandmother (Mother's Mother's side)": "ゴールデンデリシャス",
  }
]

 
奥州ロマンの場合はこんな感じです。こちらも良さそうです。

[
  {
    "Apple Name": "奥州ロマン",
    Father: "つがる",
    "Grandfather (Father's side)": "紅玉",
    "Great-Grandfather (Father's Father's side)": "不明",
    "Great-Grandmother (Father'sFather's side)": "不明",
    "Grandmother (Father's side)": "ゴールデンデリシャス",
    "Great-Grandfather (Father's Mother's side)": "不明",
    "Great-Grandmother (Father's Mother's side)": "不明",
    Mother: "シナノゴールド",
    "Grandfather (Mother's side)": "千秋",
    "Great-Grandfather (Mother's side)": "ふじ",
    "Great-Grandmother (Mother's Father's side)": "東光",
    "Grandmother (Mother's side)": "ゴールデンデリシャス",
    "Great-Grandfather (Mother's Mother's side)": "不明",
    "Great-Grandmother (Mother's Mother's side)": "不明",
  }
]

 

ソースコード

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

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

*1:稀に、遺伝子の検査などで親子の見直しが発生することもあるようです

IntelliJ Platform PluginのRailroadsについて、新しいバージョン 0.2.0 をGitHub Actionsでpublishしてみた

以前、rails routes を便利に扱う、IntelliJ Platform Plugin の Railroads を作りました。

 
また、プラグインのMarketplaceのページにてNotificationの設定をしていることもあり、時々JetBrainsから [Notification] Verification results for the Railroads plugin という件名のメールが届きます。

これにより、「新しいIDEの新しいバージョンとプラグインの互換性」を把握でき、メンテナンスをするきっかけになります。

 
そんな中、

IntelliJ IDEA Ultimate 2024.2 eap (242.19890.14) 1 missing mandatory dependency. 2 possible compatibility problems, some of which may be caused by absence of dependency in the target IDE 242.19890.14

という内容のメールが届きました。

気になったため、該当のバージョンのIDEへRailroadsプラグインをインストールして動作確認したところ、「該当のバージョンのIDEでは、ToolWindow上のRailroadsアイコンが消えてしまう」と分かりました。

 
今までのRailroadsプラグインでは、アイコン作成の手間を省くためにJetBrains IDEが内部で持っているアイコンを使用していました。

ただ、そのアイコンを使うためのAPIが内部APIだったっぽく、該当のバージョンで内部APIに何らかが発生した結果、アイコンが消えたようでした。

 
そこで、「いつかはアイコンを何とかしたほうが良いかも...」と思っていたこともあり、新しいアイコンを作成・対応することにしました。

また、他にも互換性のwarningが出ていたので、そちらも合わせて対応し、バージョン 0.2.0 としてpublishすることにしました。

 
次に、新しいバージョンのpublish作業について考えました。

今まで同様「Marketplaceのページからビルドしてできたjarファイルをアップロードする」ことも考えました。

ただ、Railroadsプラグインは IntelliJ Platform Plugin Template ベースなため、GitHubのRelease機能と連動してpublishすることもできます。
https://github.com/JetBrains/intellij-platform-plugin-template?tab=readme-ov-file#continuous-integration

 
そのため、GitHubのRelease機能によるpublishを行うことで

  • publishまで自動的に行えるので、手間が減る
  • 今までのように「ローカルでビルドした結果をアップロードする」というpublish方法だと、どのソースコードを元にしてビルドした結果なのかが不明瞭
    • 一方、Release機能と連動するのであれば、タグとpublish結果が一致するので明瞭

と考えました。

 
そこで、今後は「GitHubのRelease機能と連動してプラグインのpublishも行う」として作業を行ったため、内容をメモしておきます。

 
目次

 

環境

 

0.2.0のリリース内容

ここでは、0.2.0のリリースノート的な内容を記載します。

GitHub Actionsによるpublishを知りたい方は読み飛ばしてください。

 

Railroads のアイコンの変更について

以前の記事で

ただ、時間の都合上、Railwaysで使っている png 形式のアイコンを svg 形式で用意するのは難しかったことから、今のところ JetBrains で用意しているアイコンで代替しています。

と書いた通り、Railroadsの元となったRailwaysのアイコンは使えませんでした。

「こんなときは生成AIでアイコンを作ろう」とも考えましたが、アイコンに関する知識不足のため、良さげなアイコンを生成する適切なプロンプトを書くことができませんでした。

そこで今回は、「ライセンス的に問題ないSVGファイルをベースに、一部編集し、Railroadsのアイコンとして使う」ことにしました。

 
ライセンス的に問題ないSVGアイコンを調べたところ、WikipediaにあるRailsのアイコンがCC0でした。
https://en.wikipedia.org/wiki/File:Ruby_On_Rails_Logo.svg

そこで、「01:47, 16 January 2017」の「Dorianinou」さん版のSVGファイルをベースに編集することにしました。

 
次に、SVGファイルを編集するツールの使い方が分からないので、以下の方法で編集しました。

 
その結果、最終的にはこんな感じのアイコンになりました。

 
レール上に、1つだけプラグインをイメージした箱を乗せています。スクリーンショットだと分かりづらいので、画像の詳細はこちらのプルリクを参照してください。
https://github.com/thinkAmi/railroads/pull/38/files

 

plugin signing の設定

以前の記事でふれた、 plugin signing の設定を追加しています。
IntelliJ Platform Plugin開発にて、Plugin Signing を試してみた - メモ的な思考的な

なお、プラグインの機能には影響ありません。

関係するプルリクはこちら。
https://github.com/thinkAmi/railroads/pull/8

 

テストコードの追加

こちらも、以前の記事でふれたものです。
IntelliJ Platform Plugin開発にて、KotestやBasePlatformTestCaseを使ったテストコードを書いてみた - メモ的な思考的な

これもプラグインの機能には影響ないです。プルリクはこちら。
https://github.com/thinkAmi/railroads/pull/10

 

互換性のwarningへの対応

互換性のwarningが出ていたので、対応したものになります。

プルリクはこちら。
https://github.com/thinkAmi/railroads/pull/9

 

依存ライブラリのバージョンアップ

dependabotが上げてくれたプルリクのうちおそらく影響が少なそうなものについて、動作確認をした上でバージョンアップしています。

なお、dependabotのプルリクをそのまま適用してもうまく動作しない場合は、別のプルリクを作成しています。

 
ちなみに、2024/8/17 11:30現在、Openなdependabotのプルリクは8個あります。よく調べないとアップデートしづらい...と思ったものがメインです。

今後、時間を見てメンテナンスしていこうと思います。

 

GitHubのRelease機能を使った、プラグインのpublish

ここからが本題です。

IntelliJ Platform Plugin Template にはGitHub Actionsのワークフローファイルが含まれます。

その中には、publish用のワークフロー Release も含まれています。Releaseワークフローについて、READMEには以下のように記載されています。

  • Triggered on released event.
  • Updates CHANGELOG.md file with the content provided with the release note.
  • Signs the plugin with a provided certificate before publishing.
  • Publishes the plugin to JetBrains Marketplace using the provided PUBLISH_TOKEN.
  • Sets publish channel depending on the plugin version, i.e. 1.0.0-beta -> beta channel.
  • Patches the Changelog and commits.

 
では、どんな感じになるか実際に試してみます。

 

事前準備

Railroadsリポジトリではまだ一度もRelease機能を使ったpublishを行なっていないため、事前準備が必要です。

ちなみに、一度でもpublishできている場合は不要なため、この事前準備をスキップできます。

 

publishに必要な環境変数(secret)を設定

READMEによると、GitHub Actionsを使ったpublishでは4つの環境変数(secret)が必要そうでした。
https://github.com/JetBrains/intellij-platform-plugin-template?tab=readme-ov-file#environment-variables

また、secretはGitHubのリポジトリ設定にある、 Secrets and variables > Actions > Repository secrets > New repository secret にて設定すれば良さそうでした。

 
では、それぞれの値を見ていきます。

 

PRIVATE_KEY

設定する値について、READMEでは以下のように記載されています。

Certificate private key, should contain: -----BEGIN RSA PRIVATE KEY----- ... -----END RSA PRIVATE KEY-----

 
これについては、以前 Plugin Signing を試してみたときに作った *.pem ファイルの中身を設定します。
IntelliJ Platform Plugin開発にて、Plugin Signing を試してみた - メモ的な思考的な

ちなみに、手元の秘密鍵は -----BEGIN PRIVATE KEY----- で始まっていますが、これでも問題ありませんでした。

 

PRIVATE_KEY_PASSWORD

Plugin Signing で秘密鍵を生成したときのパスワードを設定します。

 

CERTIFICATE_CHAIN

Plugin Signing で生成した *.crt ファイルの中身を設定します。

 

PUBLISH_TOKEN

JetBrains Marketplaceで公開するためのTokenを設定します。

TokenはJetBrains Marketplaceのprofileの中にある、 Tokens タブにて生成できます。
https://plugins.jetbrains.com/author/me/tokens

今回はToken Nameを Github Action Publish Token として生成しました。

 

その他環境変数

Release用のワークフローファイルを見てみると、 GITHUB_TOKEN という環境変数も参照しています。

ただ、GITHUB_TOKEN は

各ワークフロー ジョブの開始時に、GitHub によって、ワークフローで使用する一意の GITHUB_TOKEN シークレットが自動的に作成されます。 GITHUB_TOKEN はワークフロー ジョブでの認証に使用できます。

自動トークン認証 - GitHub Docs

ということから、環境変数への設定は不要です。

 

GitHub Actionsがプルリクを作成できるように設定

Release用ワークフローの中では、GitHub ActionsがCHANGELOG.mdを変更するプルリクを作成します。
https://github.com/thinkAmi/railroads/blob/v0.2.0/.github/workflows/release.yml#L77

ただ、GitHubのリポジトリのデフォルト設定では、GitHub Actionsがプルリクを作成できません。

そこで、以下の記事を参考に、 Allow GitHub Actions to create and approve pull requests へチェックを入れます。
GitHub ActionsでのPR操作権限はデフォルトでオフになったよ

URL的には以下のページです。
https://github.com/thinkAmi/railroads/settings/actions

 
ちなみに、GitHub Actionsがプルリクを作成できなくても、JetBrains Marketplaceへのプラグインのpublishはできてしまいます。

その場合、Actionsの結果はこんな感じになります。
https://github.com/thinkAmi/railroads/actions/runs/10419491498/job/28857646112

 

GitHubのRelease機能を実行

Create a new release より新しくリリースを作成します。

  • タグを指定
    • v0.2.0 など、prefixとして v を付与
  • リリースノートを記載

その後、 Publish release ボタンをクリックします。

すると、GitHub Actionsが動作し、プラグインのpublishまで行われます。

 
合わせて、「Plugin approval status: Railroads plugin version 0.2.0」という件名で、以下のような内容を含むメールも届きました。

Thank you for submitting version 0.2.0 of the Railroads plugin to JetBrains Marketplace. It has been successfully uploaded and is now pending JetBrains' review and approval.

The review process normally takes two business days. You will receive a notification as soon as the status of this review changes.

 
しばらくするとレビューも終わり、正式にRailroads 0.2.0 が公開されました。
https://plugins.jetbrains.com/plugin/24076-railroads/

 

今回のpublishで分かったこと

What's Newに、GitHubのReleaseに記載したリリースノートが表示される

GitHubのリリースノートには以下のように書きました。

## What's Changed

* add settings of plugin signing by @thinkAmi in https://github.com/thinkAmi/railroads/pull/8
* Fix: Use methodName.name instead of RContainer.getName() by @thinkAmi in https://github.com/thinkAmi/railroads/pull/9
* Add model tests and parser tests by @thinkAmi in https://github.com/thinkAmi/railroads/pull/10
* change railroads icon by @thinkAmi in https://github.com/thinkAmi/railroads/pull/38
* update CHANGELOG.md by @thinkAmi in https://github.com/thinkAmi/railroads/pull/43
* update pluginVersion to 0.2.0 by @thinkAmi in https://github.com/thinkAmi/railroads/pull/44
* update dependencies by dependabot

 
すると、Pluginの What's Newにリリースノートで記載した内容が表示されていました。

 

VersionはGitHubのタグとは連動していない

GitHubのタグには v0.2.0 と設定しました。

一方、PluginのVersionsタブを確認すると、GitHubのタグではなく、gradle.properties ファイルの pluginVersion の値が表示されました。

 

GitHubのprerelease機能と、JetBrains Marketplace機能のリリースチャンネルは連動していない

GitHub Actionsのワークフローファイルを見ると

name: Release
on:
  release:
    types: [prereleased, released]

という設定になっていました。

そのため、GitHubのRelease機能で Set as a pre-release にチェックを入れた場合でも、ワークフローが動作するようでした。

 
また、IntelliJ Platform Plugin SDKのドキュメントによると、リリースチャンネルを切り替えることもできそうに見えました。
https://plugins.jetbrains.com/docs/intellij/publishing-plugin.html#specifying-a-release-channel

 
これらより、「もしかして、 Set as a pre-release にしたら、いい感じでリリースチャンネルを設定してくれるのでは」と考え、

  • GitHubの設定で、タグ名を v0.2.0-alpha にする
  • GitHubでのリリース実行時に Set as a pre-release にチェックを入れる
  • gradle.properties ファイルの pluginVersion は 0.2.0 のまま

として、GitHubのRelease機能を使ってみました。

 
しかし、

  • GitHub上では Pre-release と表示された
  • JetBrains Marketplace上では 0.2.0 バージョンとして、デフォルトのリリースチャンネルでリリースされた
    • つまり、 0.2.0 が正式バージョンとしてリリースされた

という事態になりました。

 
これより、次回以降にアルファチャンネルなどでリリースしたい場合は、Specifying a Release Channelのドキュメントを読んで対応する or 諦めるとしたいと思いました。
https://plugins.jetbrains.com/docs/intellij/publishing-plugin.html#specifying-a-release-channel

 

同一の pluginVersion を指定すると、publishできない

上記の通り、0.2.0-alpha をリリースするつもりが 0.2.0 がリリースされてしまいました。

そこで、整合性を取るために、pluginVersion は変更せず、改めてGitHubのタグ v0.2.0 を作成してpublishしてみました。

すると、以下のエラーメッセージが表示され、publishすることができませんでした。

* What went wrong:

Execution failed for task ':publishPlugin'.

> Failed to upload plugin: Upload failed: The com.github.thinkami.railroads plugin already contains version 0.2.0 in channel

https://github.com/thinkAmi/railroads/actions/runs/10419812646/job/28858622696

 
これより、もしpublishしたい場合は、 0.2.1 などのようにpluginVersionを変更する必要がありそうでした。

今回は「0.2.0がリリースできているし、まぁいいか...」ということで対応を見送りました。

 

ReleaseのAssetsにはビルド結果が含まれない

Releaseページを見たところ、以下のスクリーンショットのようにソースコードのみ含まれていました。

 
そのため、IntelliJ Platform Plugin Templateのリリースワークフローでは、ビルド結果がGitHub上に残らないことが分かりました。

これにより、「GitHub Actionsでビルドしたプラグインをダウンロードし、直接インストールする」ができなくなります。

ただ、正式なルートでないプラグインのインストールは避けられるので、これはこれでよいのかなと思いました。

 

おわりに

JetBrains IDEでプラグインを作る際、IntelliJ Platform Plugin Templateをベースに作っておけば、プラグインのpublishまで自動化できることが分かりました。

publishの手間や心配事が解決できたため、今後も引き続きRailroadsのメンテナンスを行なっていきたいと思います。

Cloudflare Pages・Workers + Hono + React + Chart.js で食べたリンゴの割合をグラフ化してみた

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

 
運用する中で、

  • コールドスタートがやや気になる
    • めったに使わないアプリとはいえ、使うときにはさっと起動すると嬉しい
  • DB(Litestream + SQLite)ã‚’Cloud Storageに置いているので、テーブルの中身を確認しづらい
  • Cloud Runはほとんど費用負担が発生していない一方で、Cloud Storageで費用がかさんできた

などを感じることがありました。

 
そんな中、Cloudflareがデータベース機能のD1を正式リリースしました。

データベースがあるならば移行先として使えるかもしれないと考え、必要そうな機能を検証してきました。

 
また、 d1-jdbc-driver を使うことで、D1にあるテーブルをJetBrains DataGripから確認することもできました。
JetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な

 
以上より、移行しても問題なさそうと判断しました。

そこで、今までの検証に加えて移行作業を行ったため、内容をメモしておきます。

なお、移行後のWebアプリのURLは以下です。
https://ringosky.thinkami.dev/

 
目次

 

環境

  • Windows 11 WSL2
  • Wrangler 3.60.3
  • Bun 1.1.13
    • Bunのworkspaceを使ってモノレポで管理
  • フロントエンド
    • React 18.3.1
    • Chart.js 4.4.2
    • react-chartjs-2 5.2.0
    • TanStack Router 1.38.1
    • TanStack Query 5.45.1
  • バックエンド
    • Hono 4.4.12
    • Drizzle ORM 0.31.2
    • Drizzle Kit 0.22.7
    • @atproto/api 0.12.23
      • Bluesky API用ライブラリ
  • 開発向けツール
    • Wrangler 3.63.1
    • Biome 1.8.1
      • Linter & Formatter

 
ちなみに、これらのソースコードは、Bunのworkspaceを使ってモノレポで管理しています。

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

Google Cloudだと 役割 Cloudflareでは
Cloud Run アプリをホスト Pages + Workers + Service Binding(RPC)
SQLite + Litestream + Cloud Storage アプリのデータベース D1
Cloud Scheduler SNSへの投稿を定期的に収集 WorkersのCron Trigger + KV

 
また、こんな感じで Cloudflare を使っています。 Pages と Workers は Service Binding (RPC) で連携しています。

なお、上図の各種アイコンは以下のページよりお借りしています。
Brand Icons - Cloudflare Datamining

 
以降では、どんな感じでアプリを作成・移行したのかをメモしておきます。

なお、記事中ではソースコードを必要な部分だけ明示しています。

ソースコード全体は以下のリポジトリで公開しているため、必要に応じてそちらを確認してください。
https://github.com/thinkAmi/cf_ringo_sky

 

ルートディレクトリでのセットアップ

モノレポ内のPagesやWorkersが共通で使うものをセットアップします。

 

ライブラリのインストールと設定ファイルの用意

wranglerとconcurrentlyをインストールします。

$ bun add -d wrangler concurrently

 
Linter & Formatterとして Biome をインストールします。

$ bun add --dev --exact @biomejs/biome

 
Biomeの設定ファイルへコメントを書けるよう biome.jsonc として用意します。
https://github.com/thinkAmi/cf_ringo_sky/blob/main/biome.jsonc

 

Bun workspace を使うために package.jsonを編集

今回のアプリをモノレポで開発できるよう、Bunのworkspace機能を使います。
Workspaces – Package manager | Bun Docs

また、 concurrently を使ってPagesやWorkersを一括起動できるように script も設定します。

{
  "private": true,
  "scripts": {
    "dev": "concurrently \"bun run --filter=\"ringo-db\" dev:db\" \"bun run --filter=\"ringo-web\" dev:web\""
  },
  "workspaces": ["packages/*"],
  // ...
}

 

ringo-db Workersの作成

前述の通り、Service Binding RPCを使ってPagesとWorkersを連携します。

そこで、まずは

  • D1と接続し、SQLを発行する
  • Service Binding RPC向けに、メソッドを公開する

を担当する ringo-db Workersを作成します。

 

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

Workersの雛形を作るには、Cloudflare C3で作成するのが便利です。ただ、今回のWorkers向けには不要なものもできてしまいます。

そこで、今回は bun init により最小限のものだけ生成しました。
bun init – Templating | Bun Docs

# packages ディレクトリの中で作業
$ mkdir ringo-db
$ cd ringo-db


# ringo-db アプリを生成
$ bun init
...
package name (ringo-db): 
entry point (index.ts): ./src/index.ts

Done! A package.json file was saved in the current directory.
 + ./src/index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md
...

 
続いて、D1接続に必要な Drizzle ORM まわりをインストールします。

$ bun add drizzle-orm
...
installed [email protected]

合わせて、開発向けに Drizzle Kit もインストールします。

$ bun add -D drizzle-kit
...
installed [email protected] with binaries:
 - drizzle-kit

 

Cloudflare D1を作成

次に、Cloudflare D1を wrangler で作成します。database leaderは明示的に apac を指定しておきます。
create | D1 | Commands - Wrangler · Cloudflare Workers docs

$ wrangler d1 create ringodb --location apac
...
✅ Successfully created DB 'ringodb' in region APAC
Created your new D1 database.

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "ringodb"
database_id = "01febb3d-148f-4f3c-8fea-e32445da1ae1"

 

wrangler.toml の編集

D1の情報のほか、 compatibility_date や port などを指定しておきます。
Configuration - Wrangler · Cloudflare Workers docs

name = "ringo-db"
main = "./src/index.ts"
compatibility_date = "2024-06-18"
compatibility_flags = [ "nodejs_compat" ]

[dev]
port = 8788

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "ringodb"
database_id = "01febb3d-148f-4f3c-8fea-e32445da1ae1"

 

drizzle.config.ts の作成

Drizzle ORMの設定ファイルを作成します。

なお、今回のマイグレーション適用は、Drizzle Kit ではなく Cloudflare D1 の Migrationを使います。
Migrations · Cloudflare D1 docs

そのため、Cloudflare D1のMigrationの仕様に合わせて、 out は migrations にしておきます。

import type {Config} from "drizzle-kit"

export default {
  dialect: "sqlite",
  schema: "./db/schema/*",
  out: "./migrations",
} satisfies Config

 

データベースのスキーマを作成

Blueskyの投稿を保存するためのテーブル feeds 向けのスキーマとして、ringo-db/db/schema/feeds.ts を作成します。

import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const feeds = sqliteTable('feeds', {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
  name: text('name'),
  content: text('content'),
  createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`),
  snsId: text('sns_id'),
})

 

マイグレーションファイルを生成

マイグレーションファイルを drizzle-kit を使って生成します。
Generate migrations | Drizzle ORM - List of commands

$ bun drizzle-kit generate
drizzle-kit: v0.22.7
drizzle-orm: v0.31.2

No config path provided, using default 'drizzle.config.ts'
Reading config file 'path/to/ringo_sky/packages/ringo-db/drizzle.config.ts'
1 tables
feeds 5 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ migrations/0000_wild_pride.sql 🚀

 

ローカルのD1にマイグレーションを適用

ローカルのCloudflare D1対してマイグレーションを適用する場合、drizzle-kit の機能は使えません。

そこで、今回は wrangler を使ってマイグレーションを適用します。

ちなみに、ローカルに適用する --local オプションも明示的に付けておきます。
https://developers.cloudflare.com/workers/wrangler/commands/#migrations-apply

$ wrangler d1 migrations apply ringodb --local
...
Migrations to be applied:
┌─────────────────────┐
│ name                │
├─────────────────────┤
│ 0000_wild_pride.sql │
└─────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Executing on local database ringodb (01febb3d-148f-4f3c-8fea-e32445da1ae1) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
┌─────────────────────┬────────┐
│ name                │ status │
├─────────────────────┼────────┤
│ 0000_wild_pride.sql │ ✅       │
└─────────────────────┴────────┘

 

Cloudflare D1にマイグレーションを適用

続いて、本番環境である Cloudflare D1 にマイグレーションを適用します。

$ wrangler d1 migrations apply ringodb --remote
...
Migrations to be applied:
┌─────────────────────┐
│ name                │
├─────────────────────┤
│ 0000_wild_pride.sql │
└─────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Executing on remote database ringodb (01febb3d-148f-4f3c-8fea-e32445da1ae1):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 2 commands in 0.4022ms
┌─────────────────────┬────────┐
│ name                │ status │
├─────────────────────┼────────┤
│ 0000_wild_pride.sql │ ✅       │
└─────────────────────┴────────┘

 

実装に関するメモ

あとは、 src/index.ts などに必要な機能を実装します。
https://github.com/thinkAmi/cf_ringo_sky/blob/main/packages/ringo-db/src/index.ts

実装で迷ったことは以下です。

 

batch APIを使ってSQLのトランザクション相当を実装

以下の記事にある通り、Cloudflare D1にはSQLのトランザクションが実装されていないようです。

 
そこで、SQLのトランザクションが必要になったときは batch APIを使って実装しました。

 

Drizzle ORMでGroup By や Count する

Drizzle ORMのドキュメントに従い実装しました。

なお ringo-db Workers は Service Binding RPC で利用することを前提にしているため

  • WorkerEntrypoint を継承したクラスに、公開するメソッドを定義
  • エラーになるのを防ぐため、default export するオブジェクトにはダミーの fetch を定義

としています。

 

 

ringo-web Pagesの作成

次に、Pages である ringo-web を作成します。

このPagesでは

  • ringo-db Workers と Service Binding RPC して、D1のデータを受け取る
  • D1のデータを元に、ブラウザで描画する

を担当します。

 

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

packages ディレクトリの中で、 create hono によりHonoアプリの雛形を作成します。

# インストール
$ bun create hono ringo-web

create-hono version 0.7.1
✔ Using target directory … ringo-web
? Which template do you want to use? cloudflare-pages
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? bun
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd ringo-web


# ringo-webディレクトリへ移動
$ cd ringo-web

 
次に必要なライブラリをインストールします。

Reactまわりです。

$ bun add react react-dom
...
installed [email protected]
installed [email protected]


$ bun add -d @types/react @types/react-dom
...
installed @types/[email protected]
installed @types/[email protected]

 
Chart.js まわりです。

$ bun add chart.js react-chartjs-2
...
installed [email protected]
installed [email protected]

 
TanStack Routerまわりです。

$ bun add @tanstack/react-router
...
installed @tanstack/[email protected]


$ bun add -D @tanstack/router-vite-plugin @tanstack/router-devtools @tanstack/router-cli
...
installed @tanstack/[email protected]
installed @tanstack/[email protected]
installed @tanstack/[email protected] with binaries:
 - tsr

 
TanStack Queryまわりです。

$ bun add @tanstack/react-query
...
installed @tanstack/[email protected]

 

wrangler.toml の編集

compatibility_date などを設定しておきます。

なお、今回の compatibility_date には、開発を始めた頃の日付 2024-06-18 を設定してあります。

name = "ringo-web"
pages_build_output_dir = "./dist"
compatibility_date = "2024-06-18"

 

実装に関するメモ

YAMLをJSONへ変換するのに yq を使った

今までりんごに関する情報は apples.yaml というファイルで管理していました。
https://github.com/thinkAmi/dj_ringo_tabetter/blob/development/apples.yaml

今回、Cloudflareへ移行するにあたり、ソースコードはすべてTypeScriptになりました。そこで、YAMLでの管理をやめ、TypeScriptで管理することを考えました。

 
YAMLをJSONへ変換するツールを探したところ、 yq があったため、使ってみることにしました。
https://github.com/mikefarah/yq

まずはインストールします。

$ sudo snap install yq

yq v4.40.5 from Mike Farah (mikefarah) installed

 
続いて、JSONファイルへと変換します。

$ yq -o json ./old_data/apples.yml > ./src/apples.json

 
あとはファイルに保存されたJSONをTypeScriptファイルへと移植・修正して使います。

 

デプロイしようとするとviteが途中でハングするので、それぞれ分ける

過去記事でも書きましたが、Pagesをデプロイしようとするとハングしてしまいます。

そこで、フロントエンド・バックエンド・デプロイの3段階に分けてビルド・デプロイします。

ただ、各ビルドでは途中でハングするので、途中でキャンセルしています。

まずはフロントエンドのビルド

$ bun run build:fe
...
dist/static/index.lazy-B-SIKAr-.js           1.01 kB │ gzip:  0.60 kB
dist/static/month.lazy-mKjT0B0Q.js           1.06 kB │ gzip:  0.64 kB
dist/static/appleLegendPlugin-C9-uDFHk.js  201.77 kB │ gzip: 70.46 kB
dist/static/client.js                      213.00 kB │ gzip: 68.13 kB
✓ built in 1.33s

^C  # ハングしたのでキャンセル

 
続いてバックエンドです。

$ bun run build:be
$ vite build
vite v5.3.1 building SSR bundle for production...
✓ 21 modules transformed.
dist/_worker.js  22.64 kB
✓ built in 196ms

^C  # ハングしたのでキャンセル

 
最後にデプロイします。

$ bun run deploy
$ wrangler pages deploy dist
The project you specified does not exist: "ringo-web". Would you like to create it?"
❯ Create a new project
✔ Enter the production branch name: … main
✨ Successfully created the 'ringo-web' project.
🌏  Uploading... (5/5)

✨ Success! Uploaded 5 files (2.38 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at https://578e4ac8.ringo-web.pages.dev

 

ringo-bsky Workersの作成

最後に、 Blueskyからリンゴの投稿を定期的に取得する ringo-sky Workersを作成します。

 

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

packages ディレクトリの中に ringo-bsky を作成し、その中で bun init します。

$ bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (ringo-bsky): 
entry point (index.ts): 

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts

 
続いて、Blueskyにアクセスするためのライブラリ @atproto/api をインストールします。
https://www.npmjs.com/package/@atproto/api

$ bun add @atproto/api
bun add v1.1.13 (bd6a6051)

installed @atproto/[email protected]

11 packages installed [3.82s]

 

Blueskyのクレデンシャルをローカル・本番環境へ設定

Blueskyの投稿を取得するために、クレデンシャルを設定します。

Workersではクレデンシャルは

  • ローカル環境は .dev.env ファイル
  • 本番環境は wrangler のコマンド

を使います。
Secrets · Cloudflare Workers docs

 
まず、ローカル環境向けには ringo-bsky ディレクトリの直下に .dev.env ファイルを生成します。中身には以下のクレデンシャル情報を設定しておきます。

IDENTIFIER=自分のDID
APP_PASSWORD=アプリパスワードの値

 
次に、wranglerを使って本番環境用のクレデンシャルを設定します。

まずは IDENTIFIER を設定します。

$ wrangler secret put IDENTIFIER
...
✔ Enter a secret value: … ********************
🌀 Creating the secret for the Worker "ringo-bsky" 
✨ Success! Uploaded secret IDENTIFIER

 
次に APP_PASSWORD を設定します。

$ wrangler secret put APP_PASSWORD
...
✔ Enter a secret value: … *******************
🌀 Creating the secret for the Worker "ringo-bsky" 
✨ Success! Uploaded secret APP_PASSWORD

 
なお、 wrangler secret put すると、wrangler.toml の情報をもとに Cloudflare Workersが自動で作成されます。

このときに作成される Workers を確認したところ

  • 名前は wrangler.tomlで指定した name
  • Environment にシークレットが設定済
  • ソースコードは export default { fetch() {} } のみ

という状態でした。

 

KV へ処理済の投稿に関する情報を記録

Blueskyから投稿を取得・保存する際、重複してデータを保存しないよう「前回どこまで保存したか」を記録しておく必要があります。

今まではDBへ保存していました。ただ、Cloudflareには KV というキーバリューストアがあります。
Cloudflare Workers KV · Cloudflare Workers KV

「前回どこまで保存したか」という情報は1個だけ存在していればよいので、今回は KV を使って保存することにします。

 
まずは wrangler でグローバルなKVを作成します。

なお、wrangler kv:namespace コマンドのwarningが出ていますが、まだドキュメントには反映されていないようです。また、warningなので、KVはできているようです。
https://developers.cloudflare.com/workers/wrangler/commands/#create-3

$ wrangler kv:namespace create LAST_CURSOR_KV
â–² [WARNING] The `wrangler kv:namespace` command is deprecated and will be removed in a future major version. Please use `wrangler kv namespace` instead which behaves the same.
...
🌀 Creating namespace with title "ringo-bsky-LAST_CURSOR_KV"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
[[kv_namespaces]]
binding = "LAST_CURSOR_KV"
id = "f6f608a043

 
続いて、同じコマンドに --preview オプションを付けて、ローカルのKVを作成します。

なお、warningで指示されたコマンド wrangler kv namespace を使ったところ、warningが消えました。

$ wrangler kv namespace create LAST_CURSOR_KV --preview
...
🌀 Creating namespace with title "ringo-bsky-LAST_CURSOR_KV_preview"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
[[kv_namespaces]]
binding = "LAST_CURSOR_KV"
preview_id = "54ad69fc39bc429597e55ed0fe7acdd9"

 
最後に、wrangler.toml に KV の設定を追加します。

kv_namespaces = [
    { binding = "LAST_SEARCH_KV", id = "f6f608a04340492d87d3a10b0210cfa8", preview_id = "54ad69fc39bc429597e55ed0fe7acdd9" }
]

 
あとは、

// 読み込み
await env.LAST_SEARCH_KV.get(BSKY_KV_KEY)

// 書き込み
await env.LAST_SEARCH_KV.put(BSKY_KV_KEY, latestCreateAt)

のような感じで使います。

 

Cron Trigger による定期実行を設定

Cloudflare Workerでは Cron Trigger を使って定期実行を実現できます。
Cron Triggers · Cloudflare Workers docs

 
そこで、wrangler.toml へ定期実行タイミングの設定を行います。

なお、タイムゾーンは UTC であることに注意します。以下の例では、毎日、日本時間の午前3時に起動します。

[triggers]
crons = [ "0 18 * * *" ]

 
あとは、 scheduled ハンドラを持ったオブジェクトを default export します。

import type { ExportedHandler } from 'cloudflare:workers'

export default {
  async scheduled(_event: any, env: Env) {
    const bsky = new Bsky(env)
    await bsky.run()
  },
} as ExportedHandler<Env>

 
ローカルで動作確認するには、 ringo-bsky を

$ wrangler dev --test-scheduled

にて起動します。

それに加え、別のターミナルから curl を使って

$ curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"

のようにアクセスします。
Test Cron Triggers | Cron Triggers · Cloudflare Workers docs

 

データ移行について

アプリを Google Cloud から Cloudflare へ移行するのに伴い、

  • 移行元
  • 移行先
    • Cloudflare D1

というデータ移行が必要になります。

今回は

  • Google Cloud Storageから、ローカルのSQLiteファイルとしてリストア
  • ローカルのSQLiteから、ローカルのD1へデータをリストア
  • ローカルのD1から、Cloudflare D1へリストア

というステップでデータ移行を行います。

 

Litestreamを使い、ローカルファイルとしてリストアする

Litestreamのドキュメントに従い、 litestream restore コマンドでローカルへSQLiteをリストアします。
Replicating to Google Cloud Storage - Litestream

 

リストアしたSQLiteからローカルのD1へデータを投入する

ローカルでの動作確認を可能にするため、LitestreamでリストアしたSQLiteをローカルのD1へ投入します。

データを投入するときの方針は以下です。

  • Cloudflare環境では不要なデータをリストアしない
  • Drizzle ORMを使って、ローカルのD1へデータを投入する

 
そこで、次のようなスクリプトを作成します。

なお、ローカルのD1のファイル名などについては、環境によって異なります。

// @ts-ignore
import { Database } from 'bun:sqlite'
import { sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { feeds } from '../../db/schema/feeds'

type Tweet = {
  id: number
  name: string
  tweet: string
  tweeted_at: string
  tweet_id: number
}

const main = async () => {
  const fromSqlite = new Database('old_data/ringo_2024_0502.db')
  const fromDb = drizzle(fromSqlite)
  const tweets: Tweet[] = await fromDb.all(
    sql.raw('select * from tweets_tweets'),
  )

  // 環境に応じてファイル名を修正する
  const fileName =
    '2073307253fd76d9e289ad074b54fc751825840a1efddf02aa13a82ecf5305f6.sqlite'
  const toSqlite = new Database(
    `.wrangler/state/v3/d1/miniflare-D1DatabaseObject/${fileName}`,
  )
  const toDb = drizzle(toSqlite)

  // biome-ignore lint/complexity/noForEach: <explanation>
  tweets.forEach(async (t) => {
    await toDb.insert(feeds).values({
      name: t.name,
      content: t.tweet,
      createdAt: t.tweeted_at,
      snsId: t.tweet_id.toString(),
    })
  })

  console.log('finished')
}

main()

 
続いて、 ringo-db ディレクトリの中で、スクリプトを実行します。

$ bun run scripts/development/import_local_db.ts 
finished

 
ローカルのD1を確認すると、必要なデータが投入されていました。

 

本番のD1に対してマイグレーションを実行する

ローカルでの動作確認ができたところで、次は本番環境です。

ここで、 ringo-db Workersを初めてデプロイしたとき、同時にD1も作成されているはずです。

$ wrangler deploy --minify
...
Total Upload: 64.01 KiB / gzip: 18.33 KiB
Your worker has access to the following bindings:
- D1 Databases:
  - DB: ringodb (01febb3d-148f-4f3c-8fea-e32445da1ae1)
Uploaded ringo-db (3.96 sec)
Published ringo-db (4.40 sec)
...

 
ただ、この時点ではD1はあるもののテーブルが存在しません。

そこで、wranglerを使い、本番のD1に対してマイグレーションを実行します。
https://developers.cloudflare.com/workers/wrangler/commands/#migrations-apply

$ wrangler d1 migrations apply ringodb --remote
...
Migrations to be applied:
┌─────────────────────┐
│ name                │
├─────────────────────┤
│ 0000_wild_pride.sql │
└─────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Executing on remote database ringodb (01febb3d-148f-4f3c-8fea-e32445da1ae1):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 2 commands in 0.4022ms
┌─────────────────────┬────────┐
│ name                │ status │
├─────────────────────┼────────┤
│ 0000_wild_pride.sql │ ✅       │
└─────────────────────┴────────┘

 

ローカルのD1からデータをエクスポートする

Cloudflareの以下のドキュメントを参考に、ローカルのD1からデータをエクスポートします。
Export an existing D1 database | Import and export data · Cloudflare D1 docs

なお、ローカルのD1なため、エクスポート時には --local フラグが必要です。

$ wrangler d1 export ringodb --local --output=./old_data/ringodb_local_2024_0706.sql
...
🌀 Exporting local database ringodb (01febb3d-148f-4f3c-8fea-e32445da1ae1) from .wrangler/state/v3/d1:
🌀 To export your remote database, add a --remote flag to your wrangler command.
🌀 Exporting SQL to ./old_data/ringodb_local_2024_0706.sql...
Done!

 
エクスポートしたデータを見たところ、マイグレーション済な本番環境では不要なマイグレーション関係のデータが含まれていました。

そこで、エクスポートしたデータから、以下の内容を削除しました。

CREATE TABLE d1_migrations(
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        name       TEXT UNIQUE,
        applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
INSERT INTO d1_migrations VALUES(1,'0000_wild_pride.sql','2024-06-17 23:22:59');
CREATE TABLE `feeds` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `content` text,
    `created_at` text DEFAULT (CURRENT_TIMESTAMP),
    `sns_id` text
);

 

本番のD1に対してインポートする

次に、wranglerを使って、ローカルへエクスポートしたデータを本番のD1へインポートします。

エクスポートしたデータはSQLファイルになっているため、 wrangler d1 execute を使います。
https://developers.cloudflare.com/workers/wrangler/commands/#execute

$ wrangler d1 execute ringodb --remote --file=./old_data/ringodb_local_2024_0706.sql
...
✔ ⚠️ This process may take some time, during which your D1 database will be unavailable to serve queries.
  Ok to proceed? … yes
🌀 Executing on remote database ringodb (01febb3d-148f-4f3c-8fea-e32445da1ae1):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
Note: if the execution fails to complete, your DB will return to its original state and you can safely retry.
├ 🌀 Uploading 01febb3d-148f-4f3c-8fea-e32445da1ae1.c8499ae13caabeb9.sql 
│ 🌀 Uploading complete.
│ 
🌀 Starting import...
🌀 Processed 751 queries.
🚣 Executed 751 queries in 0.04 seconds (2240 rows read, 1498 rows written)
   Database is currently at bookmark 00000005-00000000-00004dc6-da501f7be681ab1e632fa9c002d2fc69.
┌────────────────────────┬───────────┬──────────────┬────────────────────┐
│ Total queries executed │ Rows read │ Rows written │ Database size (MB) │
├────────────────────────┼───────────┼──────────────┼────────────────────┤
│ 751                    │ 2240      │ 1498         │ 0.19               │
└────────────────────────┴───────────┴──────────────┴────────────────────┘

 
インポート後、Cloudflare上のD1を確認すると、データが投入されていました。

 

その他

Pagesでカスタムドメインを設定

Cloudflare Pagesでは、アプリにカスタムドメインを設定できます。
Custom domains · Cloudflare Pages docs

 
今回は以下の流れで対応しました。

  • Cloudflare Pagesでの作業
    • Cloudflare PagesのCustom domains をタブを開き、 Set up a custom domain をクリック
    • サブドメインで運用するので、 ringosky.thinkami.dev を入力
    • ドメインのDNSは変更しないので、 My DNS providerの Begin CNAME setup を選択
    • NameとTargetが表示されるので、コピー
  • ドメインを管理している Squarespace での作業
    • カスタムレコードの レコードを追加 をクリックし、表示されている値を設定
  • 再度、Cloudflareでの作業
    • Check DNS records をクリック
    • 「Your records for ringosky.thinkami.dev are being rechecked. You’ll be notified by email when your domain is activated.」と表示されるので、しばらく待つ

 
しばらく待つと、カスタムドメインでの運用ができるようになりました。

 

移行前のデータ削除

Cloudflareで運用できるようになったので、Cloud Runで管理しているデータを削除しておきます。

  • Cloud Run
  • Cloud Storage のすべてのバケット
  • Cloud Scheduler
  • サービスアカウント
  • Secret Manager
  • Artifact Registry

 
お世話になりました。ありがとうございました。

 

ログに TypeError: e.env.RINGO_DB_TOTAL.calculateByName is not a function のように表示されたとき

今回、

  • ローカルでは、Service Binding RPC まわりは問題なく動作している
  • 本番環境にデプロイすると、動作しない
    • 本番環境のログに、「TypeError: e.env.RINGO_DB_TOTAL.calculateByName is not a function」が出力されて動作しない

ということが起きました。

 
Service Binding RPC の設定はすべてドキュメント通り行なっているはずでしたが、うまくいきませんでした。

そんな中、wrangler.toml を見たところ、 compatibility_date の値が Pages や Workers の間で差異があることに気づきました。

 
そこで、 compatibility_date の値を 2024-06-18 へ統一したところ、動作するようになりました。

本当にこの対応でよいのか自信はありませんが、手元ではこの方法で解決したため、メモとして残しておきます。

 

動作確認

以下のURLで動作しています。起動も速くなりました。
https://ringosky.thinkami.dev/

 
月ごとの表示はこちら。
https://ringosky.thinkami.dev/month

 

ソースコード

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

#KotlinFest Kotlin Fest 2024 に参加しました

6/22(土)に、ベルサール渋谷ファーストで開かれた「Kotlin Fest 2024」に参加しました。
Kotlin Fest 2024

 
 
最近、Kotlinを使って IntelliJ Platform Plugin を作りました。
RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました - メモ的な思考的な

その際にKotlinの書き味が良いと感じたこともあり、Kotlin Fest 2024へ参加することにしました。

そこで、簡単ですが参加メモを残します。誤りがあればご指摘ください。

 
目次

 

参加したセッション

今回は、合計8セッションに参加しました。

他にも参加しようと思っていましたが、

  • スポンサーブースで話し込んだ
  • Ask the Speaker コーナーで話し込んだ
  • エアコンで体が冷えすぎて館外へ出てた

などで参加できないセッションもありました。

 

オープニングセッション

今日一日Kotlinを愛でていこうという気持ちになりました。

セッションの中で特に良かったのは、スポンサー各社がどのようにKotlinを使っているかの事例紹介があったことでした。Kotlinをどんな領域で使っているのか、知ることができたためです。

 

KotlinConf 2024を後から256倍楽しむためのヒント

資料:KotlinConf 2024 を後から256倍楽しむためのヒント - Google スライド

KotlinConf 2024の様子がわかった良いセッションでした。

また、

  1. 生成AIを使って各セッションのサマリを作る
  2. 気になるセッションを動画で見る

という方法は色んなところで応用できそうだったので、今後使っていこうと思いました。

他にも、国内のKotlinコミュニティの紹介があったのも良かったです。

 

今こそ始めたい!Compose Multiplatform

資料:Get started with Compose Multiplatform! - Speaker Deck

Compose Multiplatformを知らなかった自分にとっては、全体像や作り方を知ることができたセッションでした。

Desktopに加えWebでも使えそうと分かったため、何か作りたくなった時は選択肢の一つにしてみようと思いました。

 

2024年版 Kotlin サーバーサイドプログラミング実践開発

資料:2024年版 Kotlin サーバーサイドプログラミング実践開発 - Speaker Deck

 
日頃は主にサーバーサイドのWebアプリケーションエンジニアとして働いていることもあり、Kotlinでサーバーサイドを作る時の技術スタックを知りたくて参加しました。

自分がWebアプリケーションを作りたいときに必要そうなライブラリを、更新状況も含めて一通り紹介してくださったので、参考になりました。

 

もっとKotlinを好きになる!K2時代のKotlin Compiler Plugin開発

資料:[Kotlin Fest 2024] もっとKotlinを好きになる!K2時代のKotlin Compiler Plugin開発 - Speaker Deck

KotlinのCompilerにもPluginを書けるところがあるのが気になり参加しました。

Kotlin Compilerの内部構造からPluginでの拡張ポイント、実際のPluginの例など一通りの解説がありました。図も多用されていて、専門外の自分でも分かりやすく感じました。

また、このセッションのおかげで、別のセッションの理解が深まったので、その点でもありがたかったです。

 

withContextってスレッド切り替え以外にも使えるって知ってた?

資料:withContextってスレッド切り替え以外にも使えるって知ってた? - Speaker Deck

withContext とは何かを知るために参加しました。

IntelliJ Platform PluginでもUIスレッドを意識して実装することがあったので、 CoroutineContext の切り替えが必要なときが来たら、改めて資料などを見返そうと思いました。

 

Kotlin sealed classを用いた、ユーザーターゲティングDSL(専用言語)と実環境で秒間1,000万評価を行う処理系の事例紹介

資料:#KotlinFest 2024 : Kotlin sealed classを用いた、ユーザーターゲティングDSL(専用言語)と実環境で秒間1,000万評価を行う処理系の事例紹介 - Speaker Deck

IntelliJ Platform Pluginを作ったときにUIをKotlin DSLで書いたこともあり、他のKotlinのDSL事例を知りたくて参加しました。
Kotlin UI DSL Version 2 | IntelliJ Platform Plugin SDK

このセッションではYAMLをベースにしたDSLについて解説がありました。

「Kotlinのコンパイルエラーが出なくなるまで実装すると完成」のような内容を聞いて、うっかり変な実装をしなくて済むので良さそうと感じました。

 

K2のKotlin IDEプラグインの中を覗いてみよう♪

資料:K2のKotlin IDEプラグインの中を覗いてみよう♪ - Speaker Deck

「IDEプラグインの中」というタイトルに惹かれて参加しました。

など、いつも使っているIDEの中ではそんなことが起きているのねと思ったのでした。

また、「K2時代のKotlin Compiler Plugin開発」のセッションでコンパイラの各フェーズの話を聞いていたこともあり、難しい部分が予習できていた感があって良かったです。

 
あとは、K2になった時、Kotlinで書いた自作のプラグインへの影響は何があるのか気になったため、Ask the Speakerコーナーで質問してみました。

すると

  • K2で変わるのはKotlin Pluginの話
  • IntelliJ Platform Pluginの PSI (Program Structure Interface) などは変わらない
  • Kotlinの機能をガンガン使っているのでなければ、設定ファイルに追記するくらいの影響

的な回答をいただきました。

Kotlin Fest 2024に参加するまでは今後のプラグインのメンテナンスがどうなるか悩んでいたので、中の人からお話を聞けただけでもありがたかったです。

安心して、今後も細々とメンテナンスしていこうと思いました。

 

スポンサーブース巡り

スタンプラリーが開催されていたこともあり、スポンサー各社のブースを巡りました。

複数社でKotlinに関する問題を出題していたので、Kotlin分かれば楽しそうと思いつつ、Kotlinとは関係ない話題とかで話し込んでいたりしました。

また、Twitter (X) のアイコンをバッチにしていただいたり、エンジニア トリ診断を受けてカナリアが出たりと、Kotlinにそこまで詳しくない自分でも楽しめました。

 

終わりに

最後になりましたが、Kotlin Fest 2024 を運営してくださったみなさま、参加者のみなさま、ありがとうございました。

CloudflareのService Bindings RPC を使って、Bun + Hono な Pages と Workers を連携してみた

Cloudflare環境でアプリを作っていたところ、Cloudflare Workers(以降Workers) で Service Binding RPC が使えると知りました。

 
Cloudflare Pages (以降Pages) とWorkersの連携でも RPC が使えるのか気になりました。調べてみたところ、使えそうという記事がありました。

 
そこで、Workers と Pages の RPC による連携として

  • Workers は C3 で作成し、RPC用関数を定義
  • Pages は Hono で作成し、WorkersのRPC用関数から値をもらって表示

をためしてみたところ、色々ハマったことから、メモを残しておきます。

 
目次

 

環境

  • Windows 11 WSL2
  • Wrangler 3.60.3
  • Bun 1.1.13
  • Cloudflare Workers
    • C3 で生成
  • Cloudflare Pages
    • Hono 4.4.6
  • 開発向けツール
    • concurrently 8.2.2
      • ローカルで、PagesとWorkersを同時に起動するため
    • Biome 1.8.1
      • Linter & Formatter

   

Service Binding RPCを使わない、Pages と Workers を作成

まずは、ローカルでそれぞれが連携しない、Pages と Workers を作成してみます。

 

事前準備

全体で使うパッケージをインストールします。

$ bun add -d wrangler concurrently

$ bun add --dev --exact @biomejs/biome

 
今回のWorkersとPagesですが、Bunのworkspace機能を使ってモノレポにします。

 
具体的には、 packages の下に2つのディレクトリを用意します。

  • app-worker
    • Workers用。C3で作り、Bunで動作
  • app-page
    • Pages用。Honoで作り、Bunで動作

 
まずは package.json に Bun workspace 用の設定を追加します。

{
  "private": true,  // 追加
  "workspaces": ["packages/*"],  // 追加
  "dependencies": {},
  "devDependencies": {
    "biome": "^0.3.3",
    "concurrently": "^8.2.2",
    "wrangler": "^3.60.3"
  }
}

 
また、Bunのworkspaceを使うときのサブディレクトリ packages も作成し、その中へ移動しておきます。

$ mkdir packages

$ cd packages/

 

Workersの作成

今回は Cloudflare C3 を使い、Workers を作成します。
Create projects with C3 CLI · Cloudflare Pages docs

必要最低限の実装だけあればよいので、

とし、あとは任意な設定とします。

なお、 bun create 時に cloudflare@latest とすると、最新版を探しに行って帰ってこなかったことから、明示的に2024/6/15の最新バージョン 2.21.7 を指定しています。

$ bun create [email protected]

using create-cloudflare version 2.21.7

â•­ Create an application with Cloudflare Step 1 of 3
│ 
├ In which directory do you want to create your application?
│ dir ./app-worker
│
├ What type of application do you want to create?
│ type "Hello World" Worker
│
├ Do you want to use TypeScript?
│ yes typescript
│
├ Copying template files 
│ files copied to project directory
│ 
├ Updating name in `package.json` 
│ updated `package.json`
│ 
├ Installing dependencies 
│ installed via `bun install`
│ 
├ Installing dependencies 
│ installed via `bun install`
│ 
â•° Application created 

â•­ Configuring your application for Cloudflare Step 2 of 3
│ 
├ Installing wrangler A command line tool for building Cloudflare Workers 
│ installed via `bun install wrangler --save-dev`
│ 
├ Installing @cloudflare/workers-types 
│ installed via bun
│ 
├ Adding latest types to `tsconfig.json` 
│ skipped couldn't find latest compatible version of @cloudflare/workers-types
│ 
├ Retrieving current workerd compatibility date 
│ compatibility date 2024-06-14
│ 
â•° Application configured 

â•­ Deploy with Cloudflare Step 3 of 3
│ 
├ Do you want to deploy your application?
│ no deploy via `bun run deploy`
│
├  APPLICATION CREATED  Deploy your application with bun run deploy
│ 
│ Navigate to the new directory cd app-worker
│ Run the development server bun run start
│ Deploy your application bun run deploy
│ Read the documentation https://developers.cloudflare.com/workers
│ Stuck? Join us at https://discord.cloudflare.com
│ 
â•° See you again soon! 

 
この時点で Workers ができているはずです。

そこで、 app-worker ディレクトリの中で bun run dev して、http://localhost:8787/ へアクセスしたところ、 Hello World! が表示されました。

 

Pages の作成

続いて、Honoを使ってPagesを作成します。

$ bun create hono app-page

create-hono version 0.7.1
✔ Using target directory … app-page
? Which template do you want to use? cloudflare-pages
✔ Cloning the template
? Do you want to install project dependencies? no
🎉 Copied project files
Get started with: cd app-page

 
上記ではインストールを選択しなかったので、改めて app-pageへ移動し、インストールを行います。

$ cd app-page

$ bun install
bun install v1.1.13 (bd6a6051)

+ @cloudflare/[email protected]
+ @hono/[email protected]
+ @hono/[email protected]
+ [email protected]
+ [email protected]
+ [email protected]

97 packages installed [1403.00ms]

 
bun run dev して、http://localhost:5173/ へアクセスすると、 Hello! が表示されました。

 

concurrently で Workers と Pages を同時に起動できるようにする

今後WorkersとPagesを連携して動作確認をしますが、それぞれのディレクトリで bun run dev するのは手間です。

そこで、 concurrently を使って、Workers と Pages を同時に起動できるようにします。

 
まずは、Workersである app-worker の package.json で、起動コマンドを dev:worker へ修正します。

{
  // ...
  "scripts": {
    "dev:worker": "wrangler dev",
    // ...
  },
  //...
}

 
続いて、Pagesである app-page の package.json で、 name の追加と dev:page への修正を行います。

また、Bunのドキュメントにある通り、依存先の Worker を指定します。
なお、ドキュメントと異なり devDpendencies に設定しましたが、特に意図はないです。。ドキュメントに従うなら dependencies のほうが良いかもしれません。
Configuring a monorepo using workspaces | Bun Examples

{
  "name": "app-page",
  // ...
  "scripts": {
    "dev:page": "vite",
    "deploy": "bun run build && wrangler pages deploy"
    // ...
  },
  //...
  "devDependencies": {
    "app-worker": "workspace:*"
  }
  // ...
}

 
最後に、ルートにある package.json に、concurrently で Workers と Pages を同時に起動できるような script を追加します。

その際、Bunのfilterを使って、Workers と Pages をそれぞれ指定します。
Filter – Package manager | Bun Docs

{
  //...
  "scripts": {
    "dev": "concurrently \"bun run --filter=\"app-worker\" dev:worker\" \"bun run --filter=\"app-page\" dev:page\""
  },
  //...
}

 
準備ができたので動作確認します。

ルートディレクトリで bun run dev すると、WorkersとPagesの両方が起動しました。

それぞれのURLへアクセスしても、先ほどと同じように表示されます。

$ bun run dev

bun run dev
$ concurrently "bun run --filter="app-worker" dev:worker" "bun run --filter="app-page" dev:page"
app-worker dev:worker $ wrangler dev
[0] │ 
[0] │  ⛅️ wrangler 3.60.3
[0] │ -------------------
[0] │ 
[0] │ ⎔ Starting local server...
[wrangler:inf] Ready on http://localhost:8787
[0] └─ Running...
app-page dev:page $ vite
[1] │ 
[1] │   VITE v5.3.1  ready in 592 ms
[1] │ 
[1] │   ➜  Local:   http://localhost:5173/
[1] │   ➜  Network: use --host to expose
[1] └─ Running...

 

Service Bindings RPC を使って、WorkersとPagesを連携する

WorkersとPagesができたので、次は Pages から Workers を呼び出す Service Bindings RPC を試してみます。

 

Workers で WorkerEntrypoint を使った実装へと差し替える

公式ドキュメントにある通り、 WorkerEntrypoint クラスを使った実装へと差し替えます。
The WorkerEntrypoint Class - Service bindings - RPC (WorkerEntrypoint) · Cloudflare Workers docs

今回は hello というメソッドを用意し、Pagesから呼び出せるようにします。

なお、今回はサンプルコードにあるように、 default export を使った実装にします。

また、 fetch メソッドも実装しておかないとエラーになるため、忘れずに実装しておきます。

import { WorkerEntrypoint } from 'cloudflare:workers'

export default class MyWorkerEntrypoint extends WorkerEntrypoint {
  async hello() {
    // biome-ignore lint: debug
    console.log('called worker!')
    return 'Hello my worker!'
  }

  fetch() {
    return new Response('Hello World!')
  }
}

 
以上で Workers の準備は完了です。

 

Pagesで Service Bindings RPC 用の設定を wrangler.toml に追加する

続いて Pages を実装します。

まずは、Service Bindings RPC 用の設定を wrangler.toml に追加します。
Service bindings - Configuration - Wrangler · Cloudflare Workers docs

Workersでは default export を使ったため、今回は binding と service を定義します。

[[services]]
# ソースコードの中で参照する名前
# hono的には c.env.MY_WORKERのように参照する
binding = "MY_WORKER"

# serviceは app-workerのwrangler.tomlにある `name` と同じ名前にする必要がある
service = "app-worker"

 

Pages で Service Bindings RPC を使って、Workers を呼び出す

設定が終わったので、次は実装です。

Honoの app.get() の中で、 c.env.MY_WORKER.hell() のようにして、Service Bindings RPC を使います。

なお、 Service Bindings RPC を使うときには async を使うとのことです。
All calls are asynchronous | Remote-procedure call (RPC) · Cloudflare Workers docs

app.get('/', async (c) => {
  const r = await c.env.MY_WORKER.hello()

  return c.render(r)
})

 

usingを使ったRPCリソースの開放が必要か検討する

今回、Service Binding RPC を使っています。RPCにはLifecycleがあります。
Workers RPC — Lifecycle · Cloudflare Workers docs

そのため、以下のLifecycleのドキュメントや記事にある通り、リソースの開放が必要になるかもしれません。

 
今回の使い方を見てみると

app.get('/', async (c) => {
  const r = await c.env.MY_WORKER.hello()

  return c.render(r)
})

のように、Honoの app.get() 、Cloudflare 的には fetch handler の中でRPCを使っています。

Lifecycleの Automatic disposal and execution contexts に記載されている例に該当しそうなことから、今回は using キーワードを使わない実装で良さそうです。

End of event handler / execution context

When an event handler is “done”, any stubs created as part of the event are automatically disposed.

For example, consider a fetch() handler which handles incoming HTTP events. The handler may make outgoing RPCs as part of handling the event, and those may return stubs. When the final HTTP response is sent, the handler is “done”, and all stubs are immediately disposed.

 

More precisely, the event has an “execution context”, which begins when the handler is first invoked, and ends when the HTTP response is sent. The execution context may also end early if the client disconnects before receiving a response, or it can be extended past its normal end point by calling ctx.waitUntil().

 

For example, the Worker below does not make use of the using declaration, but stubs will be disposed of once the fetch() handler returns a response

 

https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle#automatic-disposal-and-execution-contexts

 
以上より、 Pages の実装も完了です。

 

動作確認

ルートディレクトリで bun run dev し、http://localhost:5173/ にアクセスします。

すると、 Hello my worker! というWorkerから取得した Hello my worker! が表示されました。

 

Wrangler で Workers と Pages をデプロイ

今まではローカルで Service Bindings RPC を動かしてきました。

次はCloudflareへデプロイし、動作を確認してみます。

 

Workers のデプロイ

まずは Workers からデプロイします。

app-worker ディレクトリの中に入り、以下を実行すると、デプロイが終わりました。

$ bun run deploy

 

Pages のデプロイ

Workers のデプロイが完了したことを確認後、Pages のデプロイを行います。

bun run deploy すると、初回デプロイのため、Pages の proeject が作成されます。

また、production ブランチについての質問もあります。ここまでの開発は feature ブランチ上で行ってきましたが、productionは main ブランチにします。

$ bun run deploy

$ bun run build && wrangler pages deploy
$ vite build
vite v5.3.1 building SSR bundle for production...
✓ 41 modules transformed.
dist/_worker.js  27.90 kB
✓ built in 249ms
The project you specified does not exist: "app-page". Would you like to create it?"
❯ Create a new project
✔ Enter the production branch name: … main
✨ Successfully created the 'app-page' project.
🌎  Uploading... (1/1)

✨ Success! Uploaded 1 files (1.78 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at https://***.pages.dev

 
デプロイ後、 Cloudflare console を確認すると、 Preview な Environment にデプロイされました。

 
次に、今までの実装を main ブランチに取り込み & main ブランチへ切り替えしてから、デプロイします。

$ git branch
  feature/add_pages_and_workers
* main

$ bun run deploy
$ bun run build && wrangler pages deploy
$ vite build
vite v5.3.1 building SSR bundle for production...
✓ 41 modules transformed.
dist/_worker.js  27.90 kB
✓ built in 270ms
🌍  Uploading... (1/1)

✨ Success! Uploaded 0 files (1 already uploaded) (0.50 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at https://d10acd67.app-page-7vt.pages.dev/

 
Cloudflareのconsoleを確認すると、 Production へのデプロイが成功していました。

 
また、デプロイ先のURLを開くと、Workerから取得した値が表示されました。

 
ちなみに、初回デプロイ時によく見かけたのが、Pagesのデプロイで示されたURLを開くと Internal Server Error となることです。

Pagesのログを見ると "TypeError: e.env.MY_WORKER.hello is not a function" のようなエラーが出ていました。

{
  "outcome": "ok",
  "scriptName": "pages-worker--2956310-production",
  "diagnosticsChannelEvents": [],
  "exceptions": [],
  "logs": [
    {
      "message": [
        "TypeError: e.env.MY_WORKER.hello is not a function"
      ],
      "level": "error",
      "timestamp": 1718434302398
    }
  ],
}

 
ただ、手元では20分ほど待つと環境構築が終わり、Cloudflare上で Service Bindings RPC で Worker からデータを取得・表示できるようになりました。

また、一度解消してしまえば、再発はしないようでした。

 

Githubと連携して、Pagesをデプロイする

Cloudflare Pagesは、Wrangler以外にも、Githubと連携してデプロイすることができます。
Git integration · Cloudflare Pages docs

そこで、Githubと連携したときのデプロイ方法を確認してみます。

 

Pagesの wrangler.toml に設定を追加

今回のGithubからのデプロイでも Bun を使います。

ただ、今のままではデプロイ時の Bun のバージョンはデフォルトのままになってしまいます(2024/6/15時点では、1.0.1)。
Language support and tools · Cloudflare Pages docs

デプロイログにはこんな感じで出力されており、 Bun 1.0.1 が使われていることが分かります。

2024-06-15T02:56:03.647712Z  Detected the following tools from environment: [email protected], [email protected]
2024-06-15T02:56:03.648272Z Installing project dependencies: bun install --frozen-lockfile
2024-06-15T02:56:03.913028Z bun install v1.0.1 (31aec4eb)

 
そこで、Pages の wrangler.toml に環境変数を設定し、指定した Bun のバージョンを使ってデプロイするようにします。

[vars]
BUN_VERSION = "1.1.12"

 

Cloudflare と Github を連携する

続いて、Cloudflareのドキュメントに従い、CloudflareからGithubへアクセスできるようにします。
Git integration guide · Cloudflare Pages docs

今回は、必要最低限のリポジトリのみアクセス可能な設定としました。

 

Cloudflare dashboard からデプロイ

最後に、上記のGit integration guideに従って Cloudflare dashboard を操作し、 Deploy a site from your account まで移動します。

その後は、以下の内容を選択・入力し、 Save and Deploy ボタンをクリックします。

項目 値
Github account thinkAmi-sandbox
Select a repository cf_service_binding_rpc-example
Project name cf-service-binding-rpc-example (デフォルトのまま)
Production branch main
Build Settings - Framework preset None
Build Settings - Build command bun install && bun run build
Build Settings - Build output directory dist
Build Settings - Root directory - Path packages/app-page
Build Settings - Environment variables (なし)

 
するとビルドが進行し、デプロイまで正常に完了しました。

Connect to project ボタンを押した後、数分待ち、アプリが展開されるのを待ちます。
(展開されるまでは「このサイトにアクセスできません」な旨が表示されます)

 

動作確認

アプリの展開が完了後、 Visit site をクリックして Pages で作ったサイトへ遷移すると、 Hello my worker! が表示されました。

 

セキュリティ面から Named entrypoint を使うように修正する

ここまで、RPCで使う Worker のentrypointは、default export したクラスでした。

ただ、Cloudflareのブログによると、default export した entrypoint ではなく、 named export して named entrypoint を使ったほうがセキュリティが向上するようです。

n the past, service bindings would bind to the "default" entrypoint, declared with export default {. But, the default entrypoint is also typically exposed to the Internet, e.g. automatically mapped to a hostname under workers.dev (unless you explicitly turn that off). It can be tricky to safely assume that requests arriving at this entrypoint are in any way trusted.

 

With named entrypoints, this all changes. A named entrypoint is only accessible to Workers which have explicitly declared a binding to it. By default, only Workers on your own account can declare such bindings. Moreover, the binding must be declared at deploy time; a Worker cannot create new service bindings at runtime.

 
We've added JavaScript-native RPC to Cloudflare Workers

 
そこで、Named entrypoint を使うよう、Workers と Pages を修正します。

 

Workers の修正

WorkerEntrypoint を継承したクラス MyWorkerEntrypoint は named export するように修正します。

ただ、Workers として動かすためには、 fetch handlerは default export に含む必要があります。

そこで、fetch を MyWorkerEntrypoint のメソッドではなく default export の方へと移動しました。

なお、結果を分かりやすくするよう、hello メソッドの戻り値も Hello my named export worker! へと変更しています。

import { WorkerEntrypoint } from 'cloudflare:workers'

export class MyWorkerEntrypoint extends WorkerEntrypoint {
  async hello() {
    // biome-ignore lint: debug
    console.log('called worker!')
    return 'Hello my named export worker!'
  }
}

export default {
  async fetch() {
    return new Response('Hello Worker World!')
  },
}

 

Pages の修正

Pages では wrangler.toml に entrypoint を追加するよう修正します。

[[services]]
binding = "MY_WORKER"
service = "app-worker"

# Workersでexportされたクラス名を設定
entrypoint = "MyWorkerEntrypoint"  # 追加

 

動作確認

修正が終わったので動作確認します。

まずはローカルでの確認です。

ルートディレクトリで bun run dev した後、 http://localhost:5173/ を表示すると、 Workers からの戻り値が差し替わっていました。

named export での動作になっているようです。

 
続いて、WorkersとPagesをデプロイし、Cloudflare上で動作を確認してみます。

こちらも Workers からの戻り値が差し替わり、named export での動作になっていました。

 
以上より、 Service Bindings RPC を使った Pages と Workers の連携を一通り確認できました。

 

今回の記事を作るにあたり悩んだこと

ここまで書いてきた内容以外にも、いくつか悩んだところがあったため、メモとして残しておきます。

 

Service Bindings RPC 関係

Workersで使う WorkerEntryPoint クラスにはハンドラの実装が必要

Workers には以下のようなハンドラがあります。
Handlers · Cloudflare Workers docs

そのため、RPC向けのメソッドしか使わないWorkersであっても、WorkerEntryPoint クラスには何かしらのハンドラの実装が必要なようです。

もしハンドラを実装しない場合、ローカルでは正常に動作します。

一方、Cloudflare上へデプロイするときにはエラーが発生します。

$ bun run deploy

...
Total Upload: 0.21 KiB / gzip: 0.18 KiB

✘ [ERROR] A request to the Cloudflare API (/accounts/***) failed.

  The uploaded script has no registered event handlers. [code: 10068]
  
  If you think this is a bug, please open an issue at:
  https://github.com/cloudflare/workers-sdk/issues/new/choose


error: script "deploy" exited with code 1

 
そのため、今回の記事の WorkerEntryPoint では、以下のコミュニティページでも書かれているように、お手軽な fetch ハンドラを実装しました。
No event handlers were registered. This script does nothing - Developers / Workers - Cloudflare Community

 

Cloudflare dashboard 上では、Pages の Service Bindings が使えないような表示になる

Cloudflare dashboard 上で Pages の Settings > Functions とたどると、Service bindings があります。

ただ、現時点ではスクリーンショットのように

This Worker no longer exists and can not be used. Please try using a different Worker.

という表示になっています。

 
最初この表示を見た時は「Service Binding RPC の設定がうまくいっていないのかな...」と悩みました。

ただ、

  • 実際には Service Bindings が使えていること
  • Cloudflareのdiscordで探したところ、2024/5/9時点で「表示バグであり、実際には使える」と書かれていたこと

から、自分の環境も表示バグなだけと判断しました。

 

Cloudflare dashboard + Github での Pages デプロイ関係

デプロイ時の Environment variables で Bun のバージョンを指定しても反映されない

デプロイ時の設定として Environment variables (advance) があります。

そのため、 wrangler.toml ではなく、ここに BUN_VERSION に 1.1.12 を指定しても良さそうです。

しかし、

  • Environment variables に BUN_VERSION を指定
  • wrangler.toml には BUN VERSION を指定しない

という場合は、デフォルトのBunのバージョンが利用されます。

デプロイログの途中には Build environment variables: (none found) と出ているの気になりますが。。

2024-06-15T02:57:57.922697Z  Cloning repository...
...
2024-06-15T02:58:00.466151Z Found wrangler.toml file. Reading build configuration...
2024-06-15T02:58:00.471401Z pages_build_output_dir: dist
2024-06-15T02:58:00.471716Z Build environment variables: (none found)
2024-06-15T02:58:00.577156Z Successfully read wrangler.toml file.
2024-06-15T02:58:00.737961Z Detected the following tools from environment: [email protected], [email protected]
2024-06-15T02:58:00.738593Z Installing project dependencies: bun install --frozen-lockfile
2024-06-15T02:58:00.995652Z bun install v1.0.1 (31aec4eb)
...

 

Bun workspace を使っている場合、Build command には bun install も含めた方が良さそう

上記の説明では bun install && bun run build を指定していました。

ただ、元々は bun run build を使おうとしました。

しかし、 Bun の workspace を使っていることもあり、必要なパッケージが存在していない状態だとデプロイエラーになってしまいます。

例えば、以下の例では vite が見当たらないためにデプロイエラーになりました。

2024-06-15T02:15:35.790971Z  Cloning repository...
...
2024-06-15T02:15:39.536526Z Detected the following tools from environment: [email protected], [email protected]
2024-06-15T02:15:39.537111Z Installing bun 1.1.12
2024-06-15T02:15:39.67107Z  Downloading Bun v1.1.12...
2024-06-15T02:15:40.784889Z Archive:  /tmp/asdf-bun.nxn8/bun.zip
2024-06-15T02:15:41.511631Z   inflating: /opt/buildhome/.asdf/downloads/bun/1.1.12/bun  
2024-06-15T02:15:41.575948Z Installing Bun v1.1.12...
2024-06-15T02:15:41.669111Z Bun v1.1.12 is installed successfully!
2024-06-15T02:15:42.151087Z Executing user command: bun run build
2024-06-15T02:15:42.408963Z $ vite build
2024-06-15T02:15:42.412208Z /usr/bin/bash: line 1: vite: command not found
2024-06-15T02:15:42.412421Z error: script "build" exited with code 127
2024-06-15T02:15:42.414061Z Failed: Error while executing user command. Exited with error code: 127
2024-06-15T02:15:42.423082Z Failed: build command exited with code: 1
2024-06-15T02:15:43.330076Z Failed: error occurred while running build command

 
そこで、 Build commandの先頭で bun install を実行することにより、 bun.lockb などを生成してパッケージ不足にならないようにしています。

bun install が必要なことには以下の記事を見て気づきました。記事ではpnpmを使っていますが、Bunでも同じことが起きました。
Cloudflare Pagesでpnpmを使ってデプロイする

 

Cloudflare dashboard + Github でデプロイできるのは、2024/6時点では Pages だけ

Cloudflare dashboard の表示を見ると、Pagesタブにしか Github 連携できる表示がありません。

 
また、monorepo のページも Pages のドキュメントにしかありません。
Monorepos · Cloudflare Pages docs

ただ、 Workers のディレクトリを指定したらどうなるのか気になりました。

 
そこで、 app-worker のディレクトリを指定して Cloudflare dashボードからデプロイしてみました。

すると、途中で色々Skipはされたものの、Workerのソースコードのデプロイ自体は成功しました。

2024-06-15T02:05:07.299536Z  Cloning repository...
...
2024-06-15T02:05:09.881621Z Found wrangler.toml file. Reading build configuration...
2024-06-15T02:05:09.980638Z A wrangler.toml file was found but it does not appear to be valid. Did you mean to use wrangler.toml to configure Pages? If so, then make sure the file is valid and contains the `pages_build_output_dir` property. Skipping file and continuing.
2024-06-15T02:05:10.060341Z No build command specified. Skipping build step.
2024-06-15T02:05:10.061276Z Note: No functions dir at /functions found. Skipping.
2024-06-15T02:05:10.061419Z Validating asset output directory
...
2024-06-15T02:05:19.424876Z Success: Your site was deployed!

 
しかし、デプロイ後のdashboardを見ると Pages として認識されているようでした。

 
当然、この状態では Workers として正常に動作していません。

ということで、2024/6時点では Workers は Wrangler でデプロイするのが良さそうでした。

 
ちなみに、Cloudflareのアナウンスによると、2024年の後半にはWorkersでもGithubからデプロイできるようになるかもしれません。

While today’s launch represents just a few of the many upcoming additions to converge Pages and Workers, we also wanted to share a few milestones that are on the horizon, planned later in 2024

...

Workers CI/CD. Later this year, we plan to bring the CI/CD system from Cloudflare Pages to Cloudflare Workers. Connect your repositories to Cloudflare and trigger builds for your Workers with every commit.

 

https://blog.cloudflare.com/pages-workers-integrations-monorepos-nextjs-wrangler

 

Cloudflare 関係

Pages のリクエストログは Deployment details の Real-time Logs で見れる

Pagesのビルドログの確認方法については、以下のページにありました。
Debugging Pages · Cloudflare Pages docs

一方、リクエストログはどこで見れるのか、ドキュメントを見つけられませんでした。ただ、機能としては存在しているので、忘れたとき用にメモを残します。

Pages の Deployments タブの 各デプロイの View details をクリックし、Deployment details へ移動します。

その中の Functions タブの下の方に Real-time Logs があり、そこでリクエストログを確認できます。

ただ、自動的には表示できないため、右側にある Begin log stream ボタンをクリックすることで、ログが流れてくるようになります。

 

wrangler.toml の Compatibility dates や flags について

Compatibility dates と Compatibility flags は、公式ドキュメントの以下のページに記載があります。
Compatibility dates · Cloudflare Workers docs

Change history もあり、どこでどんな変更が入ったのかわかるようになっています。

 

wrangler.toml のドキュメントのありか

Workers と Pages 、それぞれにドキュメントがあります。

 

Bun関係

現時点でのbun.lockb のバージョン管理について

Bunの場合lockfileは bun.lockb というバイナリファイルになります。
Lockfile – Package manager | Bun Docs

テキストベースの lockfile のissueもあるようですが、今はまだOpenしたままになっています。
Implement a text-based lockfile format · Issue #11863 · oven-sh/bun

JetBrainsの場合、YouTrackにもissueはありますが、こちらもOpenのままになっています。
Display human-readable info when opening bun lock file : WEB-67455

そのため、現時点では以下の記事のようにして管理するようです。
bun.lockbのVersion管理をGitでどうやる?問題

 

ソースコード

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