LoginSignup
65
32

More than 3 years have passed since last update.

Go の context パッケージの使い方について整理してみた

Last updated at Posted at 2018-12-08

はじめに

おはようございます。@convto という存在です。よろしくお願いします。
業務委託のメンバーとして、Makuakeの開発に参加させていただいています。

最近Makuakeストア (クラウドファンディング終了後プロジェクトの商品を購入できる機能) で購入した商品は すごくシュッとしてるクラッチバッグみたいなやつ です。
Makuakeはクラウドファンディング終了後の商品でも後から買えるのがすごくいいと思います。

さて、今回は Go言語の context パッケージの話をしようかと思います。よろしくお願いします。

これは Makuake Product Team Advent Calendar 2018 の 9日目 の投稿です。

contextパッケージとは

プロセス間のキャンセル処理の伝搬をおもに扱うパッケージです。
公式のドキュメントはこちら context - The Go Programming Language

ざっくりいうと二つの役割があるパッケージです。
その役割とは

  • プロセス間のキャンセル処理の伝搬
  • リクエストスコープ内で共有したい値の伝搬( 後述しますが扱いに注意してください)

の二つです。

とくに一つ目の プロセス間のキャンセル処理の伝搬 に関しては、標準パッケージに context があることにより、煩雑になりがちな 「プロセスをまたいだ処理のキャンセル」 について Gopher 間での共通認識を作っていることに大きなメリットがあると考えます。
(個人的には値を伝搬できることはオマケのように感じます。)

とはいえ、字面では嬉しさがあまり伝わらないと思うのでそれぞれ説明していきます。

context を使ったプロセス間のキャンセル処理の伝搬について

実際どのようにキャンセル処理をしていくのか説明します。
説明のために以下のコードを利用します

func handler(w http.ResponseWriter, r *http.Request) {
    // 結果を受信するチャネル
    result := make(chan string)

    go heavy(result)

    // 何か別の処理

    fmt.Fprintf(w, "allow request. result: %v\n", <-result)
}

// 重い処理
func heavy(result chan<- string) {
    time.Sleep(5 * time.Second)

    // 処理が完了したらチャネルに値を送信する
    result <- "process succeeded!"
}

rootHandler が重い処理を起動し、待ち合わせをしてレスポンスを返すありがちなパターンではないでしょうか。
今回はこれらのコードで context を利用したキャンセル処理の説明をしようと思います。

context.Background

contextを生成する関数は、基本的に別のcontextを引数にとって新しいContextを返します。
プロセスの親子関係などを表現するためです。

ではその一番最初は?一番大元のcontextはどうやって作ればいいのでしょう?

それがこの Background 関数です。
無からcontextを生成できるのは、context パッケージでは1 Background関数と、 TODO関数だけです。

TODOは context - The Go Programming Language にもある通り、どのコンテキストを利用するか決まっていないときや、まだコンテキストを受け入れる関数が準備されていない場合などに暫定で利用するものです。

実際に本番投入されるアプリケーションではTODOが使われることはおそらくないでしょう。

今回の場合はこのようになります。

func handler(w http.ResponseWriter, r *http.Request) {
    // 大元となる context の作成
    ctx := context.Background()
    // ...省略
}

context.WithCancel

WithCancelとは、その名の通り「キャンセル関数」を戻り値に返します。
その際、派生 context も共に返します。

func handler(w http.ResponseWriter, r *http.Request) {
    // 大元となる context の作成
    ctx := context.Background()
    // 派生 context と キャンセル関数を返す
    derivedCtx, cancel := context.WithCancel(ctx)
    // ...省略
}

これは、context.Backgroundの代入を省略して以下のように書く場合が多いです。
また、 defer cancel() とするとcancelのし忘れが起きません。

func handler(w http.ResponseWriter, r *http.Request) {
    // context を生成
    ctx cancel := context.WithCancel(context.Background())
    defer cancel()
    // ...省略
}

さて次は、キャンセルしたことを後続のプロセスに伝えるために context を渡してあげる必要があります。

