Qushot ブログ

雑多なことを気が向いたときに書きます

Go 言語で Debounce / Throttle を実装してみた

最近この概念を知り、意外とシンプルに実装できることが分かったため記録として。

Debounce / Throttle とは

※生成AIに聞いたほうが分かりやすい説明をしてくれそう

どちらも、複数回呼び出された処理に対して、一定の間隔を開けながら実行する仕組みである。

Debounce は複数回の呼び出しに対して「最後に呼び出されてから、任意の時間経過後に最後の呼び出しを実行する」もの。 時間経過前に別の呼び出しが行われた場合は、実行せずに捨てられる。

Throttle は複数回の呼び出しに対して「任意の時間間隔を必ず空けて実行する」もの。 間隔を空けずに呼び出しが行われた場合は、実行せずに捨てられる。

実装

今回は、呼び出した処理が fmt.Printf を実行するだけなので、かなりシンプルな実装となっている。

Debounce

time.AfterFunctimer.Stop を用いることで、簡単に実装できた。 timer.Stop ではなく timer.Reset を使ったほうが良かった気はする。

package main

import (
    "fmt"
    "math/rand/v2"
    "sync"
    "time"
)

type Task func()
type DebounceFunc func(f Task)

func Debounce(duration time.Duration) DebounceFunc {
    var mu sync.Mutex
    var timer *time.Timer

    return func(f Task) {
        mu.Lock()
        defer mu.Unlock()

        if timer != nil {
            // POINT: 追加で呼び出しされた場合、既存の実行予約を取り消す
            timer.Stop()
        }

        // POINT: 指定時間経過後に実行したい処理を予約しておく
        timer = time.AfterFunc(duration, f)
    }
}

func newTask(i int) Task {
    return func() {
        fmt.Printf("  [%d回目] 実行\n", i)
    }
}

func main() {
    debounceDuration := 500 * time.Millisecond
    debounced := Debounce(debounceDuration)

    fmt.Printf("デバウンス時間は %v です\n", debounceDuration)

    for i := range 10 {
        ms := rand.IntN(501) + 100
        sleepTime := time.Duration(ms) * time.Millisecond
        fmt.Printf("[%d回目] 次の呼び出しまで %v 待機\n", i, sleepTime)

        debounced(newTask(i))
        time.Sleep(sleepTime)
    }

    fmt.Println("最後の呼び出しを確認するために1秒待機")
    time.Sleep(1 * time.Second)
}

実行結果: 500 ms 以上の間隔を空けて呼び出された処理のみが実行されている

$ go run ./debounce
デバウンス時間は 500ms です
[0回目] 次の呼び出しまで 591ms 待機
  [0回目] 実行
[1回目] 次の呼び出しまで 491ms 待機
[2回目] 次の呼び出しまで 203ms 待機
[3回目] 次の呼び出しまで 547ms 待機
  [3回目] 実行
[4回目] 次の呼び出しまで 471ms 待機
[5回目] 次の呼び出しまで 462ms 待機
[6回目] 次の呼び出しまで 197ms 待機
[7回目] 次の呼び出しまで 238ms 待機
[8回目] 次の呼び出しまで 565ms 待機
  [8回目] 実行
[9回目] 次の呼び出しまで 312ms 待機
最後の呼び出しを確認するために1秒待機
  [9回目] 実行

Throttle

こちらは更にシンプルで、最後に実行した時刻を記録しておき、指定以上の時間が経過していれば実行する。

package main

import (
    "fmt"
    "math/rand/v2"
    "sync"
    "time"
)

type Task func()
type ThrottleFunc func(f Task)

func Throttle(duration time.Duration) ThrottleFunc {
    var mu sync.Mutex
    var lastRun time.Time

    return func(f Task) {
        mu.Lock()
        defer mu.Unlock()

        // POINT: 最終実行からの経過時間を確認
        if time.Since(lastRun) >= duration {
            f()
            lastRun = time.Now()
        }
    }
}

func newTask(i int) Task {
    return func() {
        fmt.Printf("  [%d回目] 実行\n", i)
    }
}

func main() {
    throttleDuration := 500 * time.Millisecond
    throttle := Throttle(throttleDuration)

    fmt.Printf("スロットル時間は %v です\n", throttleDuration)

    totalSleepTime := time.Duration(0)
    for i := range 10 {
        ms := rand.IntN(251) + 100
        sleepTime := time.Duration(ms) * time.Millisecond
        fmt.Printf("[%d回目] %v 経過\n", i, totalSleepTime)

        throttle(newTask(i))
        totalSleepTime += sleepTime
        time.Sleep(sleepTime)
    }

    fmt.Println("最後の呼び出しを確認するために0.5秒待機")
    time.Sleep(throttleDuration)

    throttle(func() {
        fmt.Println("  実行")
    })
}

