その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

Go Genericsを使ってgo-optionalを書いた / Go Generics感想

Go Genericsがどんなもんか試してみたかったので、これを使ってOptionの実装を書いてみました。

github.com

基本的な使い方としてはSynopsisを読んでもらえばわかると思いますが、ユーティリティとしては

  • IsSome()
  • IsNone()
  • Take()
  • TakeOr()
  • TakeOrElse()
  • Filter()
  • Map()
  • MapOr()
  • Zip()
  • ZipWith()
  • Unzip()
  • UnzipWith()

あたりを取り揃えております。examplesも併せてご覧いただくとおおよその使い方の雰囲気が掴めると思います。
利用のためにはまだunstableな最新版 (go1.18) を使う必要があるので、gotipとかを使って新しい処理系を引っぱってくる必要があります。

で、GoのGenericsを使ってみた感想としてdefault valueとかconstraintとかどうするんだよと悪戦苦闘していたのですが、id:codehexさんの書かれた記事にほぼほぼ書かれていることを一通り終わった後に気付きました。

zenn.dev

で、概ね同じような所感を持ったのですが、それだけだと芸が無いので僕個人としてのGenericsを使ったときの感想について記しておきます。

メソッドに型パラメータが持てない

例えばこのようなメソッドについて考えてみましょう。

type Something struct {
}

func (s *Something) Echo[V any](v V) V {
	return v
}

なんとなく構文的にはvalidなように見えますが、これをコンパイルすると

./main.go:6:25: methods cannot have type parameters
./main.go:6:26: invalid AST: method must have no type parameters

というエラーが吐かれます。メソッド (つまりレシーバがある) 時にはそのメソッドに対して型パラメータが付けられないということのようですね。
例えば以下のようにメソッドにすることをやめるとコンパイルは通るようになります。

func Echo[V any](s *Something, v V) V {
	return v
}

こういった実挙動のため、go-optionalでは Map() 等の関数実行時の型パラメータに依存するユーティリティについてはメソッドではなく関数として実装しています。
ref: https://github.com/moznion/go-optional/blob/b0ada9baa88672d2f0fc11a1487842da170b5f92/option.go#L83

なんとかジェネリクスのconstraintをtype assertionで分岐させられないか

例えば、go-optionalでは「Option[T]の値が、Tの或る値を持っているか」を確認するメソッドである Contains(v T) を実装しようと思ったのですが

type Option[T any] struct {
	value T
	exists *struct{}
}

func (o Option[T]) Contains(v T) bool {
	if o.IsNone() {
		return false
	}
	return v == o.value
}

というふうに素朴に実装すると ./option.go:20: invalid operation: cannot compare v == o.value (operator == not defined on T) という風なコンパイルエラーが出てきます。そりゃそうだ。

これについては上のcodehexさんの記事にもありましたが、現状あるconstraintを満足させるためには別の型 (型パラメータ) を付けて実装する必要があります。
たとえばこれだと通る。

type ComparableOption[T comparable] struct {
	value T
	exists *struct{}
}

func (o ComparableOption[T]) Contains(v T) bool {
	if o.IsNone() {
		return false
	}
	return v == o.value
}

あくまで例えばの話ですが、anyについてtype assertionのようにconstraintのチェックができれば便利だよな〜と思い、例えば以下のように……

// o is a value of Option[T any]
v, ok := o.(Option[T comparable])

とはいえまーこれあんま筋良くない気がするな、コンパイル時のtype erasureとかもあるだろうし……

型パラメータにタプルを持たせられない

例えばこういう書き方はできません。

func Zip[T, U any](opt1 Option[T], opt2 Option[U]) Option[(T, U)] {
	...
}

まあそういうもんですよ、と言われたらその通りなのでgo-optionalでは Pair という構造体を用意して Option[Pair[T, U]] を返却するようにしました。

ref: https://github.com/moznion/go-optional/blob/b0ada9baa88672d2f0fc11a1487842da170b5f92/option.go#L108

pkg.go.dev がgodocを読み込んでくれないっぽい?

https://pkg.go.dev/github.com/moznion/go-optional