func handler(w http.ResponseWriter, r *http.Request) {
    // context を生成
    ctx cancel := context.WithCancel(context.Background())
    defer cancel()

    // 結果を受信するチャネル 
    result := make(chan string)
    // 重い処理を起動
    // キャンセルを伝搬させるため、 ctx も渡す
    go heavy(ctx, result)
    // ...省略
}

ここで気をつけなければならないのは、contextの渡し方です。
context.Context 本体を第一引数に入れて渡してください。

ドキュメントに明記されています。
context - The Go Programming Language

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx:

(google翻訳) コンテキストを構造体の型の中に格納しないでください。代わりに、コンテキストを必要とする各関数に明示的に渡します。コンテキストは、最初にctxという名前の最初のパラメータにする必要があります。

ちょっと翻訳が緩いですが、

  • structなどに埋め込んで渡すのはダメ
  • 第一引数で渡すことを推奨
  • 基本的には ctx っていう名前がいいよね

みたいなことを言ってます。
渡し方にもある程度ルール(というか慣習)があるので気をつけましょう。

キャンセル通知が来た場合のケースをサポートするために、heavy関数に手を入れましょう。

func heavy(ctx context.Context, result chan<- string) {
    // 処理には5秒かかる
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    select {
    // 5秒たったら正常に終了
    case <-ticker.C:
        result <- "process successeded!"
    // 5秒よりも早くキャンセル通知が来たらcancelする
    case <-ctx.Done():
        result <- "process canceled..."
    }
}

このようにすると、任意のタイミングでのキャンセル処理と、その伝搬が実現できます。
この例だと、cancel された場合は "process canceled..." が入ります

context.WithDeadline

WithDeadline は、処理の書き方などは WithCancel とほぼ同じです。
違うのは cancel() が実行されるタイミングだけです。

WithDeadlineは指定した時刻を超えると、処理がキャンセルされます。
実装を見てみると このあたりの処理 で受け取った deadline に合わせたタイマー処理を走らせてるっぽいです。
deadline を経過したら内部で cancel されてますね。

WithCancel との違いは cancel() が自動でよばれるかどうかです。このように書けます。

func handler(w http.ResponseWriter, r *http.Request) {
    // 現在の時刻から3秒先を deadline として登録する
    ctx, cancel := context.WithDeadlime(context.Background(), time.Now().Add(3 * time.Second))
    // deadline より先にメイン処理が終わったケースなどで cancel が漏れないように defer で指定しておく
    defer cancel()

    // ...省略
}

書き方的には WithCancel() とほぼ変わりません。
ですが、今回の例だと3秒後にはdeferの前にcancel関数が呼ばれます。

WithDeadlineのタイムアウト処理は重たい I/O が絡む処理などで重宝します。
プロキシサーバーや、SaaSを参照したりするもので利用できるかと思います。

context.WithTimeout

先に実装を書きます

func handler(w http.ResponseWriter, r *http.Request) {
    // 3秒先の時刻でキャンセル
    ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
    defer cancel()

    // ...省略
}

WithTimeout は、現在時刻からの時間を指定して、その時刻になったら処理がキャンセルされます。

WithDeadline と似てね?と思ったあなた。
全くその通りで、なんなら内部でWithDeadline呼んでます。というかそれしかしていません。

Goのソースコード上の context の実装へのリンクです。
https://github.com/golang/go/blob/master/src/context/context.go#L440-L452

この関数の役割は、現在時刻等を気にせず deadline 管理をするための Wrapper といったところでしょうか。

キャンセル処理ってチャネルでもできない?

channelでもできます。
というか、 先ほどの Done メソッドも戻り値は channel です。(受け取り方をみればわかると思いますが)
https://github.com/golang/go/blob/master/src/context/context.go#L97

contextパッケージ以前には done などの名前のチャネルを準備し、そこで goroutine とやりとりをしキャンセル処理などをしていました。
当時のgoblog を参考にすると、だいたいこんな感じだと思います。

func handler(w http.ResponseWriter, r *http.Request) {
    // 終了を通知するチャネル
    done := make(chan struct{})
    // ほかの処理が終わったらcloseする
    close(done)
    // 結果を受信するチャネル
    result := make(chan string)
    // 重い処理を起動
    // doneチャネルも渡す
    go heavy(done, result)

    // 何か別の処理
}

heavy な処理はこう書けます