実行結果: 前回の実行から最低 500 ms 以上の間隔を空けて実行されている

$ go run ./throttle
スロットル時間は 500ms です
[0回目] 0s 経過
  [0回目] 実行
[1回目] 114ms 経過
[2回目] 394ms 経過
[3回目] 503ms 経過
  [3回目] 実行
[4回目] 699ms 経過
[5回目] 886ms 経過
[6回目] 1.118s 経過
  [6回目] 実行
[7回目] 1.223s 経過
[8回目] 1.44s 経過
[9回目] 1.783s 経過
  [9回目] 実行
最後の呼び出しを確認するために0.5秒待機
  実行

まとめ

初めてこの概念を知ったときは、実装がとても難しいのではないかと思ったが、最終的にシンプルなものになった。 今回は戻り値もないような単純な処理だったので尚更簡単なものに仕上がった感はあるが、もし今後実装することになったとしても臆することは無くなりそうなのでよかった。 また、改めてGoの標準パッケージは機能が豊富だと感じた。

Debounce/Throttle の概念を教えてくれた同僚に感謝!!

Cloud Scheduler での定期実行ごとに次回のリクエスト内容を変更する

Google Cloud のリソースを整理していたところ、 2020年12月ごろに遊びで構築したものを見つけたので、ここに供養しておきます。

概要

定期実行の処理内で「Cloud Scheduler の設定を更新する処理」を実装してあげればよいです。

実装サンプル

今回は Cloud Scheduler -> Cloud Pub/Sub -> Cloud Run functions (旧 Cloud Functions)という構成です。 重要なのは Cloud Run functions 内での実装内容なので、 Cloud Scheduler のターゲットタイプが Cloud Pub/Sub かどうかは関係なく、 HTTP でも問題ないです。 また、Cloud Scheduler や Cloud Pub/Sub の設定値に関しても重要ではないため、今回は詳しい設定方法やコマンドについては割愛します。

「定期実行ごとにFizzBuzzを実行する関数」を例とし、サンプルコードを以下に示します。

なお、ソースコード中の NOTE コメントに記載したことが全てですが、ざっくり以下の処理を実装するだけです。

  • リクエストされた内容に基づく何かしらの処理
  • 次回の実行時にリクエストしたい内容を定義
  • 呼び出し元 Scheduler Job の取得、リクエスト内容のセット、更新
package sample

import (
    "context"
    "encoding/json"
    "log"

    scheduler "cloud.google.com/go/scheduler/apiv1"
    schedulerpb "google.golang.org/genproto/googleapis/cloud/scheduler/v1"
)

type PubSubMessage struct {
    Data []byte `json:"data"`
}

type Payload struct {
    Number int `json:"number"`
}

func SampleFunc(ctx context.Context, m PubSubMessage) error {
    var payload Payload
    if err := json.Unmarshal(m.Data, &payload); err != nil {
        log.Printf("json unmarshal error: %v", err)
        return err
    }

    // NOTE: Scheduler の payload に設定された値を使う処理。 e.g. FizzBuzzの出力
    printFizzBuzz(payload.Number)

    // NOTE: 次回の Scheduler 呼び出し用に payload の値をインクリメント
    nextPayload, err := json.Marshal(&Payload{Number: payload.Number + 1})
    if err != nil {
        log.Printf("json marshal error: %v", err)
        return err
    }

    client, err := scheduler.NewCloudSchedulerClient(ctx)
    if err != nil {
        return err
    }

    // NOTE: 呼び出し元の Scheduler Job を取得
    job, err := client.GetJob(ctx, &schedulerpb.GetJobRequest{Name: "projects/{{YOUR_PROJECT_ID}}/locations/asia-northeast1/jobs/{{YOUR_SCHEDULER_JOB_NAME}}"})
    if err != nil {
        log.Printf("get scheduler job error: %v", err)
        return nil
    }    

    // NOTE: nextPayload を Scheduler Job の PubSub Target に設定
    job.Target = &schedulerpb.Job_PubsubTarget{
        PubsubTarget: &schedulerpb.PubsubTarget{
            TopicName:  "projects/{{YOUR_PROJECT_ID}}/topics/{{YOUR_PUBSUB_TOPIC_NAME}}",
            Data:       nextPayload,
            Attributes: nil,
        },
    }

    // NOTE: 呼び出し元の Scheduler Job の更新
    if _, err := client.UpdateJob(ctx, &schedulerpb.UpdateJobRequest{Job: job}); err != nil {
        log.Printf("update scheduler job error: %v", err)
        return err
    }

    return nil
}