stableなruntime versionではまだ未定義の構文が多く含まれているからか、godocが解釈できないっぽい? pkg.go.dev に一向にドキュメントが出てこなくて地味に困っています……

[追記] バイナリのフットプリントについて

Genericsを使ったときと使わないときでビルドされたバイナリのフットプリントに差が出るのだろうか、という素朴な疑問があったので雑に比較。
環境は go version devel go1.18-4083a6f Wed Nov 17 14:10:29 2021 +0000 darwin/amd64

genericsなし:

package main

import "fmt"

type SomethingStr struct {
        v string
}

type SomethingInt struct {
        v int
}

func main() {
        s := SomethingStr{
                v: "foo",
        }
        i := SomethingInt{
                v: 123,
        }

        fmt.Println(s)
        fmt.Println(i)
}

genericsあり:

package main

import "fmt"

type Something[T any] struct {
        v T
}

func main() {
        s := Something[string] {
                v: "foo",
        }
        i := Something[int] {
                v: 123,
        }

        fmt.Println(s)
        fmt.Println(i)
}

結果:

-rwxr-xr-x   1 user  user  1846368 11 18 11:11 generics*
-rwxr-xr-x   1 user  user  1846368 11 18 11:27 no_generics*

1バイトも変わらぬ結果に。面白いですね。とはいえこれは最適化戦略やコードに依存する気がするのでもっと複雑なコードを書くと変わってくる可能性がある気が……あんま信用しないでください。



だいたいそんな感じでした。手習い的にGoのGenericsを使ってみましたが、けっこうシンプルかつ現時点でも普通に使えるな、という肌触りです。
ここは型の推論が効くだろ (他の言語だと効きそうに見える)、と思って型パラメータの記述をサボると「型パラメータを明示しろや」とコンパイラに怒られたりもするわけですが、まあそこはgoらしさ・シンプルさを優先したということなのでしょう。機能と実装複雑性・コンパイルタイムのトレードオフという見方をしました。

MySQLのJSON Data Typeの値に対し、明示的なキャスト無しに `BETWEEN`, `IN()`, `GREATEST()`, `LEAST()` を使ってはならない

表題の通り、MySQLJSON Data Typeの値に対しては、明示的なキャスト無しBETWEEN, IN(), GREATEST() そして LEAST() を使ってはいけません。

本記事はこれに係る話題で、id:sugyan さんに Slack で相談を受けて「僕もそれハマったことあるな」と調べたところ以下のドキュメントに辿りつきました。

dev.mysql.com

これはMySQL 8.0のJSON Data Typeに関するドキュメントですが、このドキュメントの Comparison and Ordering of JSON Values というセクションに

The following comparison operators and functions are not yet supported with JSON values:

  • BETWEEN
  • IN()
  • GREATEST()
  • LEAST()

A workaround for the comparison operators and functions just listed is to cast JSON values to a native MySQL numeric or string data type so they have a consistent non-JSON scalar type.

と明記されています。これらの演算子・関数については「未対応」ということみたいですね。


これがどういうことか実例を見てみましょう。

前提として、JSON_EXTRACT() を使って取り出した値はその値自体がJSON Data Typeとして扱われます。

mysql> SELECT JSON_TYPE(JSON_EXTRACT('{"a":101,"b":99}', '$.a'));
+----------------------------------------------------+
| JSON_TYPE(JSON_EXTRACT('{"a":101,"b":99}', '$.a')) |
+----------------------------------------------------+
| INTEGER                                            |
+----------------------------------------------------+
1 row in set (0.00 sec)

なおこの反例として、仮にこの値をキャストしてみると JSON_TYPE() はエラーを返却します。

mysql> SELECT JSON_TYPE(CAST(JSON_EXTRACT('{"a":101,"b":99}', '$.b') AS SIGNED INTEGER));
ERROR 3146 (22032): Invalid data type for JSON data in argument 1 to function json_type; a JSON string or JSON type is required.

さて、このJSON Data Typeの値について単純な比較演算子を使って数値比較をしてみましょう。比較演算子についてドキュメントには

JSON values can be compared using the =, <, <=, >, >=, <>, !=, and <=> operators.