func heavy(done <-chan struct{}, result chan<- string) {
    // 処理には5秒かかる
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    select {
    // 5秒たったら正常に終了
    case <-ticker.C:
        result <- "process successeded!"
    // 5秒よりも早くキャンセル通知が来たらcancelする
    case <-done:
        result <- "process canceled..."
    }
}

やってることが同じな以上、これでもなんの問題もなく実現できます。

でも僕はキャンセル処理は可能であれば context を利用して管理した方がいいと思います。
以下にその理由を書いていきます。

timeout がちょっとだけめんどくさい

channel は単に goroutine 間で値などをやりとりするものであり、そのままではタイムアウトの管理はしてくれません。
何かしらの仕組みを準備してあげる必要があります。

とはいえこれはやればいいだけなのでそこまで重要ではありません。
重要ではありませんが、context はすでにその用途向けに準備されているものがあるので、そちらを使う方がわかりやすいと思います。

context は統一的なキャンセルを扱うイディオムである

これが主な理由です。

プロセス間のキャンセル処理は色々な方法で実装できますが、 context は何と言っても "標準パッケージがキャンセル処理のために提供している" ものです。

つまり、context はコードに登場した瞬間に(ほぼ2)「プロセス間のキャンセル処理を管理するための記述」というのがわかります。(WithCancelなどのメソッドの明快さも理解を助けています)

さらに、context は十分にシンプルで、処理の内容などが明快でわかりやすく、その振る舞いや特性、気をつけるべきことなどはGopher間で共通認識があります。

他の標準パッケージからも利用されたりもしています。

net/httpなどのキャンセル処理に関連が深いパッケージは、 context に関わる機能を提供しています。
例えば Requset.Context() などです。

http - The Go Programming Language

For incoming server requests, the context is canceled when the client's connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns.

(google翻訳)着信サーバー要求の場合、クライアントの接続がクローズされたり、要求が取り消されたり(HTTP / 2)、ServeHTTPメソッドが戻ったときにコンテキストが取り消されます。

これはhttp側でコネクションが切れた時などに cancel が発生し、以降の処理にキャンセルを伝搬してくれます。

これを利用して、先ほどのhttpサーバーの例はこのように書くことができます

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    // 結果を受信するチャネル
    result := make(chan string)
    // 重い処理を起動
    go heavy(ctx, result)

    // 何か別の処理

    fmt.Fprintf(w, "allow request. result: %v\n", <-result)
}

httpレイヤーのコネクションの異常を伝搬させることができます。
このように、context は標準パッケージの中にも強く溶け込んでいます。

以上のことから、キャンセル処理にcontextを使うことには

  • 対象が明確なので目的がわかりやすい
  • Go言語のなかの一つの共通認識になっていて捉え方や使い方に差が出づらい

というメリットがあります。

一方 channel は goroutine間の値のやり取りをする ものであり、必ずしもキャンセルのみに使われるものではありません。
より目的が明確なものを利用した方が理解しやすいコードになると思います。

context を利用した共有したい値の伝搬について

context は値の伝搬もすることができます。

こちらのコードを利用して説明します

func handler(w http.ResponseWriter, r *http.Request) {
    // 結果を受信するチャネル
    result := make(chan string)
    // 重い処理を起動
    go auth(result)

    // 何か別の処理

    fmt.Fprintf(w, "allow request. result: %v\n", <-result)
}

func auth(result chan<- string) {
    result <- "auth succeeded!"
}

context.WithValue

WithValue は context に値を渡すことができます
値を渡すためにはこのように書きます

// ベースとなる context を受け取って、値を追加した派生 context を返す
ctx := context.WithValue(parent, "key", "value")

今回の例に適用するとこのようになります。

func handler(w http.ResponseWriter, r *http.Request) {
    // Authorization ヘッダーを取得する
    // 今回は Bearer などのスキームは考慮しません
    authToken := r.Header.Get("Authorization")
    // context に値を渡す
    ctx := context.WithValue(context.Background(), "auth-token", authToken)
    // 結果を受信するチャネル
    result := make(chan string)
    go auth(ctx, result)

    // 何か別の処理

    fmt.Fprintf(w, "allow request. result: %v\n", <-result)
}