おわりに

実際に使う機会が来るかどうかは分かりませんが、約5年前のリソースを見つけて懐かしい気持ちになったので記事を書きました。 誰かの役に立てば幸いです。

iPhone 17 Pro Max 購入

iPhone 17 Pro Max を購入した。

  • 容量: 512 GB
  • カラー: シルバー

予約開始の 2025-09-12 21:00 から約1時間ほど遅れて予約したため、到着予定日は 2025-10-08 -- 2025-10-16 となっていたのだが、2025-10-04 に発送された。 また、iPhone 17 Pro Max 用の MagSafe 対応クリアケースも購入した。(こちらは発売日に到着していた)

www.apple.com

その他、購入したもの

商品 URL
ガラスフィルム https://www.yodobashi.com/product/100000001009311511/
カメラカバー https://www.yodobashi.com/product/100000001009311519/
スマホリング https://www.ankerjapan.com/products/a25a0

今まで使っていた iPhone 13 は 2021-12-25 に購入していたので、今回も4年弱使っていたことになる。

qushot.hatenablog.com

2025-10-18 更新: カメラカバー及びケースを変更

カメラカバーはクリアケースを外す際に干渉し、ガラス片を撒き散らしながら粉砕された…。 仕方がないので、以下のカメラレンズ保護フィルムを購入したものの、レンズを拭くと若干ずれるのが気になる。

https://www.yodobashi.com/product/100000001009311698/

また、ケースに関しては、クリアケースの場合は下部の端子周りの保護がいまいちで、落とした際に傷か汚れがついてしまった。 そのためテックウーブンケースに変更した。これはこれで吸水してしまうためイマイチだが…。

www.apple.com

レザーケースが復活して欲しいところである。

AG03MK2用のスタンドをダイソー商品で作成した記録

動機

  • デスクが狭く、AG03を普通に置くと若干邪魔だったため、縦置きっぽくしたかった。
  • 仕事用PCと私用PCで頻繁にUSBの差し替えを行うため、端子を視認しやすくするために縦置きっぽくしたかった。

必要なもの

雑な作り方

  1. 「漫画を飾れる棚」の出っ張っている方を上向きにして、「アクリルスタンド(幅3.8cm)」を「アロンアルファ」で貼り付けます。
  2. 「漫画を飾れる棚」の底に「クッションゴム 透明」を貼り付けます。

完成写真はそのうち机が片付いたら貼りたい。

所感

実際にAG03を乗せると、重みでゴム足がいい感じに滑り止めとなり、指一本で電源スイッチを押しても全くズレない。

Windows 10 + US 配列キーボードでの入力切り替え

PowerToys の Keyboard Manager にて「ショートカットの再マップ」を行い、 Alt + Space で英語/日本語を切り替えられるようにした。