とあるので正常に動きそうです。

mysql> SELECT JSON_EXTRACT('{"a":101,"b":99}', '$.a') > JSON_EXTRACT('{"a":101,"b":99}', '$.b');
+-----------------------------------------------------------------------------------+
| JSON_EXTRACT('{"a":101,"b":99}', '$.a') > JSON_EXTRACT('{"a":101,"b":99}', '$.b') |
+-----------------------------------------------------------------------------------+
|                                                                                 1 |
+-----------------------------------------------------------------------------------+
 1 row in set (0.00 sec)

このSQLJSON Data TypeがINTEGERの値である 10199 について比較したもの、すなわち 101 > 99 ですが、1が返却されているので正しく動作していそうです。

一方で同じ値に対し GREATEST() を利用するとどうなるでしょうか。

mysql> SELECT GREATEST(JSON_EXTRACT('{"a":101,"b":99}', '$.a'), JSON_EXTRACT('{"a":101,"b":99}', '$.b'));
+--------------------------------------------------------------------------------------------+
| GREATEST(JSON_EXTRACT('{"a":101,"b":99}', '$.a'), JSON_EXTRACT('{"a":101,"b":99}', '$.b')) |
+--------------------------------------------------------------------------------------------+
| 99                                                                                         |
+--------------------------------------------------------------------------------------------+
1 row in set, 1 warning (0.00 sec)

おやおや、期待した値は 101 なのですが 99 が返却されています。
これは辞書順なのでしょうか……? というところから暗黙的なキャストやその他もろもろを疑っていたのですが、結論としては冒頭の通り「未対応」なので使ってはいけないということのようです。

というわけで、提案されているワークアラウンドを実行してみましょう。JSON INTEGERをSIGNED INTEGERに明示的にキャストして試してみます。

mysql> SELECT GREATEST(CAST(JSON_EXTRACT('{"a":101,"b":99}', '$.a') AS SIGNED INTEGER), CAST(JSON_EXTRACT('{"a":101,"b":99}', '$.b') AS SIGNED INTEGER));
+--------------------------------------------------------------------------------------------------------------------------------------------+
| GREATEST(CAST(JSON_EXTRACT('{"a":101,"b":99}', '$.a') AS SIGNED INTEGER), CAST(JSON_EXTRACT('{"a":101,"b":99}', '$.b') AS SIGNED INTEGER)) |
+--------------------------------------------------------------------------------------------------------------------------------------------+
|                                                                                                                                        101 |
+--------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

はい、期待通りの結果になりましたね。良かった良かった。

そしてこの挙動について、実はMySQL側も警告メッセージを残していることがわかります。

mysql> SHOW WARNINGS;
+---------+------+----------------------------------------------------------------------------------------------------+
| Level   | Code | Message                                                                                            |
+---------+------+----------------------------------------------------------------------------------------------------+
| Warning | 1235 | This version of MySQL doesn't yet support 'comparison of JSON in the LEAST and GREATEST operators' |
+---------+------+----------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

--show-warnings を有効にしておくとクエリの実行時に気付くこともできますが、この手のクエリを実行した時に失敗させる方法は見付かりませんでした。strictモードとかを色々いじってはみたのですが。なにか良い方法はあるんでしょうか?

結論

MySQLJSON Data Typeを使う時は気を付けましょう。

macOSでDocker Desktopをアンインストールしてdocker-cli + docker-machineで動かすようにする

www.docker.com

Docker Desktopがここ最近活発に開発されているというか、かなり見た目がオシャレになってきてて「ヤル気あるな〜」と思って眺めていたのですが、なるほど有料化するということなのですね。

Docker Desktop remains free for personal use, education, non-commercial open source projects, and small businesses (fewer than 250 employees AND less than $10M USD in annual revenue).
Commercial use of Docker Desktop in larger enterprises (more than 250 employees OR more than $10 million USD in annual revenue) requires a Docker Pro, Team or Business subscription for as little as $5 per user per month.

実際、こういうところに注力して利益を得ていくというのは良いことだと思います! 頑張れdocker!

