バッチ処理などで大量のレコードを処理するときはカーソルベースページネーションを使う
バッチ処理などで大量のレコードを処理するときは、少しづつ取得して処理するためにページネーションを用いるのが一般的だが、このときオフセットベースページネーション(一般的なページネーション)を使ってはいけない。
たとえば以下のように有効期限の切れたファイルの情報を格納するレコードを削除するときを考える。
i = 0 n = 100 now = Time.zone.now loop do records = UploadedFile.where('expired_at <= ?', now) records = records.limit(n).offset(n * i) records = records.to_a break if records.empty? records.each do |record| record.file.delete # S3等にアップロードしてあるファイルを削除する record.destroy # レコードの削除 end i += 1 end
ページネーションにはオフセットベースページネーションを使用しているが、レコードを削除しているため次回ループ時にズレが生じる。
これを防ぐためには、カーソルベースページネーションを用いる。カーソルベースページネーションは以下のようなものである。
- 一意かつソート可能な識別子をカーソルとする
- カーソルで昇順(降順)ソート
- 指定したカーソルより大きい(小さい)もののみをN件抽出
ページネーションにオフセットを用いないため、レコードの削除を行なってもズレが生じない。
cursor = nil n = 100 now = Time.zone.now loop do records = UploadedFile.where('expired_at <= ?', now) records = records.limit(n).sort(id: :asc) # 一意かつソート可能な識別子でソート(ここではauto incrementを用いたID) records = records.where('id > ?', cursor) if cursor.present? # 現在のカーソルより大きいもののみに絞る records = records.to_a break if records.empty? records.each do |record| record.file.delete # S3等にアップロードしてあるファイルを削除する record.destroy # レコードの削除 end cursor = records.last.id # カーソルを更新 end
なお、ActiveRecord の場合は find_in_batches
などがこのカーソルベースページネーションを使うので、ActiveRecord を使っている場合はこれを用いるのがよい。
このように、カーソルベースページネーションは一般的にはSNS等のタイムラインなどに使われることが多いが、ページネーション中に対象レコードの増減が起きても歯抜け・重複が起きないという特性がバッチ処理でも有効となる。
RubyのRegexp.escapeを使って簡易的なワイルドカードによるマッチングをする
例えばログ出力をテストしたい時があり、時刻などは無視したい場合があるが、これを正規表現を使ってマッチングしようとすると正規表現において特別な意味を持つ文字のエスケープが大変で見にくいのでワイルドカードのような簡易的な表現にしたい。
actual = <<~EOS [INFO] [2024-07-04T10:00:00Z] message1 [ERROR] [2024-07-04T10:00:01Z] #<StandardError: error> /path/to/file1:10:in `initialize' /path/to/file2:3:in `hoge' /path/to/file3:5:in `foo' [INFO] [2024-07-04T10:00:02Z] message3 EOS expected = Regexp.new(<<~EOS, Regexp::MULTILINE) \\[INFO\\] \\[.+\\] message1 \\[ERROR\\] \\[.+\\] #<StandardError: error> .+ \\[INFO\\] \\[.+\\] message3 EOS expect(actual).to match(expected)
ここで Ruby には Regexp.escape
1 という正規表現において特別な意味を持つ文字をエスケープしてくれるメソッドがあり、これを使うとエスケープしなくて済む。そしてエスケープするときに自身で定義したワイルドカード文字列(ここでは%
がワイルドカードとする)を正規表現に置き換えればワイルドカードによる簡易的なマッチングできるようなる。
actual = <<~EOS [INFO] [2024-07-04T10:00:00Z] message1 [ERROR] [2024-07-04T10:00:01Z] #<StandardError: error> /path/to/file1:10:in `initialize' /path/to/file2:3:in `hoge' /path/to/file3:5:in `foo' [INFO] [2024-07-04T10:00:02Z] message3 EOS expected = Regexp.new(Regexp.escape(<<~EOS).gsub('%','.+'), Regexp::MULTILINE) [INFO] [%] message1 [ERROR] [%] #<StandardError: error> % [INFO] [%] message3 EOS expect(actual).to match(expected)
環境
- Ruby: 3.3.0
TypeScriptではリテラルの型指定にはキャストではなくsatisfiese演算子を使う
よく以下のように、キャスト{} as T
を使ってリテラルに型指定をしているコードを見る。
type User = { id: string; name: string; }; const value1 = { user: {} as User, };
しかし、このコードは型安全ではなく、存在しないプロパティがある場合や必要なプロパティが不足している場合であってもエラーにならない。そのため嘘の型情報を付与してしまい、実質的に型安全でなくなる。
これを防ぐために以前はliteral
という簡単なヘルパー関数を作って対応していた。
type User = { id: string; name: string; }; function literal<T>(value: T): T { return value; } const value2 = { user: literal<User>({ id: '1', name: 'name 2', }), };
このようにすれば型安全になり、嘘の型情報を付与することがなくなる。
しかし、TypeScript 4.9 から satisfies
演算子というものが導入されたので、これを使うと上記と同様のことができるようになった。
type User = { id: string; name: string; }; const value3 = { user: { id: '1', name: 'name 3', } satisfies User, };
そのため、TypeScript 4.9 以上を使用しているのであれば、リテラルへの型指定にはsatisfies
演算子を使うとよい。
参考:
React Select を使ったコンポーネントのテストをする
react-select
という複数選択やoptionにサムネイルを追加したりできる高機能なselect
を提供するライブラリがある。
OptionSelector.tsx:
import { useCallback } from 'react'; import Select from 'react-select'; export type Option = { value: string; label: string; }; export const options: Option[] = [ { value: '1', label: 'Option 1' }, { value: '2', label: 'Option 2' }, { value: '3', label: 'Option 3' }, ]; type Props = { value: Option | undefined; onChange: (option: Option | undefined) => void; }; export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback((option: Option | null) => { onChange(option || undefined); }, [onChange]); return ( <div data-testid="OptionSelector"> <Select value={value} options={options} onChange={onChangeSelect} /> </div> ); };
使う分にはいいが、このライブラリを使っているコンポーネントのテストをする時は少々やっかいで、たとえば、特定のオプションを選択してから何かをするようなテストを書く場合などである。
__tests__/OptionSelector.test.tsx:
import { render, screen } from '@testing-library/react'; import OptionSelector, { Option, options } from '../OptionSelector'; describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { let option: Option | undefined = undefined; const onChange = jest.fn((newOption: Option | undefined) => { option = newOption; })); render(<OptionSelector value={option} onChange={onChange} />); // なんらかの値を選択したい expect(onChange).toHaveBeenCalledTimes(1); expect(option).toBe(options[1]); }); });
このような場合は、モックに置き換えるのが一般的だが、react-select-event
というライブラリを使うとモックに置き換えることなく任意の値を選択することができる。
import React from 'react' import Select from 'react-select' import {render} from '@testing-library/react' import selectEvent from 'react-select-event' const {getByTestId, getByLabelText} = render( <form data-testid="form"> <label htmlFor="food">Food</label> <Select options={OPTIONS} name="food" inputId="food" isMulti /> </form>, ) expect(getByTestId('form')).toHaveFormValues({food: ''}) // empty select // select two values... await selectEvent.select(getByLabelText('Food'), ['Strawberry', 'Mango']) expect(getByTestId('form')).toHaveFormValues({food: ['strawberry', 'mango']}) // ...and add a third one await selectEvent.select(getByLabelText('Food'), 'Chocolate') expect(getByTestId('form')).toHaveFormValues({ food: ['strawberry', 'mango', 'chocolate'], })
上記のサンプルコードによると selectEvent.select(<react-select内部のinput要素>, <選択するオプション>)
というAPIを使用することで選択できる模様。
ここで、react-select内部のinput要素
は、react-select
の inputId
を指定すると任意のIDを付与できるので、label
要素のfor
属性と同じ値を指定することで紐づけられる。そのため、getByLabelText(<labelに指定している文字列>)
で取得できるようになる。
これにより、以下のようにテストが書けるようになる。
OptionSelector.tsx:
export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback<OnChangeSelect>((option) => { onChange(option || undefined); }, [onChange]); return ( <div data-testid="OptionSelector"> <label htmlFor="option_selector">option:</label> <Select value={value} options={options} onChange={onChangeSelect} inputId="option_selector" /> </div> ); };
__tests__/OptionSelector.test.tsx:
import { render, screen } from '@testing-library/react'; import selectEvent from 'react-select-event'; import OptionSelector, { Option, options } from '../OptionSelector'; describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { let option: Option | undefined = undefined; const onChange = jest.fn((newOption: Option | undefined) => { option = newOption; })); render(<OptionSelector value={option} onChange={onChange} />); await selectEvent.select(screen.getByLabelText('option:'), [options[1].label]); expect(onChange).toHaveBeenCalledTimes(1); expect(option).toBe(options[1]); }); });
ここで、以下にあげるように、いくつかの問題がある。
- IDを指定しているのでこのコンポーネントを一つの画面で複数使用している場合うまくいかない
- label要素がない場合は自分でコンポーネント内部のIDをもとに
document.getElementById()
などで取得する必要がある
まず 1.
の問題についてだが、ジェネレーターなどを使ってコンポーネント生成毎に異なるIDを生成することで解決できる。
OptionSelector.tsx:
function* inputIdGenerator(): Generator<string, string, string> { let id = 1; while (true) { yield `option_selector_${id++}`; } } const inputIdIterator = inputIdGenerator(); export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback<OnChangeSelect>((option) => { onChange(option || undefined); }, [onChange]); const inputId = useMemo(() => inputIdIterator.next().value, []); return ( <div data-testid="OptionSelector"> <label htmlFor={inputId}>option:</label> <Select value={value} options={options} onChange={onChangeSelect} inputId={inputId} /> </div> ); };
そして次に 2.
の問題についてだが、コンポーネントに data-inputid
属性を追加してここにIDを指定して、テスト時に参照することで解決できる。
OptionSelector.tsx:
export default function OptionSelector({ value, onChange } : Props) { const onChangeSelect = useCallback<OnChangeSelect>((option) => { onChange(option || undefined); }, [onChange]); const inputId = useMemo(() => inputIdIterator.next().value, []); return ( <div data-testid="OptionSelector" data-inputid={inputId}> <label htmlFor={inputId}>option:</label> <Select value={value} options={options} onChange={onChangeSelect} inputId={inputId} /> </div> ); };
__tests__/OptionSelector.test.tsx:
describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { ... await selectEvent.select( document.getElementById(screen.getByTestId('OptionSelector').dataset.inputid!)!, [options[1].label] ); ... }); });
さらにヘルパー関数を作成しておくとよい。
OptionSelector.tsx:
export function getReactSelectInputElement(el: HTMLElement | undefined | null): HTMLInputElement { if (!el) throw new Error('Element is null'); if (el.dataset.testid !== 'OptionSelector') throw new Error('Element is not OptionSelector'); if (typeof el.dataset.inputid === 'undefined') throw new Error('InputId is empty'); const input = el.querySelector<HTMLInputElement>(`#${el.dataset.inputid}`); if (!input) throw new Error('input element not found'); return input; }
__tests__/OptionSelector.test.tsx:
describe('<OptionSelector />', () => { it('calls onChange() prop with selected value', async () => { ... await selectEvent.select( getReactSelectInputElement(screen.getByTestId('OptionSelector')), [options[1].label] ); ... }); });
以上で、react-select
を使ったコンポーネントもテストできるようになった。また、上記の方針で実装したサンプルコードは sandbox/ts-react-select にある。
環境:
- React:
18.2.0
- react-select:
5.8.0
- react-select-event:
5.5.1
Docker 25.0 でヘルスチェックに start-interval オプションが追加された
Docker にはヘルスチェック機能があるが、25.0
で start-interval
オプションが追加された。
The options that can appear before CMD are:
- --interval=DURATION (default: 30s)
- --timeout=DURATION (default: 30s)
- --start-period=DURATION (default: 0s)
- --start-interval=DURATION (default: 5s)
- --retries=N (default: 3)
...
start interval is the time between health checks during the start period. This option requires Docker Engine version 25.0 or later.
いままでは、以下のように interval
間隔でヘルスチェックがされていた。
このとき start-period
を指定していると、コンテナ開始から指定した時間が経過するまでは失敗しても無視されるという動作をする。
|<------- start-period(10s) --------->| |<- interval(5s) ->|<- interval(5s) ->|<- interval(5s) ->|<- interval(5s) ->| | +------------------+------------------+------------------+------------------+ ^ ^ ^ | | | 失敗 失敗 成功
start-interval
を指定すると、start-period
の時のヘルスチェック間隔を設定できる。使い道としては、Docker Compose で depends_on
で service_healthy
を指定する時に起動が完了するまで待ち、立ち上がったら素早くhealthy
状態に移行できるように高頻度でヘルスチェックしつつ、通常のヘルスチェックを頻繁にしなくするということができる。
なお、start-period
期間内でヘルスチェックを通ると指定した時間が経過していなくても、通常のヘルスチェックに移行する。
- SI: start-interval |<-------- start-period(3s) ---------->| |<- SI(1s) ->|<- SI(1s) ->|<- SI(1s) ->|<---------------- interval(10s) ---------------->| | +------------+------------+------------+-------------------------------------------------+ ^ ^ ^ ^ | | | | 失敗 失敗 失敗 成功
Docker Compose では compose.yaml
で以下のように指定できる。
compose.yaml:
services: app: ... depends_on: db: condition: service_healthy db: image: mysql:8 volumes: - type: volume source: mysql_data target: /var/lib/mysql ports: - target: 3306 published: 3306 environment: MYSQL_ALLOW_EMPTY_PASSWORD: 1 healthcheck: test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] start_period: 10s start_interval: 1s interval: 600s timeout: 30s retries: 0 volumes: mysql_data:
RubyのENV.fetch()で空文字列の時もデフォルト値を使うようにする
Rubyには、指定された環境変数の値を読み込み、存在しなければデフォルト値を返すENV.fetch()
というメソッドがある。Railsなどでは設定ファイルで環境変数から値を読み込むのによく使う。
config/database.yml:
default: &default pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
しかし、たとえば指定された環境変数が空文字列だったりすると、デフォルト値ではなく空文字列が返されてしまう。
HOGE='' ruby -e 'puts ENV.fetch("HOGE"){1}' #=>
これは direnv の .envrc
や Docker Compose の compose.yaml
で環境変数を指定していたりすると起きがちである。
.envrc:
export HOGE=
compose.yaml:
services: app: environment: HOGE: ${HOGE}
なので、空文字列の時もデフォルト値が参照されるようにENV
をモンキーパッチして、新たにENV.fetch2()
というENV.fetch()
とほぼ同様の動作をするメソッドを実装する。
lib/env.rb:
class << ENV # 基本的な動作は`ENV.fetch()`と同様だが、空文字列のときもデフォルト値を返すようにする def fetch2(*args, &block) key, default = args value = fetch(*args, &block) return value if value != '' raise KeyError, format('key not found: "%s"', key) if args.size == 1 && !block warn('block supersedes default value argument', uplevel: 1) if args.size == 2 && block block ? yield(key) : default end end
HOGE='' ruby -r ./lib/env.rb -e 'puts ENV.fetch2("HOGE"){1}' #=> 1
これで問題を解決できた。なお、空文字列の時はデフォルト値ではなく空文字列が返ってきてほしい時があるので、ENV.fetch()
をオーバーライドするのはやめた方がよい。
Gist: https://gist.github.com/mrk21/3c6f29aa91659dcaf45eefba11f866a3
環境
- Ruby: 3.2.2
年齢の算出
年齢の算出は簡単にできるかと思いきや、少々面倒臭い。これは、閏年というものが存在するためである。そのため以下のような単純な時間のdiffで算出しようとすると年を重ねる日時が1日ほどズレる場合がある。
const today = new Date(2010, 2, 10); // 2010-03-10 const birthday = new Date(2000, 2, 10); // 2000-03-10 const diff = today.getTime() - birthday.getTime(); const age = Math.floor(diff / (1000*3600*24*365.25)); console.log(age); // 9
また、誕生日が閏日(02-29
)の人は、閏年以外は うるう年をめぐる法令|参議院法制局 によると、03-01
が誕生日となるのでこれも考慮すると以下のようになる。だいたいの日付オブジェクトでは閏年以外で 02-29
を指定すると 03-01
と解釈されるので、閏日の時に条件分けする必要はない。
// const today = new Date(2010, 1, 29); // 2010-03-01, 閏年以外で 02-29 を指定すると 03-01 と解釈される const today = new Date(2010, 2, 10); // 2010-03-10 const birthday = new Date(2000, 2, 10); // 2000-03-10 let age = today.getFullYear() - birthday.getFullYear(); if (today.getMonth() < birthday.getMonth()) { age--; } else if (today.getMonth() === birthday.getMonth() && today.getDate() < birthday.getDate()) { age--; } console.log(age); // 10
ここで、上記で参照した うるう年をめぐる法令|参議院法制局 によると道路交通法では閏日の人は 02-28
が誕生日と解釈されるので道路交通法関連で年齢を算出するときは注意が必要となる。また日本国外では別のルールに基づく可能性があるので合わせて注意が必要となる。