値を受け取るためにはこのように書きます。

val := ctx.Value("key")

戻ってくるのは interface{} なので、型アサーションなどとセットで使われることが多いです。

s, ok := ctx.Value("key").(string)

今回の例に適用するとこのようになります。

func auth(ctx context.Context, result chan<- string) {
    // context から取得した authToken を受け取る
    s, ok := ctx.Value("auth-token").(string)
    if !ok {
        // エラー処理
    }

    // 処理が完了したらチャネルに値を送信する
    result <- s
}

これで

curl SERVER_URI -H 'Authorization:AUTHORIZATION_TOKEN'

などすれば、レスポンスに AUTHORIZATION_TOKEN と出力されます。

これで context を利用した値の伝搬ができました!便利ですね!

contextのvalueは型がゆるい

とはいっても便利なだけで終わると崩壊します。

context - The Go Programming Language をみてみると
WithValue は func WithValue(parent Context, key, val interface{}) Context という形式の関数で、
key と value は共にinterface{} です。

次に Value関数 のインターフェイスを見てみましょう。
実装部分です。
https://github.com/golang/go/blob/master/src/context/context.go#L151
Value は Value(key interface{}) interface{} です。

context の Value には型の恩恵が一切ありません。

これはなかなかにしんどいことで、雑に扱うと崩壊してしまいます。
複数の人間が好き勝手に Set しまくったりすると、だれも全体像がわからなくてリファクタできない、なんて状態になるかもしれません。
Goのような型が強い言語で型の恩恵を受けられないというのはしんどいです。

なんでもかんでも自由に値を入れられるようなスラム街運用をすると崩壊します。
最低限の必要な値のみに絞って伝搬する必要がありそうです。

context の Value ってどんな値なら入れていいの?

Valueを雑に使うと困ることが多そうなことはわかりました。
では、どんな値なら入れていいのでしょうか?

ドキュメントには
context - The Go Programming Language

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

(google翻訳)コンテキストを使用するオプションのパラメータを関数に渡すのではなく、プロセスとAPIを転送するリクエストスコープのデータに対してのみ値を使用します。

context の Value はリクエストスコープのプロセスやAPI間にのみ使う、オプションとかを入れてはダメ的なことを言っています。
リクエストスコープという概念が少し難しいですね。他の例も探してみましょう。

Peter Bourgon · Context

Good examples of request scoped data include user IDs extracted from headers, authentication tokens tied to cookies or session IDs, distributed tracing IDs, and so on.

(google翻訳)リクエストスコープ付きデータの例としては、ヘッダーから抽出されたユーザーID、CookieまたはセッションIDに関連付けられた認証トークン、分散されたトレースIDなどがあります。

リクエストスコープな値として

  • Header から取得した UserID
  • cookie や session に紐づく認証トークン
  • 分散処理の追跡ID

などを例としてあげています。例があるとわかりやすいですね。

また

I think a good rule of thumb might be: use context to store values, like strings and data structs; avoid using it to store references, like pointers or handles.

(google翻訳)経験則としては、文字列やデータ構造体のような値を格納するコンテキストを使用すること、ポインターやハンドルなどの参照を保管するのに使用しないでください。

valueに保存するときは、参照は避けて値を使った方がいいんじゃない?みたいなことも言っています
次の例です。

How to correctly use context.Context in Go 1.7 – Jack Lindamood – Medium

A database connection is not a request scoped value because it is global for the entire server. On the other hand, if it is a connection that has metadata about the current user to auto populate fields like the user ID or do authentication, then it may be considered request scoped.

(google翻訳)データベース接続は、サーバー全体に対してグローバルであるため、要求スコープ値ではありません。一方、ユーザーIDのようなフィールドを自動入力する、または認証を行う、現在のユーザーに関するメタデータを持つ接続である場合は、要求スコープとみなされます。

A logger is not request scoped if it sits on a server object or is a singleton of the package. However, if it contains metadata about who sent the request and maybe if the request has debug logging enabled, then it becomes request scoped.

(google翻訳)ロガーは、サーバー・オブジェクトに置かれているか、パッケージのシングルトンであれば、要求スコープではありません。ただし、誰が要求を送信したかに関するメタデータが含まれていて、要求がデバッグログを有効にしている場合は、要求スコープになります。

