ISUCON8予選で敗退しました...
ISUCON8の予選に @hanhan1978, @trtraki とyokohama-northというチームで参加しましたが、最終スコア30824で敗退しました。無念。。
以前と同様のメンバーでしたが、今回は役回りを変更。過去に参加した時はいつもDB等ミドルウェア・OS周りをやってたけど、今回はアプリケーション改修班に。Goでやりました。担当を変えたことで別の体験ができてすごく良かったです。
今回のお題はイベントの座席の予約サイトという感じ。アプリケーション触ったりコード見たりしてて排他制御があるなー、それ以外は普通かな?と思ってたのですが、実際はやたらと呼ばれてるgetEventという関数の改善で右往左往してるだけでほとんど終わってしまいました。もっとプログラミングの質とスピードを上げたいのと、複数台サーバー使った時に排他制御のところどうやっていこうかなというのをじっくり考えたいなぁという気持ちです。
とにかくすごく楽しかったし、ベンチマーカーやポータルサイトも非常に快適でした。運営の方々、チームメンバーの方々、こんなに楽しい機会をいただき本当にありがとうございます!
以下、雑な思い出タイムライン。
- 10:00に滞りなく開始!
- とりあえずGoに切り替える
- pprofとecho-pprofを導入
- 使ったことがないgbとかいうパッケージ管理ツールが使われてて一瞬焦る
- まずはコード読んだりアプリケーション使ってみたり
- h2oであることに気がつく
- h2o使ったことある人がいないので、Nginxに置き換えることをチームで決定
- pprofがうまくいかない
- 502 BadGatewayだったので、h2oのタイムアウトかなぁ、という感じ
- Nginxに置き換えられるの待ち
- 11:00くらい
- チームで話し合い
- Nginxへの置き換えをとりあえず終わらせて、ログをalpで分析しよう、ということに
- alpの結果から
GET /
とGET /api/users/:id
とで分担。GET /
を担当することに - pprofしたけど、handlerが無名関数で書かれていてプロファイルできない
- handlerを関数化する。最初の1時間でやっておくべきだった
- 関数化完了。pprof見ると、ほとんど
getEvent
- 各エンドポイントで N+1 にならないようにしましょう、とメンバーと話す
GET /
のフロントのコードを読むと、reservationsをそもそも使っていないので、DBから取得する必要すらないだろう、ということで修正する- 修正したけど、ベンチが通らない。あー、ベンチマーカーには画面を返してるわけじゃなくJSON返してるんだから、レスポンスの構造は変えちゃダメじゃないかということに気がつく
- getEventのインタフェースは維持して、中身のロジックを修正する方針に切り替える
- eventとreservationsのN+1を解消する
- 最初うまくいかなかったところ
- OUTER JOINを使ったSQLの結果をstructにマッピングするときにNULLを
int64
に代入しようとして実行時エラー sql.NullInt64
を使って対応
- OUTER JOINを使ったSQLの結果をstructにマッピングするときにNULLを
- 次にうまくいかなかったところ
- 存在しないイベントに対する応答がベンチマーカーが期待してたステータスコードと違う
db.QueryRow
を使ってレコードがない場合はScan
時にsql.ErrNoRows
が返ってくるが、db.Query
を使うように変更したのでレコードが存在しない場合の挙動が変わってしまった。明示的にsql.ErrNoRows
を返すようにして対応
- getEvent修正終わった
- 点数が10000点超えた。たしかここで16:00くらいになってた気がする
- alpすると、
GET /
とPOST /api/events/:id/actions/reserve
が遅い - pprofすると
POST /api/events/:id/actions/reserve
は結局getEvent
- 引き続き
GET /
を対応する- 公開済みのeventのデータだけをN+1を解消して取得するようにする
- 修正できたけど
GET /
速くならず・・・! - 最初のgetEvent改修時のSQLをチューニングしてなかったことに気がついてインデックス作成する
- 20000-30000くらいをいったり来たり
- 17:00くらい
- なんかfailになる時がちょいちょいある
- しかもサーバー上のコミットがなんか意図したものと違うことに気がつく
- gitで調整しつつ、バイナリ作る
- うまくいくコミットが見つかったので、ビルド。バイナリを保存
- reboot後の動作確認して終了ー
Goでechoサーバーを書いた時のメモ
Goでechoサーバーを書いた時のシステムコールはどうなってるのかなぁ、と思って調べてみた時の日記です。
前にCでechoサーバーを書いた時は以下のような実装をしました。
socket
でソケットを作成するbind
でソケットをバインドするlisten
でListen状態になるaccept
でコネクションを取り出すread
でクライアントから文字列を受け取るwrite
でクライアントへ文字列を返す
Goで書くとどうなるでしょうか?
$ cat /etc/lsb-release DISTRIB_ID=Ubuntu DISTRIB_RELEASE=16.04 DISTRIB_CODENAME=xenial DISTRIB_DESCRIPTION="Ubuntu 16.04.4 LTS"
$ go version go version go1.10.3 linux/amd64
以下のような無限ループするコードから始めます。
package main import "fmt" func main() { fmt.Println("wait...") for { } }
実行。
$ go run echo_server.go
$ lsof -i:30000
$ ps -x | grep echo_server 2200 pts/1 Sl+ 0:00 go run echo_server.go 2232 pts/1 Rl+ 0:24 /tmp/go-build303798012/b001/exe/echo_server 2287 pts/2 S+ 0:00 grep --color=auto echo_server
$ ll /proc/2232/fd total 0 dr-x------ 2 vagrant vagrant 0 Jul 7 05:48 ./ dr-xr-xr-x 9 vagrant vagrant 0 Jul 7 05:48 ../ lrwx------ 1 vagrant vagrant 64 Jul 7 05:48 0 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 05:48 1 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 05:48 2 -> /dev/pts/1
まだポートのバインドもしていなければソケットのディスクリプタもない。psの結果を見るとstatに l
が出力されていてGoのランタイムがマルチスレッドで動いてることが分かる。
Listen
netパッケージのListenを使う。
package main import ( "fmt" "net" ) func main() { l, err := net.Listen("tcp", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") for { } }
確認してみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2330 vagrant 3u IPv4 19936 0t0 TCP *:30000 (LISTEN)
$ ss -lt4 State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 *:30000 *:*
30000ポートでLISTENになってる。つまり、 Listen
関数で以下の3ステップが行われた模様。
socket
でソケットを作成するbind
でソケットをバインドするlisten
でListen状態になる
ディスクリプタも確認する。
$ ll /proc/2330/fd total 0 dr-x------ 2 vagrant vagrant 0 Jul 7 06:04 ./ dr-xr-xr-x 9 vagrant vagrant 0 Jul 7 06:04 ../ lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 0 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 1 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 2 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 3 -> socket:[19936] lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 4 -> anon_inode:[eventpoll]
おや、ソケットの他に anon_inode:[eventpoll]
が。これはepollを使ったechoサーバー書いたときにもあったやつ。 epoll_create
システムコールで作られるファイルディスクリプタ。あの時は、 sock
, bind
, listen
で準備したソケットのディスクリプタとepoll_create
で作ったファイルディスクリプタを、epoll_ctl
の引数で指定する、という順序でechoサーバーを書いた。
straceしてみると確かにやってる。
$ go build echo_server.go $ strace ./echo_server ... epoll_create1(EPOLL_CLOEXEC) = 4 ... socket(PF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3 setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0 bind(3, {sa_family=AF_INET, sin_port=htons(30000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0 listen(3, 128) = 0 epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=880447232, u64=140669649325824}}) = 0 ...
通信する
net.ListenerのAccept
メソッドでコネクション(net.Conn)を取得して、net.ConnのRead
メソッド, Write
メソッドを使う。
package main import ( "io" "fmt" "net" ) func main() { l, err := net.Listen("tcp4", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") for { conn, err := l.Accept() if err != nil { panic(err) } _, err = conn.Write([]byte("Hello World!\r\n")) if err != nil { panic(err) } buf := make([]byte, 1024) n, err := conn.Read(buf) if err != nil { if err == io.EOF { break } panic(err) } _, err = conn.Write(buf[:n]) if err != nil { panic(err) } conn.Close() } }
クライアントからつなぐ。
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'. Hello World!
サーバー側で確認する。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2019 vagrant 3u IPv4 18087 0t0 TCP *:30000 (LISTEN) echo_serv 2019 vagrant 5u IPv4 18089 0t0 TCP 192.168.33.10:30000->192.168.33.1:52736 (ESTABLISHED)
$ ll /proc/2019/fd total 0 dr-x------ 2 vagrant vagrant 0 Jul 7 07:20 ./ dr-xr-xr-x 9 vagrant vagrant 0 Jul 7 07:19 ../ lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 0 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 1 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 2 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 3 -> socket:[18087] lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 4 -> anon_inode:[eventpoll] lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 5 -> socket:[18089]
クライアントから適当な文字列を入力するとechoされて接続がサーバーから切られる。
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'. Hello World! hoge hoge Connection closed by foreign host.
echoサーバーできた。
複数クライアントから接続してみる
複数のクライアントで同時に接続してみる。
クライアント1
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'. Hello World!
クライアント2
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'.
最初に接続したクライアント1はサーバーから Hello World!
が送られてきてるけど、後に接続したクライアント2には何も送られてきてない。
サーバー側で確認してみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2019 vagrant 3u IPv4 18087 0t0 TCP *:30000 (LISTEN) echo_serv 2019 vagrant 5u IPv4 18100 0t0 TCP 192.168.33.10:30000->192.168.33.1:54396 (ESTABLISHED) $ ss -at4 | grep 30000 LISTEN 1 128 *:30000 *:* ESTAB 0 0 192.168.33.10:30000 192.168.33.1:54396 ESTAB 0 0 192.168.33.10:30000 192.168.33.1:54405
C言語でechoサーバー書いた時と同じように、1クライアントずつしかさばけていない模様。あの時は read
のブロッキングI/Oで1クライアントずつしか捌けなかった。
今回はどうだろう。straceで確認してみる。
$ sudo strace -p 2019 strace: Process 2019 attached epoll_pwait(4,
read
じゃなくて epoll_pwait
でブロックしてる。
straceしながら確認してみる。
$ strace ./echo_server
クライアント1からつなぐ。
[{EPOLLIN, {u32=1587633920, u64=139927327170304}}], 128, -1, NULL, 139927327170304) = 1 futex(0x5bcd50, FUTEX_WAKE, 1) = 1 futex(0x5bcc70, FUTEX_WAKE, 1) = 1 accept4(3, {sa_family=AF_INET, sin_port=htons(57992), sin_addr=inet_addr("192.168.33.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5 epoll_ctl(4, EPOLL_CTL_ADD, 5, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1587633712, u64=139927327170096}}) = 0 getsockname(5, {sa_family=AF_INET, sin_port=htons(30000), sin_addr=inet_addr("192.168.33.10")}, [16]) = 0 setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0 write(5, "Hello World!\r\n", 14) = 14 read(5, 0xc4200b2000, 1024) = -1 EAGAIN (Resource temporarily unavailable) epoll_pwait(4, [{EPOLLOUT, {u32=1587633712, u64=139927327170096}}], 128, 0, NULL, 0) = 1 epoll_pwait(4,
accept
するときにノンブロッキングの指定をしている。
accept4(3, {sa_family=AF_INET, sin_port=htons(57992), sin_addr=inet_addr("192.168.33.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5
read
したけどノンブロッキングにしてるのですぐに EAGAIN
が返ってきている。つまり、 read
ではブロックしていない。
read(5, 0xc4200b2000, 1024) = -1 EAGAIN (Resource temporarily unavailable)
クライアント2からもつなぐ。
[{EPOLLIN, {u32=1587633920, u64=139927327170304}}], 128, -1, NULL, 0) = 1 epoll_pwait(4,
epollからGoへの通知は発生しているものの、 accept
が呼ばれない。
read
システムコールでブロッキングはしていないが、GoのRead
から先に進んでいない。1つのgoroutineで処理を行っておりRead
でブロッキングしてるため、クライアントずつしか捌けていない。OSから見るとノンブロッキングI/Oな read
だけれども、Goから見るとブロッキングな Read
という感じ。
複数クライアントを捌く
クライアントから接続が来たらgoroutineを生成して対応する。C言語で書いた時のブロッキングI/Oのままfork
を使ってマルチプロセスにしたコードの同じように書ける。
package main import ( "io" "fmt" "net" ) func main() { l, err := net.Listen("tcp4", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") for { conn, err := l.Accept() if err != nil { panic(err) } go func(conn net.Conn) { _, err = conn.Write([]byte("Hello World!\r\n")) if err != nil { panic(err) } buf := make([]byte, 1024) n, err := conn.Read(buf) if err != nil { if err == io.EOF { return } panic(err) } _, err = conn.Write(buf[:n]) if err != nil { panic(err) } conn.Close() }(conn) } }
これで複数クライアント同時に接続しても大丈夫。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2128 vagrant 3u IPv4 18169 0t0 TCP *:30000 (LISTEN) echo_serv 2128 vagrant 5u IPv4 18171 0t0 TCP 192.168.33.10:30000->192.168.33.1:64033 (ESTABLISHED) echo_serv 2128 vagrant 6u IPv4 18173 0t0 TCP 192.168.33.10:30000->192.168.33.1:64042 (ESTABLISHED) $ ss -at4 | grep 30000 LISTEN 0 128 *:30000 *:* ESTAB 0 0 192.168.33.10:30000 192.168.33.1:64033 ESTAB 0 0 192.168.33.10:30000 192.168.33.1:64042
プロセス数とスレッド数を見てみる。
$ ps -L x | grep echo_server 2128 2128 pts/1 Sl+ 0:00 ./echo_server 2128 2129 pts/1 Sl+ 0:00 ./echo_server 2128 2130 pts/1 Sl+ 0:00 ./echo_server 2128 2131 pts/1 Sl+ 0:00 ./echo_server 2128 2132 pts/1 Sl+ 0:00 ./echo_server
プロセスは1個、スレッドは5個ある。Goを実行すると勝手にできるみたい。同時に接続するクライアントを6つにしてみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2128 vagrant 3u IPv4 18169 0t0 TCP *:30000 (LISTEN) echo_serv 2128 vagrant 5u IPv4 18171 0t0 TCP 192.168.33.10:30000->192.168.33.1:64033 (ESTABLISHED) echo_serv 2128 vagrant 6u IPv4 18173 0t0 TCP 192.168.33.10:30000->192.168.33.1:64042 (ESTABLISHED) echo_serv 2128 vagrant 7u IPv4 18221 0t0 TCP 192.168.33.10:30000->192.168.33.1:65256 (ESTABLISHED) echo_serv 2128 vagrant 8u IPv4 18223 0t0 TCP 192.168.33.10:30000->192.168.33.1:65265 (ESTABLISHED) echo_serv 2128 vagrant 9u IPv4 18225 0t0 TCP 192.168.33.10:30000->192.168.33.1:65270 (ESTABLISHED) echo_serv 2128 vagrant 10u IPv4 18227 0t0 TCP 192.168.33.10:30000->192.168.33.1:65275 (ESTABLISHED) $ ps -L x | grep echo_server 2128 2128 pts/1 Sl+ 0:00 ./echo_server 2128 2129 pts/1 Sl+ 0:00 ./echo_server 2128 2130 pts/1 Sl+ 0:00 ./echo_server 2128 2131 pts/1 Sl+ 0:00 ./echo_server 2128 2132 pts/1 Sl+ 0:00 ./echo_server
接続が6個になったにも関わらず、スレッド数は変わってない。接続数を10にしても変わらず。あくまでも1クライアントを1goroutineで捌いてるのであって、1クライアント1スレッドというわけではない。
Goのランタイム
I/OやスレッドをGoのランタイムがOSとの間に入って管理してくれている。
以下の本で、 GoのランタイムはミニOS って書いてあってすごく分かりやすい。
Webの記事はこっち。
イベント駆動で書いてみる
goroutineとchannelを使ってイベント駆動っぽいのも書いてみた。
package main import ( "fmt" "io" "net" ) type Event struct { Conn net.Conn Msg []byte } func asyncAccept(l net.Listener) chan net.Conn { ch := make(chan net.Conn) go func() { for { conn, err := l.Accept() if err != nil { panic(err) } _, err = conn.Write([]byte("Hello World!\r\n")) if err != nil { panic(err) } ch<-conn } }() return ch } func asyncRead(ch chan *Event, conn net.Conn) { go func() { buf := make([]byte, 1024) _, err := conn.Read(buf) if err != nil { if err == io.EOF { return } panic(err) } event := &Event{ conn, buf } ch<-event }() } func main() { l, err := net.Listen("tcp4", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") listener := asyncAccept(l) events := make(chan *Event) for { select { case conn := <-listener: asyncRead(events, conn) case event := <-events: _, err = event.Conn.Write(event.Msg) if err != nil { panic(err) } event.Conn.Close() } } }
goroutineとchannelを使うとGoの世界の中で色々な書き方ができて楽しいですね!
Google SignInするSPAとGoサーバー間のセッション管理
書き出したら長くなってしまい4回分になってしまった日記です。
今日は、Google SignInでログインした後、自分のサーバーサイドアプリケーションとのセッション管理について考えてみます。
IDトークン
GoogleSignInと自前サーバーサイドアプリケーションの連携は、ここに書いてある通りやれば良い。
https://developers.google.com/identity/sign-in/web/backend-auth
強調して書かれてるけど、ログイン後のコールバック関数で取得できるユーザー情報をサーバーサイドに渡してはいけない、って書いてある。IDトークンを渡しましょう。
下で取得してるuserIdをサーバーサイドに渡してはダメ。
onSignIn = (googleUser) => { userId = googleUser.getBasicProfile().getId()); }
代わりにIDトークンを渡す。
onSignIn = (googleUser) => { idToken = googleUser.getAuthResponse().id_token; }
Googleのドキュメントに明記はされてないのだけど、OpenID ConnectのIDトークンと一緒だと思う。
IDトークンの検証
クライアントは、GoogleからもらったIDトークンをサーバーへ送信する。
サーバーアプリケーションは、受け取ったIDトークンの検証をする必要がある。検証方法も先ほどのGoogleのドキュメントに書いてある。自分でやる方法、Googleから提供されてるライブラリを使う方法、Web APIとして公開されてるtokeninfoエンドポイントを使う方法、3つある。
今回は、tokeninfoエンドポイントを使う。
セッション管理
検証がうまくいったらサーバーサイドアプリケーションで、セッションを作成する(初めてログインしてきた場合はユーザーデータの作成もする)。
セッションをどう管理するか?セッション情報をどこに保持するかと、クライアントサーバー間でどうやりとりするかの2つ考えることがある。
セッション情報をどこに保持するか?
- サーバー側のデータストアに格納して、そのキーとなる文字列をクライアントとの通信で使う
- Pros
- クライアントとやり取りするデータがキーのみなので小さい
- Cons
- セッション情報をデータストアに保持するので、アクセスが必要(データストアは、アプリケーションサーバーとは別のサーバーになることが多くサーバー間通信が発生)
- アプリケーション内メモリに保持するのであれば、(通常複数ある)アプリケーションサーバー間で共有する工夫が必要
- もしくは、ロードバランスを工夫して同じサーバーにアクセスがいくようにする
- Pros
- JWTを使う
- Pros
- セッション情報をJWT自身に保持させることができるので、データストアから取り出す必要がない
- と思ったけど、本当にデータストアへのアクセスはなくせるのか?例えばサーバー側からセッション切れにしたりしたい場合はどうするのだろう?
- セッション情報をJWT自身に保持させることができるので、データストアから取り出す必要がない
- Cons
- セッション情報が増えるとJWTのサイズが大きくなる
- Pros
クライアントサーバー間でどうやりとりするか?
- Cookieを使う
- レスポンスボディとリクエストヘッダーで通信して、クライアント側での保存にはlocalStrage ( or sessionStrage )を使う
- Pros
- 特になし?
- Cons
- XSS脆弱性があると、JWTやセッションキーが外部に漏れる
- Pros
今回は、サーバーサイドのデータストアにセッション情報を格納、クライアントサーバー間はCookieでやりとりするようにする。
ここまでを実装
実装方針をまとめる。
- クライアントは、GoogleSignInに成功したらGoogleからもらったIDトークンをサーバーアプリケーションへ送る
- サーバーアプリケーションは、Googleのトークンエンドポイントを使ってIDトークンの検証をする
- サーバーアプリケーションは、検証に成功したらセッション情報を作成しデータストアに保存する
- サーバーアプリケーションは、Set-Cookieヘッダーをセットしたレスポンスを返す
- クライアントは、以降サーバーアプリケーションにCookieを送信することで、認証が必要なWebAPIを使うことができる
サーバーサイド
Goでサーバーサイド書いた。サーバーサイドでやることはさっきのステップのうち2,3,4。データストアは map
で代用。5555ポートで動かす。
HTML, JavaScriptも同じサーバーから配布する方法もあるけど、今回はCORSの設定をしてクライアントサーバー間で通信できるようにした。
コードは記事の最後に貼っておきます。
クライアントサイド
クライアントサイドの実装。上記の5ステップのうち、1,5がクライアントサイドでやること。
- ログインに成功したらGoogleからもらったIDトークンをサーバーサイドのエンドポイント(
http://localhost:5555/login
)へ送る - サーバーアプリケーションにCookieを送信することで、認証が必要なサーバーサイドのエンドポイント(
http://localhost:5555/my
)を使ってプライベート情報を取得できる
@@ -54,7 +54,22 @@ class App extends Component { } onSignIn = (googleUser) => { console.log('ID: ' + googleUser.getBasicProfile().getId()); - this.setState({authenticated: true}) + fetch("http://localhost:5555/login", { + method: "POST", + mode: 'cors', + credentials: 'include', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ "IDToken": googleUser.getAuthResponse().id_token }), + }) + .then(response => { + return fetch("http://localhost:5555/my", { + mode: 'cors', + credentials: 'include', + }) + }) + .then(response => { + response.json().then(data => console.log(data)) + this.setState({authenticated: true}) + }) } onSignOut = () => { const auth2 = this.state.gapi.auth2.getAuthInstance();
ログアウト
この時点でセッションは2つある。
- 自分のアプリケーションのセッション
- Googleのセッション
Googleサインインは認証を肩代わりしてくれだけとみなして、セッション管理は自分のアプリケーションのセッションだけとした方が管理しやすそう。なので、IDトークンをサーバーサイドに送った以降は、Googleのことは気にしない。
ログアウトボタンの挙動を変える必要がある。
あと、gapiをAppのstateに持たせるのも止める。 そうすれば、1日目の日記で採用しないことにした React-Google-Loginも使える。
まずは、React-Google-Loginを導入して、Appのstateからgapiを消す。
$ npm install react-google-login
@@ -1,5 +1,6 @@ import React, { Component } from 'react'; import { BrowserRouter as Router, Switch, Route, Link, Redirect } from 'react-router-dom'; +import { GoogleLogin } from 'react-google-login'; const PrivateRoute = ({authenticated, render, ...rest}) => ( authenticated ? ( @@ -22,34 +23,20 @@ const PrivateRoute = ({authenticated, render, ...rest}) => ( class App extends Component { state = { authenticated: false, - gapi: null + initialized: null } componentDidMount() { - this.downloadGoogleScript(this.initSignInButton) - } - downloadGoogleScript = (callback) => { - const element = document.getElementsByTagName('script')[0]; - const js = document.createElement('script'); - js.id = 'google-platform'; - js.src = '//apis.google.com/js/platform.js'; - js.async = true; - js.defer = true; - element.parentNode.insertBefore(js, element); - js.onload = () => callback(window.gapi); - } - initSignInButton = (gapi) => { - gapi.load('auth2', () => { - gapi.auth2.init({client_id: "<CLIENT_ID>"}) - .then( - (result) => { - if (result.isSignedIn.get()) { - this.setState({authenticated: true, gapi}) - } else { - this.setState({authenticated: false, gapi}) - } - }, - (err) => console.error(err) - ); + fetch("http://localhost:5555/my", { + mode: 'cors', + credentials: 'include', + }) + .then(response => { + if (response.status === 200) { + response.json().then(data => console.log(data)) + this.setState({ authenticated: true, initialized: true }) + } else { + this.setState({ authenticated: false, initialized: true }) + } }) } onSignIn = (googleUser) => { @@ -78,7 +65,7 @@ class App extends Component { this.setState({authenticated: false}) } render() { - if (!this.state.gapi) { + if (!this.state.initialized) { return ('Loading...') } return ( @@ -88,7 +75,7 @@ class App extends Component { <Top {...props} authenticated={this.state.authenticated} onSignOut={this.onSignOut} /> }/> <Route path="/login" render={props => - <Login {...props} onSignIn={this.onSignIn} gapi={this.state.gapi} authenticated={this.state.authenticated} /> + <Login {...props} onSignIn={this.onSignIn} authenticated={this.state.authenticated} /> }/> <PrivateRoute path="/private" authenticated={this.state.authenticated} render={props => <div>プライベートなページ</div> @@ -102,14 +89,6 @@ export default App; class Login extends Component { - componentDidMount() { - if (this.props.gapi) { - this.props.gapi.signin2.render('google-signin-button', { - 'onsuccess': this.props.onSignIn, - 'onfailure': (err) => console.error(err) - }); - } - } render() { if (this.props.authenticated) { const { from } = this.props.location.state || { from: { pathname: "/" } }; @@ -118,7 +97,14 @@ class Login extends Component { return ( <div> <h1>ようこそ!</h1> - <div id="google-signin-button"></div> + <div> + <GoogleLogin + clientId="<CLIENT_ID>" + buttonText="Login" + onSuccess={this.props.onSignIn} + onFailure={(err) => console.error(err)} + /> + </div> <Link to='/'>Topへ</Link> </div> )
ログアウト時の処理を変えて、サーバーサイドアプリケーションのログアウトAPIを呼ぶようにする。
@@ -60,9 +60,16 @@ class App extends Component { }) } onSignOut = () => { - const auth2 = this.state.gapi.auth2.getAuthInstance(); - auth2.signOut().then(() => console.log('sign out')); - this.setState({authenticated: false}) + fetch("http://localhost:5555/logout", { + method: "POST", + mode: 'cors', + credentials: 'include', + headers: new Headers({ 'Content-Type': 'application/json' }), + }) + .finally(response => { + console.log(response) + this.setState({authenticated: false}) + }) } render() { if (!this.state.initialized) {
おしまい
これでできた。セッションタイムアウトや強制ログアウトはサーバーサイドのセッション情報を適切に消してあげれば良い。JWTを使う場合はJWT自身の有効期限でセッションタイムアウトは実現できるけど強制ログアウトさせたい場合に良い方法あるのかな、という点は分かっていない。
書いたコード。