Windows 標準の Alt + ` ではバッククォートと Esc キーの押し間違いをわりと頻発していたので、この設定は結構気に入っている。

ついでに「キーの再マップ」から Caps LockCtrl の入れ替えも行った。REALFORCE CONNECT にも「Caps Lock と Ctrl を入れ替えるだけ」の機能はあるが、この機会に PowerToys での一元管理にしたかったため。

以上。

富山旅行

はじめに

今まで旅行と呼べそうなものは修学旅行、卒業旅行、開発合宿くらいしかなく、自分で新幹線や宿の予約をしたことがなかったため、やってみようと思ったのがきっかけ。 有給休暇もなかなか取れずに溜まってきていたし。 目的地を富山にしたのは、富山出身の友人がいることや、同僚が富山の寿司を推していたため、前々から気にはなっていたし、白えびも旬なのでちょうど良さそうだったから。

日程

新幹線や宿を自力で予約して目的地までたどり着き、寿司が食べられればいいなくらいに考えていたので、一泊二日とした。

宿

畳敷きの和室だった。賃貸を探していて和室があるとムカつくが、こういう場所での畳は嬉しい。冷蔵庫にはウェルカムゼリー?が入っていた。 室内に檜風呂があっていい香りがしたが、試しに湯を張ってみるとあまり使われていないのか浮遊物が多かった。まぁ普通は見向きもせずに大浴場に行くだろうし仕方ないとは思った。 備え付けのテレビではYouTubeNetflixを見れないタイプだったのでちょっとがっかりした。iPadを持って行っておいてよかった。

大浴場にはサウナが併設されており、久々に良い汗をかけた。また、大浴場でもサウナでもヒノキの良い香りがして心地よく過ごせた。 最近あまり趣味の銭湯に行けていなかったため、本当に良い時間だった。

食事は朝食の有無を予約時に選ぶことができ、いただく場合は追加料金が発生する仕組みだった。 イメージ写真にまんまと釣られて申し込んでしまったが、朝が弱い自分にとってはプレッシャーになってしまい、寝られなくなったので申し込まなければよかったと後悔した。たら汁は美味しかった。

ちょくちょく文句を挟んでしまってはいるが、大満足だった。また富山に来ることがあったらまた予約してもいいかなと思えた。 ただ、夏休み前になってしまったせいで少し料金が高くなってしまったところは事前に考慮できたはずで失敗だった。(ちなみに翌週はガッツリ夏休み開始で更に高くなるため、そういう意味ではギリギリセーフだったかもしれない)

食事

1日目夜

寿司栄 総曲輪店で富山の味にぎりを注文。苦手なものが無いか聞いてくれる優しさがある。(自分は何でも食べるけど) ラストに蟹と味噌の軍艦、白エビの軍艦が出てくるのだが、「蟹味噌で白エビの味が正しく感じられなくなってしまうから、先に白エビを食べてください」とのことだった。説明を聞いた上でなぜか間違えて蟹軍艦から食べてしまい、もったいないことをした…。にぎりセットに加えて他に食べたいネタや白エビを追加でいただき、税込み7,500円くらいだった。寿司はもちろん、ガリもアガリも美味しかった。満足。 どうでもいいが、COVID-19の影響だと思うが、手洗い用の水は流れていなかった。

2日目朝

宿のバイキング。食べすぎた。

2日目昼

富山ブラックラーメンの元祖、西町大喜 西町本店にて中華そば並のライスセットを食べた。 ライスセットを注文すべしと事前に教えてもらっていたので従ったが、本当にライスを頼んでおいてよかった。 驚くほどしょっぱいため、ラーメンをおかずにご飯を食べる感覚だった。ライスが救いだった。 具体的には麺だけなら単体でもいけるが、チャーシューやネギ、メンマなどの具材(とくにメンマが最高にしょっぱい)を食べるためにライスが必要という感じ。 寝不足だったこともあり、少しきつかったが完食できた。いい経験になった。次は体調が良いときに食べたい。 テーブルにレンゲが置いてないところから、誰もスープを飲まないことが察せられて個人的に少しおもしろかった。

その他

おわりに

最高だった。また行きたい。何なら住んでもいい…と思いかけたが冬は雪かきが大変そうなので、旅行で行くくらいがちょうどよさそう。本当にまた富山に行きたいな。もつ煮込みうどんも食べたいし。

振り返ると今回の失敗点は以下の3点くらいかな?

  • 夏休み直前で少し料金が高かったこと
  • 朝食バイキングを予約したこと
  • 富山から東京に着く時間が退勤ラッシュと丸かぶりしてしまったこと(運良く座れたけど)

それはそれとして、次は飛行機の予約とかをやってみたいので来年辺りにまたどこかに行こうかな。

www.susiei.com www.nisicho-taiki.com www.itoshou.com

Firestore EmulatorとGoで開発する際に指定するプロジェクトIDはdummy-emulator-firestore-projectがいいかも

はじめに

本記事で指している Firestore Emulator とは gcloud components で提供されているエミュレータではなく、Firebase の Local Emulator Suite から提供されている方です。 また、実行環境の環境変数FIRESTORE_EMULATOR_HOST が設定されている前提で話を進めます。

何をすればいいのか

  • Firestore Emulator 起動時に --project=dummy-emulator-firestore-project フラグを追加する。
  • Firestore のクライアントライブラリで NewClient を行う際に DetectProjectID を利用する。

Go 側の解説

Firestore のクライアントライブラリで NewClient を行う際には引数で ProjectID の指定が必要ですが、私は DetectProjectID という定数を利用しています。(余談ですが Datastore のクライアントライブラリでも同様の定数が利用できます。) DetectProjectID を指定した場合、実行環境の認証情報からプロジェクトIDを取得してくれるのですが、ローカル環境で特に認証情報を設定していなかった場合、プロジェクトIDにはdummy-emulator-firestore-project という文字列が設定されます。なので Firestore Emulator を起動する際のプロジェクトIDも dummy-emulator-firestore-project に合わせてあげるとシームレスに接続できるようになります。

おわりに

Local Emulator Suite の UI 機能が便利そうだったので gcloud components 版のエミュレータから移行しましたが、 移行前はアプリ側の環境変数FIRESTORE_EMULATOR_HOST を設定するだけで接続できており、Local Emulator Suite 移行後にアプリから接続出来なくなったため意外なハマりポイントかな?と思って記事を書いてみました。