それはそうとしてDocker Desktopの機能をあまり使ってこず、実際のところcliでdockerが操作できればよい、つまりdocker engineが動けば良いという感じだったので、docker-machineを使ってローカルでdockerデーモンのためのVMを動かし、ホストからそのVMcliを繋いでdockerを使うという方法をやっていくこととします。どことなく懐しいですね。podman使うとかでも良かったんですけど、ひとまずdocker cliでやります。

まずDocker Desktopをアンインストールする。

f:id:moznion:20210901111820p:plain

"Troubleshoot" メニューを開きます。この虫マークは "Troubleshoot" というメニューだったのですね。バグレポートボタンだと思ってた……

f:id:moznion:20210901111903p:plain

アンインストールしましょう。これで完了です。

そしてdocker cliとdocker-machineを入れる。

$ brew install docker docker-machine

で、docker-machineを使ってローカルにVMを立ててそこでdockerデーモンを動かすこととする。このとき、ローカルマシンにあらかじめVirtualBoxがインストールされている必要があります。
やりかたとしては右のページに従うと良い: Get started with Docker Machine and a local VM | Docker Documentation
おおざっぱに書くと以下のようなかんじ。

$ docker-machine create --driver virtualbox default
$ echo 'eval "$(docker-machine env default)"' >> .bash_profile

そして docker ps などと打ってみてちゃんとレスポンスが返ってくると成功です。


Docker Desktopで動かしていた時と比較するとなにか制限等があるかもしれませんがそれは遭遇した時に考える、あるいは遭遇したらLinux Desktopに乗り換えるなどを検討していきたいと思います。実際Linux Desktopへ移行していきたい……

[追記]

良くみてみるとdocker-machineの開発が停滞しているように見えるので、そこは懸念ポイントと言えそうですね……
github.com
まあなんかあったら手でVM運用していきましょう。俺たちにはそれができるはずだ。

[追記]

Gitlabがforkしたdocker-machineがあるとのこと。

IntelliJ IDEAのsbt pluginがPrivateなGitHubのmaven repositoryに上げたライブラリを解決してくれないとき

表題の件で、sbtの依存解決コンポーネントが突然HTTP Status 400を返却してきてなにをやっても無駄、一生解決してくれない、みたいなことが原因不明ながら周期的に起きていて、そういうときにどうすれば良いかというと、

$ GITHUB_REGISTRY_TOKEN="YOUR-TOKEN" open /Applications/IntelliJ\ IDEA.app/

みたいな感じでトークンを環境変数で与えながらIDEAを起動してやるととりあえず動く……が、まったく本望ではない。
実際IDEAの環境変数を適切に設定すれば動くでしょ、とは思いつつも環境変数どこで設定すりゃ良いんだよ、つーかそもそもsbt pluginでは環境変数を設定することができず本当につらい、mavenやgradleのpluginではできるのだが……という気持ちでいっぱいです。


忙しいときにこういうの踏むと本当に大変ですね。以上です。

Elasticsearchの"index.mapping.total_fields.limit"を監視する話

Elasticsearchには index.mapping.total_fields.limit という設定があり、これは何かというと「1つのindexあたりが保存できるフィールドの上限数」を表現しており、この上限に触れると Limit of total fields [1000] in "your_index" index has been exceededのようなエラーが発生してindexができなくなります。

この上限への対症療法としては先人たちが示している通り様々あるのでそちらに譲り *1、本記事ではこの index.mapping.total_fields.limit を監視する方法について考えていきましょう。上にも書きましたが、この上限にヒットするとそのindexに対するindexingが完全にストップするのでマズいんですよ……
(もちろん、Elasticsearchのパフォーマンスを考えると1つのindexに大量のフィールドを生やすのは良くないし、そもそも理性的な使いかたをしていれば起きないことだとは思うのですが……まあ実際にElasticsearchを運用していると色々なことがありますね)


というわけで、johtaniさんに色々教えてもらったことについて以下に記します。いつもありがとうざいます。

なるほど、コードを読んでみると確かにシンプルなリミットチェックを行っているだけっぽい。

stackoverflow.com

ハハア、なるほど。APIを叩いてそのindexのフィールド数を回収することで現在のステータスを回収しよう、ということですね。