DBコネクションやLoggerは基本的にはリクエストスコープではない(= context の Value に渡してはいけない)
しかし、そのコネクションやLoggerの役割によってはリクエストスコープとして扱うことは可能という意味で受け取りました。

context の value はこんな値が入れられそう

以上を鑑みると、context の value に入れられる値は

  • Header から取得した UserID
  • cookie や session の認証トークン
  • 分散処理の追跡ID

などが該当するんじゃないでしょうか。
また、参照ではなく値を保存した方が管理が煩雑にならなさそうです。

ただ、リクエストスコープか否かは該当部の役割などによって決めるべきな、一概にはなんとも言えないものなので、
チーム構成、プロダクトの状態、その役割などを鑑みて、よくチームメンバーと相談して決めると良さそうです。

考えなしになんでも詰めるのはダメ、ゼッタイ
これだけ覚えておいてください

( +α 取り出し方にマナーを持とう

以下は Value の扱いについての補足的な内容です

さて、チーム内で十分に議論して妥当と思われるものを Value で扱うことが決まりました。
仮に認証情報を扱うとして、先ほどのコードを改良していきましょう。

ここから先の整理については、以下の記事を参考しました

どちらも非常に良い記事でした。ぜひ元記事もご確認ください。

これが先ほどの最後の段階のコードです。これを整理していきます。

func handler(w http.ResponseWriter, r *http.Request) {
    // Authorization ヘッダーを取得する
    // 今回は Bearer などのスキームは考慮しません
    authToken := r.Header.Get("Authorization")
    // context に値を渡す
    ctx := context.WithValue(context.Background(), "auth-token", authToken)
    // 結果を受信するチャネル
    result := make(chan string)
    go auth(ctx, result)

    // 何か別の処理

    fmt.Fprintf(w, "allow request. result: %v\n", <-result)
}

func auth(ctx context.Context, result chan<- string) {
    // context から取得した authToken を受け取る
    s, ok := ctx.Value("auth-token").(string)
    if !ok {
        // エラー処理
    }

    // 処理が完了したらチャネルに値を送信する
    result <- s
}

...これでも問題はありませんが、ここまでしっかりやったのです。あとすこしだけ気を使って丁寧に取り出してあげましょう。

context の Value は map[interface{}]interface{} のようなもので、型の恩恵が全くないです。
もうすこしでも節度をもって使うことで安全側に倒していきましょう。

context key の定数化

まず handler と heavy のこの部分。

設定時

ctx := context.WithValue(context.Background(), "auth-token", authToken)

取得時

s, ok := ctx.Value("auth-token").(string)
if !ok {
    // エラー処理
}

これではタイポの可能性などもありますし、管理もしづらいので定数化しましょう。
つまりこのように書けます

const (
    // 複数ある場合はこのように並べる
    ctxKeyAuthToken = "auth-token"
    ctxKeyOther    =  "another"
)

設定時

ctx := context.WithValue(context.Background(), ctxKeyAuthToken, authzToken)

取得時

s, ok := ctx.Value(ctxKeyAuthToken).(string)
if !ok {
    // エラー処理
}

呼び出し側から文字列を気にしなくてもよくなったので、多少安全になりました。

パッケージ切り出し

さらに、Valueの設定と取得を追加し、別パッケージに置いてしまいましょう
こうすることで、 Value のキーと値の管理をこのパッケージに閉じ込めることができます。

package ctxval

import "context"

const authTokenKey = "authz-token"

func AuthzToken(ctx context.Context) (string, bool) {
    s, ok := ctx.Value(authTokenKey).(string)
    return s, ok
}

func SetAuthzToken(ctx context.Context, t string) context.Context {
    return context.WithValue(ctx, authTokenKey, t)
}

こうするとこのように呼べます

設定時

// parent は派生元の context
ctx := ctxval.SetAuthToken(parent,  authToken)

取得時

s, ok = ctxval.AuthToken(ctx)
if !ok {
    // エラーハンドリング
}

取得や設定時に利用するキーの値を考慮する必要がなくなるので管理しやすくなります。

(setは context の子を作る処理を隠蔽してしまうので、処理が若干追いづらくなる可能性があります。管理のしやすさとバランス次第ですが、 get のみの対応して定数を公開する、などでも良いと思います。)

context key にユーザー定義型を利用

まだ改良できます。
golint の contextKey の評価の実装 をみると、コメントに

checkContextKeyType reports an error if the call expression calls
context.WithValue with a key argument of basic type.

(google翻訳)checkContextKeyTypeは、呼び出し式が基本型のキー引数を持つcontext.WithValueを呼び出す場合にエラーを報告します。

とあり、かつコードでは go/types パッケージを利用して types.BasicKind を取り出してベーシック型でないことを確認しています。
types.BasicKind について、詳しくは types - The Go Programming Language をご確認ください。

ユーザー定義型を使った方が良いということでしょうか?
context - The Go Programming Language を確認したところ

The provided key must be comparable and should not be of type string or any other built-in type to avoid collisions between packages using context. Users of WithValue should define their own types for keys. To avoid allocating when assigning to an interface{}, context keys often have concrete type struct{}. Alternatively, exported context key variables' static type should be a pointer or interface.

(google翻訳)提供されるキーは同等でなければならず、コンテクストを使用してパッケージ間の衝突を避けるために、文字列またはその他の組み込み型であってはなりません。 WithValueのユーザーは、キーの独自の型を定義する必要があります。コンテキスト{}に割り当てるときの割り当てを避けるために、コンテキストキーにはしばしば具体的な型struct {}があります。あるいは、エクスポートされたコンテキストキー変数の静的型は、ポインタまたはインタフェースでなければなりません。

なるほど、いわゆるプリミティブな型を利用するとパッケージ間で利用している context のキーがコンフリクトしてしまう可能性があるんですね。

また、コンテキストキー変数を公開する場合、それはポインタもしくはインターフェースである必要がありそうです。
それに type myKey struct{} のようにできるケースが多そうです。
今回はコンテキストキー変数は非公開なため、単にユーザーの定義型を利用するように変更すれば良さそうですね。

修正しましょう。

package ctxval

import "context"

type authTokenKey struct{}

func AuthToken(ctx context.Context) (string, bool) {
    s, ok := ctx.Value(authTokenKey{}).(string)
    return s, ok
}

func SetAuthToken(ctx context.Context, t string) context.Context {
    return context.WithValue(ctx, authTokenKey{}, t)
}

ここまでやれば概ね問題ないのではないでしょうか!

さいごに

まとめてみて改めて、 context パッケージはGoのキャンセル処理の思想や文化に強い影響力を持つとてもパワフルなパッケージだな、と感じました。

context の面白いところはこれほど core で強い影響力を持ったパッケージなのに、実装が493行しかないことです(12/9日現在)
https://github.com/golang/go/blob/master/src/context/context.go#L493

これほど小さな実装の中に、Goの「文化」や「らしさ」の重要な部分がたくさん含まれているんです!
うまく言えませんが、非常にかっこいいですね。こういうの大好きです。

今回は context パッケージについて書かせていただきました。
僕自身、この記事を書く過程で新たに発見できたこともあり、非常に楽しかったです。

株式会社Makuakeでは一緒に働いてくれるメンバーを募集しております!
ご興味が有りましたらご連絡いただけると幸いです!
https://www.wantedly.com/projects/26807

Makuake Product Team Advent Calendar 2018 はまだまだ続きます。
明日は @inooka さんです。ぜひお楽しみに!

参考

context - The Go Programming Language
golang/go: The Go programming language
Go Concurrency Patterns: Pipelines and cancellation - The Go Blog
http - The Go Programming Language
Peter Bourgon · Context
How to correctly use context.Context in Go 1.7 – Jack Lindamood – Medium
Context keys in Go – Mat Ryer – Medium
Golangのcontext.Valueの使い方 | SOTA
golang/lint: [mirror] This is a linter for Go source code.
types - The Go Programming Language


  1. context パッケージ以外にも、context.Context を返す関数を持つパッケージは存在します 

  2. Valueを渡すためだけに使っているパターンもたまにあるので「ほぼ」という表現をしています 

65
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
65
32