というわけで以下のようなスクリプトを一定周期で流してメトリクスとして回収し、可視化およびアラートを設定することで現状なんとかしております、というお話でした。

for idx in $(curl -Ss "${ES_ENDPOINT}/_cat/indices?format=json" | jq -r .[].index); do
  num_of_fields=$(curl -Ss "${ES_ENDPOINT}/${idx}/_field_caps?fields=*" | jq '.fields | length')
  echo "$idx: $num_of_fields"
  store_metric "$idx" "$num_of_fields"
done

jitterをかけたtickerを提供するgoのライブラリjickerを書いた

github.com

定期的に実行したい何かがあって、そしてそのインターバルにjitterが入っていてほしいということがしばしばあり、必要な時に都度そういうコードを書いていたのですが、毎度書くのもしんどいな〜と思ったのでこの度ライブラリにしたという次第です。

READMEのUsageに書いてあるとおり、

package main

import (
	"context"
	"log"
	"time"

	"github.com/moznion/jicker"
)

func main() {
	ctx, cancelFunc := context.WithCancel(context.Background())
	defer cancelFunc()

	c := jicker.NewJicker().Tick(ctx, 1*time.Second, 0.05)
	for t := range c {
		log.Printf("tick: %v", t)
	}
}

というふうに Tick() を使用すると、1秒に対して±5% jitterがかかったインターバル (すなわち0.95秒から1.05秒の間でランダムなインターバル) おきにtickしてくれるtickerを得ることができます。

package main

import (
	"context"
	"log"
	"time"

	"github.com/moznion/jicker"
)

func main() {
	ctx, cancelFunc := context.WithCancel(context.Background())
	defer cancelFunc()

	c, err := jicker.NewJicker().TickBetween(ctx, 1*time.Second, 2*time.Second)
	if err != nil {
		log.Fatal(err)
	}
	for t := range c {
		log.Printf("tick: %v", t)
	}
}

一方別の関数 TickBetween() を使用すると、任意の区間でjitterがかかったインターバル (この場合1秒から2秒の間でランダムなインターバル) おきにtickしてくれるtickerがやってきます。


「実家」のような名前のライブラリですが今年のゴールデンウィークは実家に帰れません。世界が大変ですね。
小粒なライブラリですが、ぜひご利用くださいませ。

Goのhttptestパッケージを使ってUNIX domain socketを使ったHTTPサーバのテストをする

Goが提供するHTTPのテストのためのユーティリティであるところのhttptestを使って、UNIX domain socketを使用するHTTPサーバのテストをするという内容についてのメモです。

import (
	"log"
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
)

func TestFoo(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) {
		_, err := w.Write([]byte("hello"))
		if err != nil {
			log.Printf("error: %s", err)
			w.WriteHeader(500)
			return
		}
		w.WriteHeader(200)
	})

	tempSock, err := os.CreateTemp("", "sock")
	if err != nil {
		t.Fatal(err)
	}
	err = os.Remove(tempSock.Name())
	if err != nil {
		t.Fatal(err)
	}

	listener, err := net.Listen("unix", tempSock.Name())
	if err != nil {
		t.Fatal(err)
	}

	server := httptest.NewUnstartedServer(mux)
	server.Listener = listener
	server.Start()
	defer server.Close()

	// do something
}
  • os.CreateTemp()でtemporaryなファイルを作り、それを削除してファイル名だけを引っ張りつつ (削除せず実ファイルが残っているとbind(2)がEADDRINUSEを吐く)
  • それを net.Listen()に渡してlistenerを作り
  • httptest.NewUnstartedServer()でテスト用のサーバのインスタンスを作り
  • listenerをそのサーバのインスタンスに食わせ
  • サーバスタート
  • あとはUNIX domain socketを使ったクライアントを使ってテストを書く

という感じで使うことが可能です。簡単ですね。


もちろんtemp fileを使わずに任意のパスをソケットファイルとして与えても良い (server.Close()が実行されるとそのソケットファイルも削除されるので) のですが、うっかりserver.Close()が実行されないとゴミファイルが残ってしまったりしてダルいので、まあ保険のようなものです。