TECH PLAY

株式会社一休

株式会社一休 の技術ブログ

161

こんにちは。宿泊プロダクト開発部 UI開発チーム エンジニアの香西です。 半年ほど前に、一休.comとヤフートラベルで、クチコミ画像の投稿機能をリリースしました。 一休.comとヤフートラベルでは、ユーザーに画像をアップロードしてもらう機能の実装は前例が無かったため、試行錯誤しながらの開発となりました。 今回はその時の開発についてお話したいと思います。 背景 全体像 フロントエンドの実装 GraphQL のリクエスト送信 どのタイミングで画像をアップロードするか アップロード進捗状況を表示したい バックエンドの実装 画像のバリデーション 画像のデコード・エンコード (余談)JPEG のエンコードでメモリを大量に使用してハマった ベンチマーク計測 Amazon S3 バケットに画像をアップロード S3 署名付きURLを使用 使いやすいユーザーインターフェースを求めて 最後に 背景 クチコミを投稿する機能自体は、以前から存在していました。 実際に宿泊したユーザーだけがクチコミを投稿できるため、信憑性の高いクチコミではあるものの、投稿できるのは文字情報のみでした。近年、あらゆるサービスにおいてクチコミの重要性が高まってきているため、視覚情報を増やしてクチコミの質をあげるべく、画像を投稿できるようにしよう!ということで、クチコミ画像の投稿機能を開発することになりました。 全体像 画像の保管場所には、Amazon S3 を使用することにしました。 以前から一休.comとヤフートラベルで使用しており、imgIX と連携する仕組みがすでに整っていたため、サイト上で扱いやすいというのが一番の理由です。 もう一つの理由は、「外部からアクセスできる保管場所」「外部からアクセスできない保管場所」をそれぞれ用意したかったためです。 ユーザーが投稿した画像をそのままサイト上に公開するのではなく、社内で掲載チェック(不適切な画像を取り除く)をしてからサイト上に公開したいという要件がありました。つまり、掲載チェックが済んでいない画像は、「外部からアクセスできない場所」に置いておく必要があります。 Amazon S3 で「公開バケット」「非公開バケット」を用意し、それぞれ適切なアクセス設定を行うことで、今回の要件が実現できることがわかったので、Amazon S3 を使用することにしました。 ただし、掲載チェックが済んでいない「非公開バケット」にある画像であっても、 投稿者のユーザーに対してのみ表示したい(自分がどんな画像を投稿したか確認できるようにするため) 掲載チェックを行うために社内の管理画面上には表示したい という要件が出てきました。この点については、後述する「署名付きURL」の仕組みを使って、「非公開バケット」にある画像を特定の画面上でのみ表示できるようにしました。 フロントエンドの実装 GraphQL のリクエスト送信 Apollo でファイルをアップロードする方法はいくつかありますが、multipart リクエストを使用して mutation を実行し画像をアップロードする方法を採用しました。 uploadFile ( file: File ) : void { const input: ReviewImageInput = { id: 12345678 , file: null , } const formData = new FormData () formData.append ( 'operations' , `{ "query": "mutation($input: ReviewImageInput!) { registerReviewImage(input: $input) { id error __typename }}", "variables": { "input": ${JSON .stringify(input) } } }` , ) formData.append ( 'map' , '{ "0": ["variables.input.file"] }' ) formData.append ( '0' , file ) // 以下略 どのタイミングで画像をアップロードするか クチコミ投稿を行うときの画面の構成は、以下の三画面です。1~3の順に遷移します。 クチコミ入力画面(ここで投稿する画像を選択する) クチコミ入力確認画面(選択した画像を確認する) クチコミ投稿完了画面 どのタイミングで、画像アップロードのリクエストを送信するのがよいでしょうか。 クチコミ入力画面で、画像を選択するたびにリクエストを送信する? もしくは、クチコミ確認画面で「投稿する」ボタンを押したときに、全画像まとめてリクエストを送信する? 全画像まとめてアップロード処理を行った場合、処理が完了するまでユーザーを待たせることになり、画像枚数が多いと煩わしさを感じるかもしれません。 また、アップロードに失敗したときに最初から画像を選択し直すとなるとユーザーのモチベーションが下がってしまうので、どの画像が失敗したのかユーザーに伝えつつ成功した画像のみ復元して...といったケアをしようとすると、処理がどんどん複雑化していきそうです。 開発メンバーで検討した結果、ユーザーが画像を選択したタイミングで、1枚ずつ画像アップロードのリクエストを送信することにしました。 それがユーザーにとって最もスムーズな体験であり、かつ実装上もシンプルだという結論に至りました。 アップロード進捗状況を表示したい 各画像のアップロード処理がどのくらい進んでいるのか?が視覚的に分かると、ユーザーにとって安心感があると思います。 しかし、fetch API / Apollo client ではアップロードの進捗を確認する機能がサポートされていなかったため、XMLHttpRequest の upload プロパティで進捗を監視し、プログレスバーでアップロードの進捗を表示するようにしました。 fetch ( url: string , opts: any , onProgress: ( ev: ProgressEvent < EventTarget >) => void , ) : Promise < string > { return new Promise (( resolve , reject ) => { const xhr = new XMLHttpRequest () xhr.open ( opts.method || 'get' , url ) xhr.timeout = 60000 for ( const k in opts.headers || {} ) xhr.setRequestHeader ( k , opts.headers [ k ] ) xhr. onload = () => resolve ( xhr.response ) xhr.onerror = ( e ) => reject ( e ) xhr.ontimeout = ( e ) => reject ( e ) if ( xhr.upload ) xhr.upload.onprogress = onProgress xhr.send ( opts.body ) } ) } , バックエンドの実装 フロントエンドから画像アップロードのリクエストが飛んできたとき、大まかに以下の4つの処理を行っています。 画像のバリデーション 画像のデコード・エンコード S3 非公開画像用バケットに画像をアップロード データベースに画像情報を登録 画像のバリデーション 画像の条件については、 OWASP のチートシート や他サービスなどを参考にしながら以下の仕様に決めました。 チェック項目 制限 1画像ファイルサイズ上限 10MBまで 画像ファイル種類(MIMEタイプ) image/jpeg, image/png 画像ファイルの縦横比 4:1まで許容 画像ファイルの最小幅 80px ユーザーから送信される content-type ヘッダーは偽装される可能性があるため信頼せず、画像のバイナリデータの先頭 512byte を見てファイル種類(MIMEタイプ)の判定を行うようにしました。 Go の http パッケージの DetectContentType を使用しています。 head := make ([] byte , 512 ) n, err := r.Read(head) if err != nil && !errors.Is(err, io.EOF) { return nil , err } contentType := http.DetectContentType(head[:n]) if contentType != "image/jpeg" && contentType != "image/png" { return nil , ErrRegisterInvalidType } 画像のデコード・エンコード セキュリティ観点から、Amazon S3 に画像をアップロードする前に、画像のバイナリデータに含まれている Exif 情報(位置情報・撮影日時など)を削除する必要があります。 Exif 情報には画像の向き(Orientation)が含まれているため、この情報は削除したくありません。 Go の imaging パッケージを使用してデコードを行うと、Exif 情報が取り除かれた Image が返ってきます。 また、引数に imaging.AutoOrientation(true) のオプションを渡すと画像の向き(Orientation)を自動で適用してくれます。 img, err := imaging.Decode(r, imaging.AutoOrientation( true )) if err != nil { return nil , err } デコードで Exif 情報を取り除いた Image を、今度はエンコードし、Amazon S3 にアップロードする画像データを用意します。 JPEG は、引数に imaging.JPEGQuality(75) のオプションを渡して品質を指定することができます。 デフォルト値 95 のままエンコードすると、画像によってはファイル容量が2倍程度大きくなるケースが見受けられたため 75 を指定することにしました。 import ( "bytes" "github.com/disintegration/imaging" ) func (i *Image) Encode() (*bytes.Reader, error ) { b := new ( bytes.Buffer ) if i.ContentType == "image/jpeg" { err := imaging.Encode(b, i.Image, imaging.JPEG, imaging.JPEGQuality( 75 )) if err != nil { return nil , err } } else { err := imaging.Encode(b, i.Image, imaging.PNG, imaging.PNGCompressionLevel(png.DefaultCompression)) if err != nil { return nil , err } } return bytes.NewReader(b.Bytes()), nil } (余談)JPEG のエンコードでメモリを大量に使用してハマった 画像のデコード・エンコードでは imaging パッケージを使用したとお話しましたが、開発当初は Go 標準 の image パッケージを使用して、デコード・エンコードを行い、Exif 情報を削除しようとしていました。 ところが、いざ処理を実行してみるとすごく重かったのです。 testing パッケージでベンチマークを測定したところ、メモリを大量に使用していることが判明しました。 デバッグしながら調査していくと、image パッケージの jpeg.Encode の処理が怪しそうだという事がわかってきました。 さらに深堀してみると、画像データに書き出している処理 writeSOS のなかで、 rgba ycbcr がどちらも nil になっていたため、 toYCbCr の処理に入っていました。 // writeSOS writes the StartOfScan marker. func (e *encoder) writeSOS(m image.Image) { // 中略 default : rgba, _ := m.(*image.RGBA) // nil になっていた ycbcr, _ := m.(*image.YCbCr) // nil になっていた for y := bounds.Min.Y; y < bounds.Max.Y; y += 16 { for x := bounds.Min.X; x < bounds.Max.X; x += 16 { for i := 0 ; i < 4 ; i++ { xOff := (i & 1 ) * 8 yOff := (i & 2 ) * 4 p := image.Pt(x+xOff, y+yOff) if rgba != nil { rgbaToYCbCr(rgba, p, &b, &cb[i], &cr[i]) } else if ycbcr != nil { yCbCrToYCbCr(ycbcr, p, &b, &cb[i], &cr[i]) } else { toYCbCr(m, p, &b, &cb[i], &cr[i]) // ここの処理に入っていた } prevDCY = e.writeBlock(&b, 0 , prevDCY) } scale(&b, &cb) prevDCCb = e.writeBlock(&b, 1 , prevDCCb) scale(&b, &cr) prevDCCr = e.writeBlock(&b, 1 , prevDCCr) } } // 以下略 toYCbCr の処理なかを見ていくと、 Image.At を使って各ピクセルの色情報(RGBA)を取得していました。ここでメモリを大量に使用していました。 // toYCbCr converts the 8x8 region of m whose top-left corner is p to its // YCbCr values. func toYCbCr(m image.Image, p image.Point, yBlock, cbBlock, crBlock *block) { b := m.Bounds() xmax := b.Max.X - 1 ymax := b.Max.Y - 1 for j := 0 ; j < 8 ; j++ { for i := 0 ; i < 8 ; i++ { r, g, b, _ := m.At(min(p.X+i, xmax), min(p.Y+j, ymax)).RGBA() // ここで Image.At yy, cb, cr := color.RGBToYCbCr( uint8 (r>> 8 ), uint8 (g>> 8 ), uint8 (b>> 8 )) yBlock[ 8 *j+i] = int32 (yy) cbBlock[ 8 *j+i] = int32 (cb) crBlock[ 8 *j+i] = int32 (cr) } } } こちらの issue でも言及されており こちら で修正されていましたが、 ycbcr が nil ではない場合に yCbCrToYCbCr の処理に入るようになっているため、そもそも ycbcr が nil になってしまうと、 toYCbCr の処理のほうに入って Image.At によってメモリが大量に使われてしまう、ということが起きていました。 ちなみに、JPEG のデータがどうなっているかを理解する際、 Ange Albertini さんの作ったイメージに助けてもらったので貼っておきます。 引用: https://github.com/corkami/pics/blob/master/binary/JPG.png さてどうしようかと頭を悩ませていましたが、社内メンバーに助言をもらい imaging パッケージを使ってデコード・エンコードしてみたところ、メモリの使用量が抑えられたのでした。 さらには Orientation も自動設定してくれるので、自力で Orientation を設定するコードも不要になりました。 imaging パッケージの Encode でも、内部では image パッケージの jpeg.Encode を使っていますが、事前に rgba を作成し、 jpeg.Encode に rgba を渡していました。 // Encode writes the image img to w in the specified format (JPEG, PNG, GIF, TIFF or BMP). func Encode(w io.Writer , img image.Image, format Format, opts ...EncodeOption) error { cfg := defaultEncodeConfig for _, option := range opts { option(&cfg) } switch format { case JPEG: if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Opaque() { rgba := &image.RGBA{ Pix: nrgba.Pix, Stride: nrgba.Stride, Rect: nrgba.Rect, } return jpeg.Encode(w, rgba, &jpeg.Options{Quality: cfg.jpegQuality}) // jpeg.Encode に rgba を渡していた } return jpeg.Encode(w, img, &jpeg.Options{Quality: cfg.jpegQuality}) // 以下略 そうすることで、例の writeSOS の処理のなかで rgba が nil にならず、 rgbaToYCbCr の処理のほうへ入るようになりました。 rgbaToYCbCr の処理のなかではすでに色情報(RGBA)が分かっているため Image.At を実行する必要もなく、大量にメモリを使うことなくエンコードが出来ていました。 // writeSOS writes the StartOfScan marker. func (e *encoder) writeSOS(m image.Image) { // 中略 for i := 0 ; i < 4 ; i++ { xOff := (i & 1 ) * 8 yOff := (i & 2 ) * 4 p := image.Pt(x+xOff, y+yOff) if rgba != nil { rgbaToYCbCr(rgba, p, &b, &cb[i], &cr[i]) // こちらの処理に入るようになった } else if ycbcr != nil { yCbCrToYCbCr(ycbcr, p, &b, &cb[i], &cr[i]) } else { toYCbCr(m, p, &b, &cb[i], &cr[i]) // もともとは、ここの処理に入っていた } prevDCY = e.writeBlock(&b, 0 , prevDCY) } // 以下略 ベンチマーク計測 imaging パッケージを使った場合・image パッケージのみを使った場合でベンチマークを比較してみると、その差は明らかです。 imaging パッケージを使ったほうが、処理速度・メモリ割当領域・メモリアロケーション回数が小さく高パフォーマンスであることが分かります。 $ go test -bench . -benchmem goos: windows goarch: amd64 cpu: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz BenchmarkImagingPkg-16 3 435976333 ns/op 71321248 B/op 122 allocs/op -- imaging パッケージ使用 BenchmarkImagePkg-16 2 614217700 ns/op 120224332 B/op 12193780 allocs/op -- image パッケージ使用 以下がベンチマーク測定のために用意したコードです。 ※確認用のため処理を簡易化し、エラーハンドリングはしていません。 image パッケージを使用していたときは、わざわざ Orientation を設定する関数 setOrientation を書いて、画像データが持つ Orientation の値を見て Image を回転させる、ということをやっていました。 import ( "bytes" "image" "image/jpeg" "os" "testing" "github.com/disintegration/imaging" "github.com/rwcarlsen/goexif/exif" ) // imaging パッケージ使用 func BenchmarkImagingPkg(t *testing.B) { for i := 0 ; i < t.N; i++ { file, _ := os.Open( "C://dev/test-exif-orientation-2842.jpg" ) defer file.Close() // デコード img, _ := imaging.Decode(file, imaging.AutoOrientation( true )) // エンコード b := new ( bytes.Buffer ) _ = imaging.Encode(b, img, imaging.JPEG, imaging.JPEGQuality( 75 )) } } // image パッケージ使用 func BenchmarkImagePkg(t *testing.B) { for i := 0 ; i < t.N; i++ { file, _ := os.Open( "C://dev/test-exif-orientation-2842.jpg" ) defer file.Close() // デコード img, _, _ := image.Decode(file) _, _ = file.Seek( 0 , 0 ) // 画像の Exif 情報から Orientation を取得し、デコードした Image に Orientation を設定する ex, _ := exif.Decode(file) tag, _ := ex.Get(exif.Orientation) orientation, _ := tag.Int( 0 ) newImg, _ := setOrientation(img, orientation) // エンコード b := new ( bytes.Buffer ) _ = jpeg.Encode(b, newImg, nil ) } } func setOrientation(img image.Image, orientation int ) (image.Image, error ) { var newImg image.Image // @see: https://www.jeita.or.jp/japanese/standard/book/CP-3451E_J/#target/page_no=34 switch orientation { case 1 : newImg = img case 2 : newImg = imaging.FlipH(img) case 3 : newImg = imaging.Rotate180(img) case 4 : newImg = imaging.FlipV(img) case 5 : newImg = imaging.Rotate90(img) newImg = imaging.FlipH(newImg) case 6 : newImg = imaging.Rotate90(img) case 7 : newImg = imaging.Rotate270(img) newImg = imaging.FlipH(newImg) case 8 : newImg = imaging.Rotate270(img) default : return nil , errors.New( "invalid value: " + strconv.Itoa(orientation)) } return newImg, nil } Amazon S3 バケットに画像をアップロード 「非公開バケット」に画像をアップロードするときは、 AWS SDK for Go の PutObjectWithContext を使用しています。 import ( "context" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/s3" ) func (c *client) Put(ctx context.Context, resource string , input PutInput) error { in := &s3.PutObjectInput{ Bucket: aws.String(input.Target.Bucket), Key: aws.String(input.Target.Key), Body: input.Body, ContentType: aws.String(input.ContentType), ContentLength: aws.Int64(input.ContentLength), } ctx = httptrace.WithSpan(ctx, c.service, resource, map [ string ]any{ "http.content_length" : input.ContentLength, "http.content_type" : input.ContentType, }) _, err := c.s3.PutObjectWithContext(ctx, in) return err } S3 署名付きURLを使用 掲載チェックが済んでいない「非公開バケット」にある画像であっても、 投稿者のユーザーに対してのみ表示したい(自分がどんな画像を投稿したか確認できるようにするため) 掲載チェックするために社内の管理画面上には表示したい という話を冒頭でしました。 これを実現するために 「署名付きURL」 の仕組みを使用することにしました。 外部からアクセスできないように制御している画像に対して、署名付きURLを発行することができます。署名付きURLを <img> タグの src に指定し、特定の画面上に画像を表示しています。 なお、署名付きURLの有効時間は、自由に指定することができます。 import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/s3" ) func (c *client) Presign(bucket string , key string ) ( string , error ) { req, _ := c.s3.GetObjectRequest(&s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) url, err := req.Presign( 5 * time.Minute) if err != nil { return "" , err } return url, nil } 使いやすいユーザーインターフェースを求めて フロントエンド、バックエンド、両方の実装がだいたい完了して動作する状態になったらすぐにデモ環境にデプロイし、プロジェクトメンバーに触り心地を確認してもらうようにしました。UI開発チームでは、他のプロジェクトにおいても、なるべく早い段階でデモ環境にデプロイしてみんなで触ってみる、ということを大切にしています。 そこで出てきたフィードバックをもとに修正し、再びユーザー体験を確認し...を繰り返して改善していきます。今回、サービス初の画像アップロード機能ということで、実際に触ってみるとさまざまな問題が出てきましたが、デザイナーと密に連携しながらユーザーインターフェースを詰めていきました。 ▽ Slack 上のフィードバックのやりとり 最後に クチコミ画像の投稿機能の他にも、社内での掲載チェック機能や、非公開画像の削除機能など、関連機能がいろいろあるのですが、今回は画像の投稿機能に焦点をあててお話してみました。 UI開発チームでは、ユーザー体験に関わる部分はフロントエンド・バックエンドに関わらず開発できるため、全体像を把握しながら実装することができます。 一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。
アバター
プロダクト開発部デザイナーの河村恵です。昨今、デザインシステムを用いた「UI / UXの品質担保」「トンマナの統一」「再利用性の向上による開発効率のUP」が注目されつつある中、一休.comでも本格的なデザインシステムの構築を目指し、プロジェクトが発足しました。 本記事では、プロジェクト発足から一休.comならではの課題・実際に作っているUIガイドラインについてなど赤裸々にお話ししたいと思います。 目次 1) プロジェクト発足に至る経緯 2) プロジェクトの進め方 3) 実際に作っているUIガイドライン 4) まとめ 1.プロジェクト発足に至る経緯 CTOからのフィードバック そもそも「デザインシステム導入しよう!」となったきっかけは、CTO(以下直也さん)から一休.com と Yahoo! トラベルの2システムを一つに統合することで実現した、Yahoo!トラベルのリニューアル(詳しくは こちら )に際して「デザイナーとエンジニアのコラボレーションが上手く出来ていない」という指摘を受けたことからでした。 Yahoo!トラベルリニューアルはUI/UXの改善を実施した上で、一休.com・Yahoo!トラベルと2つの異なるサービスのUIコンポーネントを共通化し、一貫したユーザー体験と開発体験の向上を実現する一大プロジェクトでした。 しかしいざ開発が進むとサービス毎の微妙なデザインの違いで「提供する機能は同じだが色が違うだけ」のようなUIコンポーネントがいくつも作成される事態が発生してしまいました。 当然、ほぼ同じ責務を持ったコンポーネントがそれぞれのサービスに存在するので、修正があった場合も同じ箇所を修正するという非効率な開発になってしまっていました。 これは明らかにエンジニアとの連携不足が招いた事態でした。 Yahoo!トラベルリニューアルという一大プロジェクトにも関わらず、私の初動によって本来在るべき実装が行われなかったことへの猛省と、同時に必ずこのままでは終わらないという決意に変わりました。 エンジニアとの共通言語 この事態に対して、直也さんはエンジニアとデザイナーがコミュニケーションをとるための共通言語が必要だと考えていました。 その上で「デザイナーが感覚でデザインしていた部分をちゃんと言語化・型化する。そのためにデザインシステムを導入してみてはどうか?」と提案してくれました。 さらに社内にはデザイナー・エンジニア含めデザインシステムに関する知見を持つメンバーがいなかったため、過去に、はてな、クックパッドなどの経験からデザイナーとエンジニアの連携についての知見が深い池田拓司さんより指導を受けられるよう手配してもらい、池田さんを講師に迎えデザインシステムプロジェクトがスタートしました。 2.プロジェクトの進め方 figma導入 まず、デザインシステムの構築を行う上で最初に行ったのが、デザインツール「figma」の導入でした。これまで一休のデザイナーはAdobe XDをメインのプロトタイピングツールとして使用していました。XDでもコンポーネントの作成やエンジニアにcssコードを展開できる機能等はありますが、多くの会社でfigmaによるUIガイドラインの作成事例が公開されている点や、様々なアセットを大量に管理するのに適していることなどから、デザイナー間やエンジニアとのコミュニケーションも取りやすいfigmaの導入が決まりました。 デザインシステムでやること 一休のデザインシステムプロジェクトでは、大きく分けて3つの実施項目を行いました。 1. UIガイドライン及びFigmaでのデザインデータの作成 2. 1で定義したデザインデータを元に実装上でコンポーネント化 3. ドキュメント作成 デザイナーのメインタスクは1.のUIガイドライン作成になりますが、2.の実装に落とし込む作業の際にエンジニアとの密なコミュニケーションが必要となりました。お互いに意見を交わしながら作業を行いました。3.のドキュメント作成に関しては、デザイナー側のドキュメントはfigma上にルールを言語化したページを設け、エンジニア側は開発の際デザインシステムに意識を向けてもらえるようGitHub上に総合的なガイドブックとなるドキュメントを残すことにしました。 3.実際に作っているUIガイドライン 一休.com/Yahoo!トラベルとの共通部分、差分を可視化する 実際のUIガイドラインは、一休.com と Yahoo!トラベルとの共通部分、差分を可視化することを第一の目的としました。 UIガイドラインは下記の3つの要素で構成しました。 Guidelines… 色、タイポグラフィー、スペース、角丸、シャドウなどのデザインの基本要素 Master… 最新の本番画面のデザインデータ Components… 特定のページのみではなく、サイト全体で汎用的に使用するデザインパーツ 色やタイポグラフィーといった「Guidelines」、本番画面と同一のデザインデータである「Master」に関しては左右に一休.com、Yahoo!トラベルを並べることで比較可能としました。 「Components」の各コンポーネントは、figmaの状態管理機能であるVariantsを利用して、IK =一休.comとY=Yahoo!トラベルのステートをServiceで定義し比較可能としました。 4.まとめ デザインシステム導入を進める中で、小さい粒度(ボタン、ラジオボタン、チェックボックス、タブ等)のコンポーネントに関しては、一休.comとYahoo!トラベルでファイルが分かれていた部分の共通化を行うことができました。その過程には、デザイナー間のコミュニケーション(一休とヤフトラで分かれていたデザインの統一=より高いクオリティーで統一)、デザイナーエンジニア間のコミュニケーション(デザインデータの不備指摘や、様々なケースの掲示等)など、多くの会話と時間を要しましたが、一旦フローが出来てからはスムーズに進行できました。 これまで一休ではデザインの仕組み化をエンジニアとデザイナーと共同で行う機会がありませんでしたが、チャンスをくれた直也さん、親身に指導していただいた池田さんのサポートもあり、プロジェクトを着実に前に進めることができました。 引き続き一休.com、Yahoo!トラベルのデザインが一定のクオリティを担保し続けられるよう、「美しく機能的なサイトで宿泊先を選んでいる」という、ユーザーの心地よい体験を叶えるべく、デザインシステム構築を進めてまいります! 一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。 hrmos.co hrmos.co hrmos.co
アバター
一休と、「出前館」を運営する株式会社出前館でオンライン・イベントを開催します。 今回はフロントエンド開発をテーマとして両社のエンジニアにお話いただきながら、様々な学びを得ることを目的としたイベントです。 イベント後のアーカイブ動画を公開しませんのでご興味がある方はぜひご参加ください。 日時:6/30(木) 18:00~20:00 費用:無料 場所:オンライン(Zoom) お申し込みは以下のリンクからお願いします。 ikyu.connpass.com 発表テーマ プロダクトのタイプ別 GraphQL クライアントの選び方(一休 / 管理画面 / 新規サービス) 一休/Yahooトラベル、マルチブランドにまたがるデザインシステム 20年続いているサービスの注文画面をGraphQLを活用して作り直した話 ライフインフラとなるために進めているアクセシビリティ向上への取り組み 多くの方のご参加をお待ちしております!
アバター
新規事業本部、エンジニアの所澤です。 今回は4/19にリリースした一休の新サービス『YADOLINK(ヤドリンク)』についてお話します。 yadolink.com YADOLINKとは? サービス立ち上げの経緯と開発体制 技術選定 React or Vue ? Next.js を使うのか? Apollo Server、GraphQL、そして Universal TS 一休と新規開発について YADOLINKとは? TOPページ YADOLINKはホテル・旅館に特化した写真投稿SNSです。 「宿好きが集まり、心置きなく宿愛を語れ、それが誰かの役に立つ幸せな場所」 となることを目指しています。 サービス立ち上げの経緯と開発体制 YADOLINKは一休.com のマーケターの提案からボトムアップで事業化が決まったサービです。 開発チームは社内公募で集められ、ディレクター、デザイナー、エンジニア2名の合計4人で約半年ほどの開発期間を経てリリースされました。 技術選定 技術選定にあたっては、 既存サービスの技術スタックに縛られない "もっとも良い選択肢" を選ぶこと 開発スピードを重視してなるべく素朴な作りにすること を心がけました。 以下がYADOLINKの技術スタックをまとめた図です。 それぞれの詳細はまた別エントリで述べますが、簡単に各技術について所感を書いていきます。 React or Vue ? 一休.com も 一休.com レストラン もどちらも Vue を使って開発していまが、今回はあえて React を選択しました。 Vue を選ばなかった理由は、 Vue2系の型検査に不満があったこと と 開発開始時点では Nuxt 3 の正式リリースの目処が立っていなかった からです。 既存サービスの開発チームとの人員の入れ替えがあるのであればコンテキスト・スイッチを減らすために Vue を選んだかもしれませんが、YADOLINKは完全に独立した開発チームなので React を選択することができました。 開発開始から2週間ほど経った頃にはすっかり React にも慣れ、Vue を使うのとそう変わらない速度で開発できるようになりました。 型に対する不満・不安もなくなり、堅牢な型に守られて快適な開発ができています。 Next.js を使うのか? 将来的にSEOで集客をしたいので、SSRができる Next を選びました。また、Next のレールに乗って開発効率を上げたいという狙いもありました。 現時点ではSSRはしていませんし、Vercel にデプロイしていないのでISRもできません。Next の真価をフルに発揮する構成ではありませんが、それでも Next を採用するメリットは十分にありました。 Zero Config で開発が始められることは React の開発経験があまりなかった私にとってもは非常にありがたかったですし、SWC はフロントエンドの「コンパイル遅すぎ問題」を解決してくれました。 Apollo Server、GraphQL、そして Universal TS YADOLINKはフロントエンド開発の比重が大きいアプリケーションなので、2人のエンジニアがフロントエンド・バックエンドで役割分担をするのが効率的ではありません。一人のエンジニアがフロントエンドとバックエンド両方触ってもストレスが少ないように、サーバーは Node を採用して開発言語を TypeScript に統一しました。 Prisma, Nexus, GraphQL Code Generator を採用したこともあっ、てDBからフロントエンドの Component まで、アプリケーションの隅々まで型情報が行き渡り非常に安心して開発ができています。YADOLINKは現時点ではあまり複雑なロジックもないこともあり、型がアプリケーションの品質の多くの部分を保証してくれています。 コンパイルが通れば不具合がほぼない という状態です。 TypeScript の強力な型システムの力をフルに引き出して効率的な開発ができています。 一休と新規開発について さて、簡単ではありますが新サービスの開発についてご紹介しました。 採用面接に出るとしばしば「一休は成熟したサービスを運営しているが、新規開発することはあるのか?」と質問を受けます。 おそらく、既存サービスの改修だけでなく新規の開発でバリバリコードを書きたい、という思いがあっての質問だと思います。 答えは『YES』。既存サービスにも大規模な機能を追加を頻繁に行っていますし、今回のYADOLINKのようにまったくの新サービスを開発することもあります。 (実は、今もいくつか新サービスの開発が動いています) ちょっとでも興味を持ってくれた方がいたらカジュアル面談などで気軽にお話しましょう! hrmos.co hrmos.co meety.net
アバター
今から二ヶ月ほど前、10/1 に Yahoo! トラベル のリニューアルが完了しました。このリニューアルは、一休.com と Yahoo! トラベルの2システムを一つに統合することで実現しました。 ご存知の通り、ヤフーと一休は同じグループに所属する企業です。ざっくりいうと「同じグループで2つの宿泊予約システムを開発し続けるのは効率が悪いよね」という話があり、今回のシステム統合に至っています。 Yahoo! トラベルと一休のシステム統合は、(1) 2017年頃にホテルの空室管理や予約、決済、精算業務などを担うバックエンドのシステム統合を行い、そして (2) 今回 2021年春先から半年ほどをかけて、ユーザーが利用する画面も含めた全面統合を行いました。全面統合は総勢で 50名ほどのディレクター、エンジニア、デザイナーが関わる一休的には大きな規模のプロジェクトになりましたが、目立ったトラブルもなく、先日無事リリースすることができました。 それぞれ数百万人以上のユーザーを抱える二つのサービスの統合・・・となると、聞こえ的にもまあまあ派手な部類かなと思います。その裏側を少しだけですが、お伝えしていこうと思います。 デジタル・トランスフォーメーション (DX) が進んでいる宿泊予約 まず前提として宿泊予約の業務は、おそらく世間での印象よりも DX が進んでいるという背景を先に。 Yahoo! トラベルや一休は、全国のホテルや旅館から空室情報 (「在庫」と呼ばれます) を預かり、それをオンラインで販売するサイトです。オンラインの旅行会社ということで OTA (Online Travel Agency) と総称されています。 ホテルや旅館の方々は日々 OTA の管理画面から自分たちの施設の空室情報や料金を手で入力して・・・と言いたいところですが、それはずいぶんと昔の話です。 2010年代前半頃にはホテルのフロント業務を担うシステムである「PMS (Property Management System) 」、ホテルからみると複数ある OTA に在庫や料金情報を一括登録してくれる「サイトコントローラー」、そして我々 OTA のシステム。これらバリューチェーンが API でリアルタイム連携するデジタル化が完成しています。 OTA は複数あれど、部屋数は有限。各 OTA に登録された在庫を予約やキャンセルが行われるたび同期しないとオーバーブッキングが発生してしまいます。PMS、サイトコントローラー、OTA 間の情報の流れがデジタル化されたことで、在庫はほぼリアルタイムに同期されているのが昨今です。(そしてホテルの PMS は、そこから更にホテルの基幹システムへ繋がっています) 現代の OTA は、サイトコントローラと通信して在庫・料金設定を受け取りそれを販売、宿泊施設と連絡を取って顧客の決済、予約〜宿泊・精算までのプロセスを管理するのが主な仕事というわけです。ホテルから見ると予約業務のフロントエンドである PMS さえ操作していれば、複数の OTA から予約が集まってくる・・・ざっくりいうとそういうシステム連携が実現されています。 この宿泊予約バリューチェーンのデジタル化により、OTA では日々の空室状況をもとに需給に応じてルーム価格を変動させるのが容易になりました。結果、航空券などと同様、旅行業界はダイナミック・プライシングが当たり前の世界になっています。 話は逸れますが、ほぼ同じ構造のデジタルトランスフォーメーションが、ホテル宿泊業界に10年遅れて、飲食店業界でも進み始めています。なぜ宿泊予約で10年先にそれが起こり、遅れていま飲食業界なのか・・・というテーマも面白い話なので、また別途書いてみたいと思います。 2017年に実施した Yahoo! トラベルと一休.com のバックエンドシステム統合 さて、一休が Zホールディングスグループ (当時は Yahoo! Japan グループ) に参画した 2016 年には、Yahoo! トラベルと一休.com は、当然、それぞれ別のシステムとして動いていました。営業を含む組織も二つの会社に分かれて存在していました。 これは経営的な全体最適の視点からいくと、たとえば同じホテルにグループ内の二つの会社から営業にいってしまうし、同じ機能をいつも二回開発する必要があったりと何かと効率が悪いわけです。複数あるサイトコントローラーともそれぞれのシステムから接続して、それぞれが在庫をもらっているという状況です。「会社が一緒のグループになったのだから、システムも一本化して合理化しようじゃないか」と当然考えますよね。結果のシステム統合プロジェクトです。 なおシステムを統合するといっても、サービスまでは統合しません。一休には一休のお客さんがいてヤフーにはヤフーのお客さんがいるし、一休は高級・ラグジュアリー指向でホテルや旅館を厳選していて、一方のヤフーはビジネスやレジャーを得意としています。顧客基盤も、商品もブランドも違うので「システムは一つに。サービスは二つに」というのがシステム統合の目指すべきところとなりました。 ここまではいいとして、ご想像の通り難しいのはここからです。 特に、どちらのシステムを主体としてマージを行っていくのか (あるいはどちらも捨てずに玉虫色のシステム統合を行うのか)。 ここが最大の論点になります。喧喧諤諤の議論を経て結論「一休のシステムを主とし、ヤフーのバックエンドシステムは捨てる。ヤフートラベルのフロントから業務処理を一休のバックエンドに API 通信で依頼する方式で統合」となりました。 2社間のシステム統合は、ここの意志決定が非常に大事・・・というのが私なりの持論なのですがその辺りの考察は最後に回しましょう。 まだお互いグループになったばかりでいろいろ大変ではありましたが、バックエンドのシステム統合を行って営業活動を一本化することで様々な業務密度があがり、そのシナジーで双方大きく業績を伸ばすことができました。ホテル施設からそれぞれのサービスへの在庫のデータフローも一本化されて綺麗になりました。 2021 年、フロントエンドも含めたシステムの全面統合 バックエンドシステムの統合を行ってから数年間は一休 / ヤフーそれぞれの販売面はそれぞれで開発・運用を行ってきました。 たとえば昨年の Go To Travel の対応なども、バックエンドの業務処理開発は一休にてまとめて実施。フロントはそれぞれが持っているので、それぞれのサービスで対応、みたいな形で実施しています。 ここで改めてグループの宿泊予約事業全体をここから更に成長させていくには、高級にフォーカスしている一休よりもより市場規模の大きいセグメントを対象にしている Yahoo! トラベルの成長が重要と考えました。 一方、一休はその黎明期から OTA をやってきたこともあってより良い顧客体験、ユーザーインタフェース作りには自信があります。そこで一休が構築した UI や顧客体験を Yahoo! トラベルにも横展開できるようバックエンドだけでなくフロントエンドも一休とシステム統合、今後は Yahoo! トラベルを一休が、一休.com と一緒に開発し Yahoo! トラベルのユーザー体験を大きくアップグレードしよう・・・という話になったのが直近の全面統合です。 システムを統合するといっても、やはり Yahoo! トラベルと一休.com は別のブランドとしてそれぞれのサイトでサービス提供していくので、Yahoo! トラベルの基礎になっている顧客体験部品・・・たとえばヤフードメイン、ログインアカウント、ロイヤリティプログラム (プレミアム会員特典)、PayPay や T ポイントなどの決済・ポイント手段・・・は維持しながらもシステム統合を行ってユーザーインタフェースや CRM は一休.com の体験に寄せるというのを基本方針としました。 分かりやすさ重視でざっくり説明していますが、簡単なプロジェクトではありません。冒頭でも触れた通りディレクター、エンジニア、デザイナー総勢で 50名が関わる大きな取り組みになりました。 ヤフー株式会社と株式会社 一休は、同じグループ企業でも、別会社である このシステム統合特有の難しさとして「ヤフーと一休は同じグループではあるものの、実際は別の会社である」ことからシステム統合にいろいろな制約がかかるという点がありました。 たとえば、プライバシーの問題。 Yahoo! トラベルの利用者はヤフーのユーザーであって、一休のユーザーではありません。従って、Yahoo! トラベルのユーザー情報を安易に一休のそれと照合するわけにはいきません。その逆も然りです。然るべきタイミングで第三者同意を得て、その上で情報のやりとりを行う必要があります。 たとえば、企業秘密の技術の問題。 例えば Yahoo! Japan のログインセッションを、一休のシステムで復元するかどうか。その復元にはヤフーがもつ暗号ロジックや、セッション管理ロジックが必要になりますが当然それらは企業秘密なので、一休がもらうことはできません。つまりログインセッションを簡単には共通化できない。でも、Yahoo! Japan でログインしているのに Yahoo! トラベルで別途またログインが要求される・・・ではいけてない。 同じ企業の中で2つあるシステムを統合する場合には、考えなくてもよい課題も多いですね。これらの制約を考慮しつつもドメインは変えず、ログインも当然 Yahoo! ID でのログインができて PayPay も使えて、UI は 一休.com のそれを継承している・・・というシステムに仕上げる必要がありました。 システム統合の要所を決める 本システムの統合はこの2社間の制約が大きかったので、この制約を前提としたシステム連携方式全体の設計を考えるところからスタートしました。 あくまで今後の Yahoo! トラベルの運営は一休が主体となるため、一休のシステムを主としてそちらに寄せるのが基本方針とする travel.yahoo.co.jp ドメインを利用しながらも、アプリケーション実装は一休のものを使う。インフラは一休のそれにデプロイする ・・・ ではヤフーのドメインを直接一休のエッジサーバーに割り当てるのか、ヤフーのエッジサーバーから L7 ルーティングするのか。一部ヤフー側に残るページが存在するためヤフーのエッジサーバーで制御し、L7 ルーティングで一休システムにトラフィックを割り当てる ログインセッションは Cookie によるセッション共有では実現できない。ヤフーに OAuth プロバイダになってもらい、OAuth をベースにした自動認証の仕組みで連携する。ユーザーからみると自動でセッションが引き継がれたように振る舞う。OAuth であればユーザー自身による第三者同意のタイミングが明確に存在するので、プライバシーの課題もクリアしやすい 第三者同意を得ていない状態で閲覧される画面遷移と、第三者同意を得た上で閲覧される画面遷移の境界をクリアにし一休システムとのデータ連携はその境界をまたいだ後に行われるようにする メールもヤフーのドメインで送信したい。一休のメール送信システムをヤフーの SPF に認可してもらってそれを可能にする ・・・などなど他多数 「ヤフーのサービスである形を維持しながら、一休のシステムに寄せる」という基本方針をぶらさずに、以上のような問題を、ヤフーの CTO やコマース部門の CTO と協議しながら一つ一つ片付けていきました。 本日ヤフートラベルをリニューアルしました。一休ライクになっています。数年前からBEは一休と統合していましたが、今回FEも統合をしました(UXはサービスごとカスタマイズしています) @naoya_ito さんと楽しく殴り合いながら統合したので(笑)ぜひご利用ください! https://t.co/Bh4ifVJ5Cs — mikanmarusan (@mikanmarusan) September 30, 2021 これはヤフー CTO 藤門さんのツイート。殴り合いだったらしいです。 さて、上記が決まってくるとビジネス要件決めにまつわる制約事項がクリアになるので、ここから体験・機能の取捨選択など業務要件決めです。業務要件については OTA 固有の話が多いので、ここでは割愛。 実装方針を決める 更に、アプリケーションの実装の方針も固める必要があります。 UI は実装をコンポーネントレベルで共通化したい。そのためソースコードは、一休のシステムと同一ソース (レポジトリ) で一休 / ヤフーの両サービスを実現する コードベースを fork してしまうと、二重管理が発生する。それは避けたい そのため一休の設定コンテキストで Nuxt、Go、.NET などのアプリケーションをビルドすると一休.com のアプリケーションに / Yahoo! トラベル設定コンテキストでビルドすると Yahoo! トラベルアプリケーションがビルドされるようにする。つまり、同じコードベースでもアプリケーションが一休として動くのか、Yahoo! トラベルとして動くのかは静的に確定させる方式 この方式で、同一レポジトリではあるものの、それぞれのサイトのアプリケーションは分離できる。他方のサイトの障害がもう一方のサイトへ波及しないよう、デプロイ先の EKS クラスタはそれぞれのサービスごとに別環境で実行し、エッジサーバーのルーティングでトラフィックをドメインに応じたクラスタへ振り分ける フロントエンドは、一休がもともとコンポーネント指向で開発してる。2サイトの UI は基本的にはスタイルこそ違えど、動きは同一になるので小・中規模のコンポーネントは共有し、Tailwind CSS のテーマ機能などを利用してプレゼンテーションレイヤの上位層の実装でその差異を吸収する 根本的に2サイトで要求される挙動が異なるコンポーネントの場合は、それぞれのサイトごとにコンポーネントを開発する (※ という方針でやってみたが、100% うまくいったとは言えない状況 ・・・ 本プロジェクトで得た知見と反省を活かして、デザイン・システムの整理を始めている) など、開発を進めるにあたり「ここはどうすれば?」というポイントを数名のメンバーで意志決定していきました。 プロジェクトの早い段階で、新システムをデプロイできる環境を用意する システム統合の全体設計、機能要件、開発方式の基本的なところが固まってくるとチームごとのロードマップがクリアになるので、ぼちぼち開発に着手できる状態になります。 ここで、一休の場合はいつもやっていることなんですが、新しいシステムのビルドパイプラインを開発開始とほぼ同時に構築してしまいます。開発のメインブランチにマージされたアプリケーションが即座にステージングのクラスタにデプロイされるようにします。 こちらのスライド にもあるのですが、一休では本番環境にあるデータベースを (個人情報など秘匿性の高いデータはマスキングした上で) 開発にレプリカする仕組みがあって、開発を本番環境相当のデータを使っておこなう・・・ということを習慣化しています。これを新 Yahoo! トラベルの開発環境にも適用しました。 これにより、プロジェクト開始直後から (まだ何一つ実装はできていませんが) Yahoo! トラベル版ビルドのアプリケーションの動作を、みんなで一箇所で確認できるようになります。しかも本番相当のデータを利用していますので、プロジェクトの早い段階にラフに作った実装で、実際のサービス提供イメージを動くもので確認することができるわけです。 チーム間同士にまたがるシステムの結合部位の特定が容易になりますし、結合テストも気軽に実行できます。常に動くものをベースに議論できるので、関係者間の認識合わせ / 認識ずれの発見もイージーです。ディレクターが日常的にこの環境で進捗を確認することで、要件の対応漏れも早期に発見することができます。 習慣化してやってきたことですが、この手の大規模プロジェクトでは改めて、とても有用なプラクティスだなと感じました。 ついでに、レガシー改善も一気に進める ところで、こういうビジネス的な大義名分のある大規模開発のタイミングというのは、レガシー改善を一気に推し進めるチャンスです。 普段だとビジネスを停めない前提でレガシー改善をするのに思い切ったシステムリニューアルに踏み込めないこともあるわけですが、こういう大規模プロジェクトのタイミングは、技術基盤を根底から刷新するですとか、そういうことが相対的に小さな扱いになるしついでにやれるのであれば、プロジェクトゴールのための手段としても合理的に肯定しやすい。 ただし、システム統合をはじめるよ、という段階で一緒に新基盤も投入するのはビッグバンリリースになっていろいろと危ういですね。日頃から少しずつ新基盤の開発と導入を進め、プロダクション環境での安定性を確保しながら虎視眈々と、こういう大きく動ける機会を狙う・・・というのがビジネスを停めないレガシー改善戦術のひとつだと思います。 というわけで、以前から進めていた Nuxt + Go での新開発基盤で、システム統合スコープに含まれる領域を塗り替える作業も同時に進めました。 新システムの成果 駆け足で紹介してきました。さすがに1エントリでは詳細まで書き切ることはできないので、今回はこの辺まで。 そもそも、このシステム統合の結果はどうだったのか気になるところですが 年始から検討、4月頃から開発に着手。当初リリース目標の9月中旬を前倒ししてカットオーバー AB テストの結果、従来サイトのパフォーマンスを、新サイトが大幅に上回ることを確認。100% リリースに至る となりました。 プロジェクト責任者としては、AB テストの結果をみてほっと胸を撫で下ろしたところでした。 システムの統合によって UI を含む販売面の体験が一休のそれに近づいたことで使いやすくなった・・・というところもありますが、今後の発展性を考えると、Yahoo! トラベルと一休.com のデータウェアハウスが一つ二統合されたということも大きいと思っています。2つの OTA のパフォーマンスを、マスターデータ管理ができた状態で分析ができますし、最近では一休のお家芸にもなったマーケティングオートメーション・・・ 機械学習を利用した、顧客行動に最適化した CRM を Yahoo! トラベルにも展開することができるようになりました。今後が楽しみです。 考察 現在はリリース後の改善を進めているところです。 一休.com と実装を共通化したため、一休.com で検索機能の改善を行うと、少ない手数で続けて Yahoo! トラベルの検索も改善される・・・という開発リソースの効率化が力を発揮し始めています。 以下、システム統合プロジェクトの考察です。 マネジメント視点でみた場合、2社間のシステムを統合するにあたってはトップダウンアプローチで「どちらのシステムを主体にするのか」を、大枠のアーキテクチャ + 業務処理フローも含めて意志決定を行うことが肝要だと感じました。 2社間のシステムの統合は計算機的な意味での「システム」を統合するのだけでなく、組織や業務フローまで含めた統合になります。そこにはそれぞれの会社の社員が関わってきますし、合理的な判断で物事を進めようとすれば、当然いろいろな痛みも伴うわけです。統合前からやっていたプロジェクトが中止になることもあるし、組織の責任者やレポートラインも大きく変わります。二つの組織で異なっていた働き方や文化もある程度、どちらか一方に寄せていく必要があります。 そういう痛みを発生させる責任を引き受けて、合理的なジャッジをしていくというのはトップマネジメントの仕事ではないか・・・と思います。 ことシステムに関して言えば、合理的判断を保留し忖度によってシステムアーキテクチャを歪めてしまうと、諸々が複雑になりその後の開発や業務に大きなダメージを与えてしまう・・・というのはこの記事を読まれている方であれば、想像に難しくはないと思います。 こういう意志決定をボトムアップで正しく実施するのは難しいというか、正直良いアプローチが思いつきませんでした。 とはいえ、トップダウンで、より詳細な要件や実装まで全てを決めていくのは不可能です。従って、ここまで見てきたとおりどちらのシステムを残して、どちらを捨てるのか。連携にまつわる重要箇所のインタフェースをどうするのか。プライバシーやセキュリティ、法務イシューをどのような手段で、クリーンに解決するのか。アプリケーション開発で各チームが共通して守るべき実装方針は何か。こういった全体方針をある程度トップダウンで決めて個々のチームの依存関係をほぐし、独立して動ける状態を作るまでが、マネジメントの最初の仕事だと思います。そこから先は、各チームがボトムアップで自分たちの領域を自分たちのやり方で進めていくのが良いでしょう。 特に一休の場合は内製の開発チームで、もう何年も一緒にやっている組織なのでチームや個々人の強みもお互いによく理解していますし、方針さえ決まればあとはボトムアップで品質、工数感やスケジュールを外さずにゴールに到達できるだろうという予感はありました。 任せるとはいっても規模の大きなプロジェクトになるので、いくつものチームが同時並行で開発を行います。互いの進捗を常に結合し状況を見える化しておくのは、プロジェクト進行の大事なプラクティスです。ビルドパイプラインとステージング環境を早期に構築したのはボトムアップで動く複数のチームの合流ポイントを早い段階で用意し物事がうまく進んでいるのか進んでいないのかを明らかにしたかったため・・・というわけでした。 おまけ プロジェクトが成功した・・・ということで会社からプロジェクトメンバーの自宅に差し入れが届きました。 みんなで集まってパーティとはいかない時期ですが、こういう労いもまた粋だなあと思いました。 一休では常時、Yahoo! トラベル、一休.com を含む一休のサービスを一緒に開発してくれる社員を募集中です。 hrmos.co hrmos.co
アバター
こんにちは。 一休.com の開発基盤を担当しています、akasakasです。 宿泊サイトのPCリストページを ASP.NET Web Forms から Go + Nuxt でリニューアルしたお話をさせていただきます。 詳しいお話をする前に:PCリストページってどこ? こちらになります https://www.ikyu.com/tokyo/140000/ 宿泊PCサイト(検索導線)の問題点 ASP.NET Web Forms のレガシーアーキテクチャによる開発生産性低下 一休.comのほとんどはASP.NET Web Formsベースの独自フレームワークで構築されています。 大規模リプレイスをしたのが2009年頃なので、宿泊サービスを10年以上支えてきてくれました。 それ故、継続して開発をしづらくなってきたというのがあります。 似たような画面があり、修正コストが高い PCリストには条件検索画面とキーワード検索画面の2つがありました。 見た目は似ているが、別ページなので、機能差分が発生していました。 Go + Nuxtでリプレイス Goの選定理由 Goが比較対象(.NET Core, Python)と比べて、総合的なバランスが最も良いと感じたからです。 Go ○:パフォーマンス、クロスプラットフォーム、開発環境の成熟度など、バランスが良い ×: Viewを書く言語としてはイマイチ(html/templateとRazorを比べると、圧倒的にRazorの方が良い) .NET Core ○:宿泊メンバーの既存スキルを活かしやすい ×:採用面が弱い Python ○:レストランで採用している ×:パフォーマンスは.NET CoreやGoに見劣りする Nuxtの選定理由 レストランサイトで採用している 公式の日本語ドキュメントが整備されている 技術的ジャンプが少なくモダン開発を始められそう 画面統合でシンプルに 余計な修正コストがかからないように 機能差分が発生しないように するために、画面統合することでシンプルにしました。 Before/After Before トップページからの検索が2つ(キーワード・条件検索)分かれていたため、修正コストが高くなっていました。 After 統合したことにより、導線もシンプルになりました。 現在の移行状況 現時点でのGo+Nuxtにリプレイス済みのページは以下になります。 SD トップページ PC リストページ 目標 中期的には以下を目標としています Go+Nuxtで主要導線をリプレイスすること 主要画面の統廃合 まとめ 宿泊サイトを ASP.NET Web Forms から Go + Nuxt に移行中です。 一緒にGo + Nuxt で一休宿泊サイトを作り直していきましょう。 hrmos.co hrmos.co
アバター
こんにちは。 宿泊事業本部のいがにんこと山口です。 id:igatea 去年同様ヤフー社内で毎年開催されているハッカソンイベント「Internal Hack Day」が先日7/31~8/2に開催されました。 そのハッカソンに去年参加していたZ Holdingsのアスクル、一休、PayPay、ZOZOテクノロジーズに加えてLINEの参加が決定し計6社での開催となりました。 今年は自分と同僚に加えて、LINEの方とチームを組み参加させていただきました。 この記事ではInternal Hack Dayに参加してきたレポートを書きます。 Internal Hack Day 去年と被るところが多いですが改めてInternal Hack Dayの説明をさせていただきます。 Internal Hack Dayはヤフー社内で毎年行われている社内向けのハッカソンイベントです。 チームを組んでテーマに沿った新しい機能やサービスのアイデアを出し合い、短い期間で作り上げて競い合うイベントとなっています。 チームは自社だけで組んでもいいですし、他社の方と組むことも可能です。 Internal Hack Dayのルールは以下の通りです。 開発時間は24時間、9:00~21:00の2日間 プレゼン時間は90秒 去年はコロナウイルスの流行もあり、テーマが「新しい生活様式での課題解決」でした。 今年はZホールディングスの新しいシナジー送出がテーマです。 開発、発表は去年同様原則オンラインで行うことになっています。 ハッカソン向けに技術提供もあり、LINE CLOVEA OCRやLINE Messaging APIなど一部APIをハッカソン限定で無制限に使えるようにしていただきました。 今回僕たちは使いませんでしたが次機会があれば使ってみたいですね。 しかも今年はランチの提供があり自宅まで特性お弁当が届くというサービスがありより一層イベントっぽさが出るものとなっていました。(いい感じの弁当だったので写真撮っておけばよかったです) みんなの避難経路 僕たちは「みんなの避難経路」というものを開発しました。 これはどういうものかというと、災害時の避難所への最適な避難経路が分かる、というものです。 災害時に避難所に行くためにはいくつかの課題があります。 どこに避難すればいいのか 水没しやすいところや封鎖されやすいところ、移動が大変なところなど迂回しなければいけない場所はどこか 災害で通れなくなっているところがあるかも こんな問題を解決したいと思って開発を行いました。 以下の国土交通省が提供している避難所データを元に災害種別に応じて近所の避難所をGoogleMap APIを使用して表示しています。 指定緊急避難場所CSVデータ 市町村別公開日・最終更新日・ダウンロード一覧|国土地理院 避難所までの経路はGoogleMap APIの経路探索APIで引いています。 そこから定期的に現在位置が記録されていき、それが別の同じ避難経路を指定したユーザーに見えるようになっています。 これによってどこが人の通った実績があり確実に通ることができる場所かを把握することができるようになります。 通常経路を他のユーザーが皆避けているようであれば、他のユーザーと同じように経路を変更するという選択が取れるようになります。 課題としては実際の経路自体はそこを考慮して経路変更を行えていないのでハッカソン内では間に合いませんでしたがここも対応したいところですね。 結び 年に1回のイベントで数少ないグループ内他社とのイベントでとても楽しかったです。 また何かやりたいですね。 ヤフーさんの方でも記事を上げているのでそちらもどうぞ https://techblog.yahoo.co.jp/entry/2021081830172653/
アバター
はじめに データサイエンス部の平田です。 ディープラーニングのモデルを作る際、学習データが少ないことが原因で精度が上がらない場合、データのかさまし(augmentation)を行うことがあります。 画像の場合は、オリジナルに対して回転させたりノイズを少し加えることで同じラベル付けがされている別の画像を作り出すことができ、それを学習データに加えることで頑健なモデルになります。 ただし、テキストの場合は回転させると意味不明になるのでどういう操作をしてかさましするかというのを考える必要があります。 そこで、EDA(Easy Data Augmentation)というものが考案されました。 参考 Synonym Replacement:文中の単語の内n個、同義語に置き換える Random Insertion:文中の単語をランダムに選んで同義語にしてランダムな場所にinsert、n回繰り返す Random Swap:文中の二つを入れ替える n回繰り返す Random Deletion:確率αでランダムにそれぞれの単語削除 上4つの操作の内どれか一つをランダムに行って一つの文から沢山の文を生成する手法です。n=α×テキストの単語数とします。αは自由に変えられるハイパーパラメータで、文章の内どのくらいの割合で単語を操作するかという意味になります。 論文中では英文のデータセットで行っていましたが今回は日本語の文で試してみました。また、同義語に置き換える方法をWordNetからとChive(Word2vec)からの2通り試しています。 実装 EDA まず文中の単語を分けるために、分かち書きをする必要があるのですが、その前に単語を引数に取って同義語を返す関数を定義します。 Wordnet から「Japanese Wordnet and English WordNet in an sqlite3 database」をダウンロードしてきます。 conn_sqlite = sqlite3.connect( "wnjpn.db" ) re_alnum = re.compile( r'^[a-zA-Z0-9_]+$' ) # 特定の単語を入力とした時に、類義語を検索する関数 def search_similar_words (word): # 問い合わせしたい単語がWordnetに存在するか確認する cur = conn_sqlite.execute( "select wordid from word where lemma='%s'" % word) word_id = 99999999 #temp for row in cur: word_id = row[ 0 ] # Wordnetに存在する語であるかの判定 if word_id== 99999999 : return [] # 入力された単語を含む概念を検索する cur = conn_sqlite.execute( "select synset from sense where wordid='%s'" % word_id) synsets = [] for row in cur: synsets.append(row[ 0 ]) words = [] for synset in synsets: cur3 = conn_sqlite.execute( "select wordid from sense where (synset='%s' and wordid!=%s)" % (synset,word_id)) for row3 in cur3: cur3_1 = conn_sqlite.execute( "select lemma from word where wordid=%s" % row3[ 0 ]) for row3_1 in cur3_1: words.append(row3_1[ 0 ]) return list ( set ([w for w in words if not re.search(re_alnum, w)])) 例えば「美味しい」の同義語は、 print (search_similar_words( '美味しい' )) # => ['快い', 'きれい', '素適', '善い', '好いたらしい', 'いい', '好い', 'ナイス' , '心地よい' , '可愛い' , '麗しい' , '旨味しい' , '好ましい' , 'すてき' , '良い' , '綺麗' , 'よい' ] こんな感じになります。美味しいと可愛いは同義語?というのは置いといて、ポジティブなワードが並んでいます。 実装は @pocket_kyotoさんの記事 を参考にしました。英単語が混じっているので取り除く処理もしています。 のちほど比較のためにべつの同義語取得関数も作ります。 続いて文を単語に分解する分かち書きですが、使いやすい sudachi を利用することにします。 pip install sudachipy==0.5.2 pip install sudachidict_core from sudachipy import tokenizer from sudachipy import dictionary tokenizer_obj = dictionary.Dictionary(dict_type= "core" ).create() mode = tokenizer.Tokenizer.SplitMode.A 以上でsudachiライブラリの導入ができました。上記4つの操作をランダムに行うEDAをごりごり実装していきます。同義語を抽出する単語は名詞と動詞、形容詞だけにします。また、単語を変換する際、動詞の活用は気にせず終止形だけ使うようにします。 alpha = 0.3 # 単語を操作する割合 N_AUG = 16 # 一文から増やす文章の数(倍率)、500: 16, 2000: 8, 5000: 4 def synonym_select (target_token): if target_token.part_of_speech()[ 0 ] == '動詞' or \ (target_token.part_of_speech()[ 0 ] == '形容詞' and target_token.part_of_speech()[ 1 ] == '一般' ): target_word = target_token.dictionary_form() else : target_word = target_token.surface() synonyms = search_similar_words(target_word) if len (synonyms) > 0 : replacement = random.choice(synonyms) else : # 同義語が無かったら置換しない replacement = target_token.surface() return replacement # Synonym Replacement 文中の単語の内n個、同義語に置き換える def synonym_replacement (text_tokens, text_split, text_length): n = math.floor(alpha * text_length) indexes = [i for i in range (text_length) if text_tokens[i].part_of_speech()[ 0 ] in [ '名詞' , '動詞' , '形容詞' ]] target_indexes = random.sample(indexes, min (n, len (indexes))) for i in target_indexes: target_token = text_tokens[i] replacement = synonym_select(target_token) text_split[i] = replacement return text_split # Random Insertion 文中の単語をランダムに選んで同義語にしてランダムな場所にinsert、n回繰り返す def random_insertion (text_tokens, text_split, text_length): n = math.floor(alpha * text_length) indexes = [i for i in range (text_length) if text_tokens[i].part_of_speech()[ 0 ] in [ '名詞' , '動詞' , '形容詞' ]] target_indexes = random.sample(indexes, min (n, len (indexes))) for i in target_indexes: target_token = text_tokens[i] replacement = synonym_select(target_token) random_index = random.choice( range (text_length)) text_split.insert(random_index, replacement) return text_split # Random Swap 文中の二つを入れ替える n回繰り返す def random_swap (text_split, text_length): if text_length < 2 : return '' .join(text_split) n = math.floor(alpha * text_length) target_indexes = random.sample( range (text_length), 2 ) for i in range (n): swap = text_split[target_indexes[ 0 ]] text_split[target_indexes[ 0 ]] = text_split[target_indexes[ 1 ]] text_split[target_indexes[ 1 ]] = swap return text_split # Random Deletion 確率pでランダムにそれぞれの単語削除 p = alpha def random_deletion (text_split, text_length): return [t for t in text_split if random.random() > p] # Easy Data Augumentation def eda (text_tokens, text_split, text_length): results = [] for _ in range (N_AUG): text_tokens_c = copy.copy(text_tokens) text_split_c = copy.copy(text_split) random_number = random.random() if random_number < 1 / 4 : result_arr = synonym_replacement(text_tokens_c, text_split_c, text_length) elif random_number < 2 / 4 : result_arr = random_insertion(text_tokens_c, text_split_c, text_length) elif random_number < 3 / 4 : result_arr = random_swap(text_split_c, text_length) else : result_arr = random_deletion(text_split_c, text_length) results.append( '' .join(result_arr)) return list ( set (results)) 実際に例文を入れていきましょう。 お肉がとても美味しかったです。食後のコーヒーが別料金(210円)なので、そこだけ注意です。 => おミートがとても美味しかったです。食後のカフェーが画然たる勘定(210円型)なので、そこだけ注目です。 「画然たる勘定」という新たな語彙が生まれました。 Chive(Word2Vec) 同義語を持ってくる別手法として、単語をベクトル化して類似度が高いものを選ぶというやり方も考えられます。今回は単語をベクトル化する手法として一番オーソドックスなWord2Vecを使い、Wikipediaをコーパスとした Chive を利用します。gensim用のv1.1 mc90 aunitをダウンロードして適切な場所に置きます。 vectors = gensim.models.KeyedVectors.load( "chive-1.1-mc90-aunit.kv" ) def search_similar_words (word): thre = 0.6 try : result = vectors.most_similar(word, topn= 20 ) return [r[ 0 ] for r in result if r[ 1 ] > thre] except : return [] search_similar_wordsを置き換えることでChiveバージョンの関数を定義できます。 ちなみに、同じく美味しいの同義語を見てみると print (search_similar_words( '美味しい' )) # => ['美味', '激旨', '味', '絶品', '食べる', '甘み', '焼き立て', 'サラダ', 'うまうま' , 'スープ' , '食べ応え' , '食感' , '香ばしい' , '熱々' , '風味' , 'ジューシー' , '揚げ立て' , '御馳走' , 'あっさり味' , '塩味' ] こんな感じになります。WordNetよりカジュアルですし、後半は唐揚げ感あります。 差し替えたバージョンでもEDAの例を見てみます。 お肉がとても美味しかったです。食後のコーヒーが別料金(210円)なので、そこだけ注意です。 => 1. お胸肉がとても美味しかったです。空腹の水出しが各料(210¥)なので、そこだけ呉々です。 2. お肉がとても美味しかったコーヒーショップです。 ¥食後のコーヒーが別料金(210円)な違う間食のでジューシー金額、細心ソーセージそこだけ注意です。 美味しいお肉が出てくるコーヒーショップは行ってみたいですが、意味変わってる気がしますね。 評価モデル作成 実際に文を増やしたことによる効果を検証したいと思います。今回は、レストランの口コミ文だけから評価点(1~5)を予測するタスクを考え、正解データとどれだけ離れているかを調べることで定量的に効果を計算します。 BertJapaneseTokenizerと、LSTMを使用します。 # LSTMで検証 from transformers import BertJapaneseTokenizer from keras.preprocessing.sequence import pad_sequences tokenizer = BertJapaneseTokenizer.from_pretrained( 'cl-tohoku/bert-base-japanese-whole-word-masking' ) MAX_WORD_COUNT = 256 x_train = pad_sequences(df_sample_train[ 'text' ].apply( lambda x: tokenizer.encode(x)), maxlen=MAX_WORD_COUNT) y_train = df_sample_train[ 'total_score' ] x_test = pad_sequences(df_sample_test[ 'text' ].apply( lambda x: tokenizer.encode(x)), maxlen=MAX_WORD_COUNT) y_test = df_sample_test[ 'total_score' ] 以下でモデルを作成します、指標として平均二乗誤差を利用します。 # ベクトルを入力としてLSTMでregression from keras.preprocessing.sequence import pad_sequences from keras.layers import LSTM, Dense, Embedding, Dropout, Input from keras.models import Model vocabulary_size = len (tokenizer) + 1 # 学習データの語彙数+1 text_in = Input(shape=(MAX_WORD_COUNT, ), dtype= 'int32' , name= 'text_in' ) x = Embedding(input_dim=vocabulary_size, output_dim= 32 , mask_zero= True )(text_in) x = LSTM( 128 , return_sequences= False )(x) x = Dropout( 0.1 )(x) out_score = Dense(units = 1 , name= 'out_score' )(x) model = Model(inputs=text_in, outputs=out_score) model.compile(optimizer = 'adam' , loss = 'mean_squared_error' ) model.summary() モデルに実データを入れて実験です! history = model.fit( x_train, y_train, batch_size= 64 , epochs= 5 , validation_data=(x_test, y_test) ) min (history.history[ 'val_loss' ]) 例えばval_lossが0.7だと実際の評価点と予測の評価点が平均±0.7だけ離れていることになります。小さい方がより性能が高いということになります。 結果 学習に使う文の数を500, 1000の二通りについて見てみます。 また、バッチサイズはEDAのときは64, なしのときは4にします。同義語の抽出はWordNetを使っています。 500文の場合 EDA val_loss なし 0.795 α=0.05 0.771 α=0.1 0.783 α=0.2 0.734 α=0.3 0.730 EDA無しの時と比べてval_lossが低い(=性能が高い)ことが分かりました。また、αは大きい方が性能が良くなっています。 1000文の場合 EDA val_loss なし 0.709 α=0.05 0.714 α=0.1 0.723 α=0.2 0.650 α=0.3 0.666 1000文の場合は学習データが増えているので全体的にval_lossは低くなっています。 また、α=0.2の場合はEDA無しと比べて性能が良くなっています。 αが低いときはEDA無しより性能が悪くなっているので、適切なαの設定は重要なことが分かります。 500文(Chive)の場合 今度はWord2Vecの事前学習済みデータChiveを使った同義語抽出に変更した場合の性能を検証してみます。 EDA val_loss なし 0.774 α=0.05 0.766 α=0.1 0.727 α=0.2 0.725 α=0.3 0.707 こちらも性能が良くなっています(0.774 -> 0.707)。同性能が出るなら実装の手軽さからChiveを使う方が良いかもしれません。 結論 基本的にはEDAを行うことで性能が上がることが分かりました。特に文章数が少ないときほど効果的です。学習データが少ないときには試してみてはいかがでしょうか!
アバター
はじめまして、システム本部CTO室の松村です。 私は去年の4月に一休に入社しましたが、当時は緊急事態宣言の真っ只中でした。 一休も感染拡大防止のために多くの人が在宅勤務になり、私もいきなり週5で在宅で働く事になりました。 それから1年以上働いた経験から、一休での在宅勤務はどんな感じだったのか、新人だった自分はどんな感じで業務を行っていたのかについてご紹介したいと思います。 概要 チーム内外とのコミュニケーション 会話によるコミュニケーション 開発のフロー Githubによるコード管理、レビュー CIによる自動テスト、デプロイ 重要なアラートはSlackに通知が来る プロダクトのログやサーバのデータなどがDatadogに集約されている おわりに 概要 一休では10人以下のチームで1つのプロダクトの開発を行っていますが、 チームで開発をすすめる上で、重要な要素だと感じた以下の3つについて説明していきます。 チーム内外とのコミュニケーション 会話によるコミュニケーション 開発のフロー チーム内外とのコミュニケーション 一休は日常的なコミュニケーションの手段として、以前からSlackを利用しています。 Slack内には様々なチャンネルがあり、全社共通のチャンネルや部署・チームごとのチャンネル、 開発向けのデータやアラートが送られてくるチャンネル、 趣味のチャンネルなどがあります。 私は以前の会社で社内の連絡手段として主にメールしか使えなかったので、以下のような点でSlackのメリットを感じました。 短文で必要な内容だけ伝える事ができる 過去の報告や議論などを全体やチャンネル毎に検索することができる メンションにより、特定の人やグループに即座に呼びかける事ができる 申請のワークフローや、営業から開発への問い合わせなど部署間のやりとりをシステム的に行うことができる スタンプを使うと雰囲気が柔らかくなる 🎉 また、一休のエンジニアには 「times」という個人のチャンネルを持っている人が多いです。 一般的には「分報」と呼ばれるようなスタイルのチャンネルで、 個人の技術メモや興味を持ったニュース、今日食べたものなど様々な内容を投稿しています。 在宅勤務だと直接会話をする機会が減るので、こういったチャンネルを見れば個人のパーソナリティを知る事ができますし、 特定の誰かに相談したい事があれば、本人のtimesチャンネルに投稿すれば必ず見てもらう事ができます。 ダイレクトメッセージだと二者間の閉じた会話になってしまいますが、timesを使えば 気になった人が会話に割って入る事ができますし、後から検索する事ができます。 timesイメージ1 timesイメージ2 会話によるコミュニケーション 一休ではビデオ会議用ツールとしてZoomを使用しています。 オンラインでは普段は簡単に行っていた「隣の人と会話する」という行動もハードルが高くなるので、 ログインせずにすぐに使う事が出来るZoomのメリットは大きいです。 チームによって方針は異なりますが、以下の2点を行う事で 「何も分からない」や「全然違う」状態を防ぐ事ができていると思います。 朝会、夕会などを毎日行い、認識合わせや報告を密に行う 何かあったらすぐ相談する チームごとのZoomのURLがあれば定期的なミーティングは毎回そこに入るだけで済みますし、 SlackとZoomが連携しているのでSlackのコマンド1つでミーティングを作成する事ができます。 Slack内のビデオ通話だと能動的に招待しないといけないので、そのあたりが気軽にできるのがとてもありがたいです。 zoomミーティングをコマンドで作成する 開発のフロー 複数人が関わるプロダクトは開発中の各段階で確認や連絡などのコミュニケーションが必ず発生するので、 コミュニケーションのコストが大きくなる在宅勤務では、開発のフローも大事になってきます。 私は入社してすぐ小さなタスクをいくつか任されましたが、実際のプロダクトにデプロイするのが非常に楽だと感じました。 以下のように、開発のフローがきっちり定まっているのが要因だと思います。 コードはGithubで管理、レビューする CIが整備されていて、自動テスト、テスト環境や実環境へのデプロイが簡単にできる 重要なアラートはSlackに通知が来る プロダクトのログやサーバのデータなどがDatadogに集約されている それぞれの点について、詳しく説明していきます。 Githubによるコード管理、レビュー コードの変更はGithubのプルリクエストを利用して管理します。 PR内で指摘や議論ができますし、SlackにURLを貼ればすぐに変更点を見に行けます。 新人でも 指摘→修正→確認 の流れがスムーズに行なえますし、常に他人にレビューしてもらう事を意識すれば、 自ずとコードやPRの内容も良くなる(はず)です。 コードレビュー CIによる自動テスト、デプロイ CircleCIによるCIを導入しているため、PRに対して事前に自動テストが動いています。 また、特定のブランチにマージすれば、テスト環境や実環境に自動でデプロイするようになっています。 これにより、自動テストが通らないようなコードを早い段階で発見する事ができますし、 デプロイ時にやるべき作業が最小限になるので、本当に確認すべき内容に集中して、ボトルネックなく開発していくことができます。 特定のブランチにマージするとデプロイ 重要なアラートはSlackに通知が来る プロダクトや各種監視ツールがSlackと連携しているため、問題が生じた際には即座にキャッチする事ができます。 デプロイ完了通知なども投稿しているので、他の場所を見にいかずに済みます グラフ付きアラート プロダクトのログやサーバのデータなどがDatadogに集約されている サーバのログやステータスなどはDatadogに記録しています。 エラーや遅延など全て関連付けられて記録されているので、 エラーが起きた時や動作が遅い時などは、原因がDBなのかサーバなのか、どういったメッセージが表示されているのかを簡単に辿る事ができ、 原因の切り分けがスムーズに進みました。 一休のDatadog利用については詳しい記事があるので、こちらもご覧になってください。 Datadog Log Management でアプリケーション稼働モニタリング - 一休.com Developers Blog おわりに SIerだった前職とは作るものもスピードも開発手法も全然違う中での在宅勤務でしたが、 おかげさまで「在宅勤務だから辛い」と感じる事もなく仕事を続けていく事ができました。 今回紹介してきた内容に関わるシステムやツールは導入や開発記事が過去の記事にあったりするので、興味がある方はぜひ読んでみてください。
アバター
こんにちは。宿泊事業本部 プロダクト開発部 UI/UXチーム の 岡崎です。 今回は、「個人的」に「 プロダクト開発で大事にしていること 」をテーマに話を進めます。 概要 なぜ大事にしているのか? 「ユーザーファースト」を大事にする 軽く機能を作成してフィードバックを得る 最終的なUI/UXの決定を長けている人に任せる CVRを確認する 「チームワーク」を大事にする プロジェクトがうまく進んでいるかを客観視する 「アーキテクチャ」を大事にする データフローを統一化する ビジネスルールをテストしやすいコードにする レイヤを責務毎に分けて実装する 最後に 概要 大事にしている事は下記3つあります。 それぞれにフォーカスして話を進めます。 1.「 ユーザーファースト 」 2.「 チームワーク 」 3.「 アーキテクチャ 」 なぜ大事にしているのか? 「ユーザーファースト」 ユーザーに価値を届けられないプロダクトは 「無意味」 である為 「チームワーク」 良いプロダクトを生み出す為に 「自分が不得意な分野の知識を借りる」 事が必要不可欠である為 「アーキテクチャ」 速いサイクル でプロダクトの 改善 をする為に必要不可欠である為 「ユーザーファースト」を大事にする Q.「 ユーザファースト 」を大事にするとは? A. ユーザが 使い心地の良い 機能かを考える事 私の場合は、これをまず最初に考えてプロダクト開発をします。 具体的には、以下のテクニックを利用しています。 1.軽く機能を作成してフィードバックを得る 2.最終的なUI/UXの決定を長けている人に任せる 3.CVRを確認する 軽く機能を作成してフィードバックを得る エンジニアにありがちなのが、手段と目的の逆転現象です。 例えば、モダンなUIフレームワークを利用して、イケているデザインを作ろう。 という風に考えると破綻します。 手段を考えるよりも先に「 どうしたらユーザが困っている事を解決できるか? 」を考えて プログラミングに臨むことが大事だと思います。 そのためにも、HTML/CSS/JavaScript だけで静的なコンポーネントを作ってみて「そもそも使い勝手良いんだっけ?」 と社内のメンバーにフィードバックを得るなどの行為は大事になってくると思います。 最終的なUI/UXの決定を長けている人に任せる 「デザインスプリント」「アジャイル開発」などのフレームワークでは、 「皆で議論して」「付箋」「ホワイトボード」... などのワードが目立つと思います。 「皆で議論する」... 事自体は、問題ないですが、最終的に「 誰がUI/UXを決めるか? 」は大事になります。 「民主主義」で決めたり「エンジニア」が決めてしまう場合は、「それぞれの欲しいデザイン」になりがちです。 UI/UXに関する内容の決定権は、「ユーザの行動分析が得意な人」や「デザイナー」に責任をもってもらう事が重要だと思います。 CVRを確認する CVRを確認する理由は、「 CVRが上昇≒ユーザが使いやすいと思っている 」という方程式が成り立ちやすいからです。 そのためにも、以下は大事になってくると思います。 A/Bテストの仕組みを整えておくこと カナリーリリースの仕組みを整えておくこと データレイクにデータを送信する仕組みを整えておくこと 分析基盤を整えておくこと 「チームワーク」を大事にする Q.「チームワーク」を大事にするとは? A. チームが「 プロジェクトに対して上手く進んでいるか 」かを考える事 私の場合は、具体的には、以下のテクニックを利用しています。 プロジェクトがうまく進んでいるかを客観視する プロジェクトがうまく進んでいるかを客観視する まずは、心理的安全性の確保などは考えず「プロジェクトがうまく進んでいるか?」を考えます。 理由は、 うまく進んでいる場合はチームが上手く回っている 事が多いからです。 チームが良くなってもプロジェクトが上手くいかなければ意味がありません。 プロジェクトを上手く進めるうえで 結果的にチームが上手く連携がとれている状態 を目指すのが良いと考えています。 私の場合は、以下を意識 / 実践しています。 マイルストーンが明確になっている事の確認 大枠のスケジュール(いつまでに / 誰が / 何を ) が明確になっている事 タスク管理 / タスクの優先度付けがちゃんと行われている事の確認 個々人の持ちタスクなどが把握できる状態になっている事 プロジェクトを進めるうえで出てくる課題をベースにチームメンバーと会話をする 会話をする事で個々人の詳細な状況を把握 / 対策を考える メンバーと会話を行う事で自分の頭の中の整理を行う 「一緒に」プロダクト開発を行うという意識を持つ チームメンバーが時間がかかっているタスクに対して積極的に介入する 知っておくと開発においてスムーズになる情報を分りやすくドキュメント化する アーキテクチャ や 実装指針 デプロイ/リリース手順 なぜ開発を行う必要があるのかの背景を説明したドキュメント 「アーキテクチャ」を大事にする Q.「アーキテクチャ」を大事にするとは? A. 開発者が「分りやすい設計 / 実装」 を心がける 事 私の場合は、具体的には、以下のテクニックを利用しています。 1.データフローを統一化する 2.ビジネスルールをテストしやすいコードにする 3.レイヤを責務毎に分けて実装する データフローを統一化する Redux や Vuex などの「一方向アーキテクチャ」や 「伝統的なレイヤードアーキテクチャ」 がなぜ分りやすいかというと 「処理が行われる順番が決まっている」という点です。 「処理が行われる順番」が決まっていない場合は、循環参照などの 危険性も出てきます。 以下を実践すると良いのかなと思っています。 ディレクトリ単位でレイヤ分けをする レイヤがどの順番で処理を行うかを決める ビジネスルールをテストしやすいコードにする ビジネスルールをテストしやすいコードにしておくとメリットが多くあります。 そのためにも、ビジネスルールのレイヤ(=Domain)をデータベース通信などのI/Oに依存しないようにすることが 大切になってきます。 なぜなら、データベースに存在する情報は、日々変化するものである為テストが常に同じ結果になるとは限らないからです。 「ダミーのデータをテストコードで扱えるよう」に「常に同じ結果」を返せるような設計にすると良いと思います。 データベース通信などの実処理に依存するのではなく、「 データベース通信などの実処理を行った結果、 どういうDomainのデータが欲しいか? 」を書いたインタフェースに依存するようにした方が良いと思っています。 ビジネスルールを単体テストしやすくすると、以下のようなメリットがあります。 以下が分かる事で開発速度・テスト速度が向上する テストコードで仕様が分かる テストコードがある事によって追加の修正 ...etc で、デグレが起きていない事を確認できる レイヤを責務毎に分けて実装する 既に出ていますが、責務毎にディレクトリ(レイヤ)を分けてSOLIDな実装をすると良いと思います。 特に大事なのは、ビジネスルールを他のレイヤに依存させないプレーンな実装にすると良いかなと思います。 因みに弊社では、「オニオンアーキテクチャ」を採用している箇所があり、「ビジネスルール」/ 「外部とのI/O」/ 「プレゼンテーション」 にそれぞれ分かれています。 「ビジネスルール」が他の「外部とのI/O」や「プレゼンテーション」に依存していない為 以下のメリットを享受できています。 テストコードが書きやすい 「同じビジネス文脈で利用されているビジネスのルール」の再利用がしやすい 最後に この記事で「伝えしたいことを一つにしろ」と言われたら、 「手段と目的」を逆転させず「プロダクト開発」を成功させるように動く事が大事 だという事を発信したいとおもっています。
アバター
社内情報システム部 コーポレートエンジニアの大多和( id:rotom / tawapple)です。 最近はオフィスファシリティと、Jamf Pro や Dialpad や、情シスの採用をやっています。 今回は情シスの業務において外すことのできない、社内のヘルプデスクを改善した話をします。 一休のヘルプデスクについて これまでのヘルプデスク 2018年の記事でも紹介している通り、一休では営業やコーポレート部門のメンバーを含めた全メンバーで Slack・Google Workspace を導入しています。 user-first.ikyu.co.jp 社内からのヘルプデスクについては、Google フォームに入力してもらった内容が Slack に自動投稿され、Slack のスレッドでやりとりを行い、問題を解決していました。 この方法を導入することで、口頭、電話、Slack など分散していた問い合わせ窓口を1つのチャンネルに集約することができました。 課題だったこと 一方で、この方法を使った運用にはいくつか課題点がありました。 対応状況のステータスが分からない この問い合わせが 対応待ちなのか、調査などの対応中なのか、すでに解決しているのか、忘れられているのか、といったステータスがひと目でわからず 、スレッドでのやりとりや、絵文字でのリアクションでしか確認することができない状況でした。 これにより対応の抜け漏れが発生することがあり、改善点として挙げられていました。 スマートフォンから投稿しづらい 一休のメンバーは営業が6割を占めており、ホテル・旅館やレストランなどの取引先や移動中など、外出時に問い合わせを行うことも少なくありません。 Google フォームを使った問い合わせ方法は、情シスにとっては管理がしやすくなった一方で、 ユーザーにとってはスマートフォンからの投稿に手間が多い状態 でした。 ブログのドメインにもなっていますが、一休は全社を通して 「ユーザーファースト」 という、ユーザーにとっての価値を追求する文化が根付いています。 www.ikyu.co.jp 情シスにとってのユーザーは社員であり、この状態はユーザーファーストではありませんでした。 また、外出時の問い合わせは緊急を要することも多く、問い合わせから解決までをスピーディーに行う必要があります。 以上のことから、スマートフォンからも投稿しやすく、すばやく問い合わせができる仕組みをつくる必要がありました。 DM で問い合わせがきてしまう 上記の使い勝手の悪さもあり、Slack の DM で情シスメンバーに直接問い合わせがよくありました。 ヘルプデスクを DM で行ってしまうと他者からやりとりが見えないため、 ナレッジが貯まらず同じ問い合わせが続いてしまう、対応が属人化し特定のメンバーに負荷がかかってしまう、対象のメンバーが離席していると対応が遅れてしまう 、など多くの問題を抱えていました。 qiita.com これらの課題からヘルプデスクにチケット管理ソリューションの導入を検討しました。 Halp について ここで本題の Halp の登場です。ハルプと読みます。 www.atlassian.com アメリカのスタートアップ企業が開発していたヘルプデスクソリューションで、2020年5月に Jira や Confluence などを開発する Atlassian が買収しました。 jp.techcrunch.com 一休では2020年7月から検証・評価を開始し、実用性の確認が取れたことから2020年10月に本導入しました。 Halp で改善できたこと 対応状況の見える化 Halp のコンソールより、チケットごとのリクエスター(ユーザー)、アサインエージェント、対応状況、最終更新日時が一覧で確認できます。 これにより、 誰もアサインされていないチケットや、しばらく更新されずオープンのままのチケットなどを確認 することができ、抜け漏れを防げるようになりました。 また、Halp のレポート機能により、 チケットを拾うまでの応答時間(First Response Times)、解決までにかかった時間(Resolution Times) を表示することができます。 問い合わせの粒度がまばらなため数値は大きめになってはしまうのですが、ここの数値は少しでも小さくなるように意識し対応しています。 また、 日ごとのチケット作成数や、アサインエージェントごとの担当チケット数 もこちらから確認可能となっています。 Slack ネイティブな問い合わせと対応 Halp ではチケットの発行からクローズまで、Slack 上で完結することができます。 it-helpdesk のようなユーザー対応を行うヘルプデスク用チャンネルと itdept-triage のような情シスメンバー用のトリアージチャンネルの2つを用意します。 ユーザーはチケットについて意識せず、ただ Slack のヘルプデスクチャンネルに問い合わせるだけで、自動でチケットが発行されます。 Bot がチケットを発行した旨をスレッドに投稿します。このあとのユーザー対応はスレッドで行います。 このやりとりはすべてトリアージチャンネルと自動同期するため、情シスメンバーはトリアージチャンネルのみで対応可能です。 情シスメンバー内での相談や依頼などは :lock: 🔒 の絵文字を先頭につけることで、ヘルプデスクチャンネルには自動同期されず、やりとりをすることができます。 ステータスの更新、クローズまで、すべてチケット操作が Slack 上で完結し 、他のシステムやページを開く必要もありません。 これにより、ユーザーはヘルプデスクチャンネルだけ、情シスはトリアージチャンネルだけで問い合わせが完結し、 スマートフォンからも操作がしやすい Slack ネイテイブな対応が可能 となりました。 DM 問題への対応 Halp は DM に対しても機能します。DM で届いた問い合わせにも :ticket: 🎫 リアクションをつけることでチケットが発行されます。 発行されたチケットはトリアージチャンネルに自動投稿 されるため、ナレッジを情シスメンバー内に共有することができます。 また、DM がチケット化されることで対応状況や対応件数も把握できるようになりました。 日頃より DM ではなくチャンネルで問い合わせていただくようにアナウンス・誘導することも大切ですが、 実際に DM で問い合わせが来たときに チャンネルと同じようにチケット化する、というアプローチ が取れるようになりました。 自動応答 bot 現在はまだ β ではありますが、「Halp Ansers」という自動応答の機能も開発されています。 現時点(2021/5)では日本語非対応なため、「Zoom」「SmartHR」などアルファベットの SaaS 名などで利用ができます。 キーワードマッチで自動応答をすることで、 適切な問い合わせ窓口へ誘導や、トラブルシューティングの URL やマニュアルを展開 することができ、 かんたんな問い合わせであれば、bot で自己解決を促すこともできるようになりました。 終わりに こうした業務の改善により、ユーザーにとっても使いやすく、情シスにとっても管理がしやすく、素早く問題が解決できる、 従業員体験を向上できるヘルプデスク を引き続き目指していきたいと思います。 さて、ここまで読んでいただいたあなたは、きっと一休の情シスに興味があると思います 。 一休では組織を IT の面で成長させる、 情シス・コーポレートエンジニアを募集 しています! 社内インフラ・ネットワーク系の方に限らず、 SaaS などのシステムを活用して業務の改善に取り組める方 は大歓迎です! インターネット企業としては比較的歴史の長い成長した組織ではありますが、裁量を持ってシステムの選定・導入に携わることができ、 チーム一丸となって最新の技術・サービスや、エンタープライズ向け製品に触れることができる充実した環境です。 hrmos.co note.com ご興味のある方はぜひご応募、ご連絡をお願いします。一度お話しましょう! 追記 SmartHR yamashu さんの記事でご紹介いただきました。 Halp を含めたヘルプデスクソリューションとの比較がわかりやすくまとまっています! tech.smarthr.jp
アバター
こんにちは。プロダクト開発部の渥美 id:atsumim です。 今回サービス横断で利用できるログインコンポーネントを WebComponents で実装したのでその紹介をします。 1. 背景 今年の2月に電話番号での会員登録及び認証機能をリリースしました。 これに伴って一休の会員基盤も刷新しました。 一休のサービスは主に、宿泊、レストラン、スパとあるのですが、 歴史的経緯により会員基盤が分散してしまっていたので、ひとつにまとめる狙いもありました。 会員基盤 Before/After その一環として、一休のサービスで横断して使えるログインコンポーネントを WebComponents で実装しました。 このコンポーネントにログインや会員登録の処理を集約し、新会員基盤へのインターフェースとするようにしました。 また、電話番号認証や2段階認証設定のモーダルも実装しました。下記が実際の画面です。 ログインモーダル 電話番号認証モーダル 2段階認証モーダル この記事ではログインモーダルに絞ってお話します。 2. 技術選定 技術選定するにあたって、条件は以下の通りでした。 ページ遷移を挟まずにログインができる どのアプリケーションプラットフォームでも利用できる 1つ目の条件からモーダルコンポーネントを提供することはほぼ決まっていました。 予約入力をしている途中でログインページに遷移すると体験を損ねてしまうので、スムーズな予約を実現するためにはモーダルコンポーネントでの提供が必須でした。 2つ目の条件として、一休のサービスは主に Vue.js, Python テンプレート, ASP.NET 等のプラットフォームで 画面描画を行なっているのですが、どのプラットフォームでもログインができるようにする必要がありました。 そのためには Web 標準で使える WebComponents が適任でした。 WebComponents について詳しくは こちらの記事 がよくまとまっています。 WebComponents の実装フレームワークには Polymer や LitElement がありますが、 Vue CLI が標準で WebComponents をビルドできる のでこれを利用しました。 内部的には vue-web-component-wrapper が使われています。大変助かりました🙏 3. 実装 一部省略していますが、下記のインターフェースになるようにログインモーダルを実装しています。 実装したログインモーダルは <ikyu-login> という CustomElement で定義しました。 HTML に <ikyu-login> と書けば通常の HTML タグ同様に使えるようになります。 Attributes Attribute Type Default Note login-only Boolean false ログイン画面のみ表示するか signup-only Boolean false 会員登録画面のみ表示するか open Boolean false モーダルを表示するか Events Event Type Note login Boolean ログイン及び会員登録成功 error Error ログイン及び会員登録失敗 close Boolean モーダルを閉じる HTML への組み込み 実際に HTML への組み込みを見てみましょう。 CustomElement に属性を指定する場合は setAttribute 関数、イベントを取得する場合は addEventListener 関数を使います。 < html > < head > < meta charset = "utf-8" > < title > ログイン </ title > < script src = "https://unpkg.com/core-js-bundle@3.0.0-alpha.1" ></ script > // IE11 用 < script src = "https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js" ></ script > // IE11, Edge 用 < script src = "./ikyu-login.js" ></ script > </ head > < body > < button onclick="openIkyuLogin () " > open </ button > < ikyu-login show-signup></ ikyu-login > < script > const ikyuLogin = document .querySelector ( 'ikyu-login' ) ; function openIkyuLogin () { ikyuLogin.setAttribute ( 'open' , true ) ; } ikyuLogin.addEventListener ( 'close' , () => { ikyuLogin.setAttribute ( 'open' , false ) ; } ) ; ikyuLogin.addEventListener ( 'login' , ( status ) => { // リダイレクトしたりする } ) ; </ script > </ body > </ html > Vue への組み込み Vue に組み込むときは通常の Vue コンポーネントと同様に props や event のやりとりができます。 setAttribute が props , addEventListener が event に置き換わるイメージです。 下記は実際の利用例です。 <template> <div> <button @click= "openIkyuSignupOnly()" >モーダルを開く</button> <ikyu-login :open= "ikyuLoggingin" :login-only= "ikyuLoginOnly" :signup-only= "ikyuSignupOnly" @close= "ikyuLoggingin = false" @login= "reload" @error= "onError" > </div> </template> <script lang= "ts" > export default Vue.extend( { data() { return { ikyuLoggingin: false , ikyuLoginOnly: false , ikyuSignupOnly: false , } } , methods: { openIkyuSignupOnly() { this .ikyuLoginOnly = false ; this .ikyuSignupOnly = true ; this .ikyuLoggingin = true ; } , reload( status ) { window . location .reload() } , onError(error: Error) { console.log(error); } , } } ); </script> 注意点として、Vue 内で CustomElements を利用するときは Vue コンポーネントとして見なされてしまうため、明示的に Vue コンポーネントではないことを宣言する必要があります。 Vue.config.ignoredElements = [ 'ikyu-login' ] ; InternetExplorer の対応 案の定 InternetExplorer では WebComponents が動作しないので、 憎しみと 愛を持って対応します。 pollyfill の読み込み、スタイル崩れなどなどありましたが結果なんとかなってよかったです。IE 許すまじ。 まずは IE および Edge 用に core-js と webcomponents-loader を読み込みます。 <script src= "https://unpkg.com/core-js-bundle@3.0.0-alpha.1" ></script> // IE11 用 <script src= "https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js" ></script> // IE11, Edge 用 vue-web-component-wrapper 内ではES6で実装しているのでそのままでは IE で動きません。許さない。 これに対処するため babel で pollyfill してやる必要があります。また、ES6 をトランスパイルするための prebuild を記述します。 package.json は以下のようになりました。 { " scripts ": { " prebuild ": " npm-run-all babel node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js -o node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js ", " build ": " cross-env vue-cli-service build --no-clean --target wc --inline-vue --name ikyu-login 'src/components/IkyuLoginAndSignup.vue' " , } , " dependencies ": { " babel-loader ": " 8.1.0 ", " babel-plugin-transform-es2015-arrow-functions ": " 6.22.0 ", " core-js ": " 3 " , } , " devDependencies ": { " @babel/cli ": " 7.11.6 ", " @babel/core ": " 7.11.6 ", " @babel/plugin-syntax-async-generators ": " 7.8.4 ", " @babel/plugin-transform-arrow-functions ": " 7.10.4 ", " @babel/plugin-transform-regenerator ": " 7.10.4 ", " @babel/preset-env ": " 7.11.5 ", " babel-plugin-transform-async-to-generator ": " 6.24.1 ", " babel-plugin-transform-custom-element-classes ": " 0.1.0 ", " babel-plugin-transform-es2015-shorthand-properties ": " 6.24.1 ", " babel-plugin-transform-es2015-template-literals ": " 6.22.0 " , } } これで InternetExplorer でも WebComponents が使えるようになりました🎉 IE 特有のデザイン崩れ等も対応しつつ、モダンブラウザと遜色なく動作するようになっています。 余談ですが IE に 悪態をつきながら 対応していたら同僚から実績解除の称号を得ました😇 Legendary Hate Speech... 4. 所感 WebComponents を使ってみてよかった点と改善点を挙げます。 よかった点 コンポーネント指向であること ログインモーダルの他にも認証モーダルなどを実装したのですが、共通コンポーネントを使い回せたので実装コストがかなり減りました。 Vue.js との親和性が高い 一休のアプリケーションプラットフォームは Vue.js が多いので、 Vue コンポーネントと同様のコンテキストスイッチで実装できたのはよかったです。 改善点 そのまま配信しようとすると重くなってしまった 当然ですが、実装を進めていくとどんどんファイルサイズが大きくなってしまいます。 そのまま配信するとコンポーネントを読み込んでいるページパフォーマンスが下がってしまう懸念があるので gzip での圧縮やブラウザキャッシュを付けて改善することが必要となってきます。 結果的に各サービスでのログイン実装が簡潔になり、ログイン処理が新会員基盤に集約できました。 Web 標準でお手軽に再利用できるコンポーネントが必要になった場合は、是非 WebComponents の選択肢を考えてみてください。
アバター
こんにちは。 宿泊事業本部のいがにんこと山口です。 id:igatea ヤフー社内で毎年開催されているハッカソンイベント「Internal Hack Day」が先日6/27~6/29に開催されました。 そのハッカソンにZ Holdingsのアスクル、一休、PayPay、ZOZOテクノロジーズが一緒に参加出来る運びになり、一休からも参加させていただきました。 この記事ではInternal Hack Dayに参加してきたレポートを書きます。 Internal Hack Day Internal Hack Dayはヤフー社内で毎年行われている社内向けのハッカソンイベントです。 チームを組んでテーマに沿った新しい機能やサービスのアイデアを出し合い、短い期間で作り上げて競い合うイベントとなっています。 チームは自社だけで組んでもいいですし、他社の方と組むことも可能です。 Internal Hack Dayのルールは以下の通りです。 開発時間は24時間、9:00~21:00の2日間 プレゼン時間は90秒 通常ルールは上記のみなのですが、新型コロナウイルスの流行に伴い、今回は上記ルールに加えて以下のルールも追加されました。 「新しい生活様式での課題解決」をテーマに 開発、発表は原則オンラインで行う 自分はハッカソンには初参加だったのですが、ハッカソンというと開催会場でみんなで集まって開発、開催会場で発表、というのが当たり前だと思っていたのですが、それが全てオンラインで行われるということで新しい試みでおもしろかったです。 開発 ハッカソン中はずっとオンラインのビデオ通話を繋げながらやっていました。 24時間なので、ずっと集中して出来るわけではないのでオンオフ切り替えるためにもご飯の時なんかは通話を切ってゆっくり過ごしたりしていました。 やっぱりオンラインコミュニケーションは大変だったりします。 オンラインで通話をしていると熱量とか空気感が伝わりづらいし感じにくい。 自分のチームは2人チームだったのでまだ問題ないのですが、これが人数が増えてくると収集つかなそうな印象を受けました。 ハッカソン中はずっと議論をしていて、手よりも口を動かすことのほうが多かったです。 最初の3時間は新しい生活様式の課題って何かをずっと議論していました。 仮説を建てて検証、さらに深堀して課題として正しいのか、課題にアプローチできているのか、今の自分たちに24時間で行えることか(発表時に成果物を見せなければいけないのでここは重要)をしっかり行ってから開発を始めました。 オンラインでの開催なので他チームの状況が全く分からなかったのもちょっとドキドキしました。 自分たちはまだ全然形に出来ていないけど他のチームはどんな感じなんだろう?と思いながら開発していました。 ここらへんはオンライン開催の課題ですね。 オンラインで複数人の声が混ざっても聞き取れるように 最終的に僕たちはオンラインでの会議や飲み会での会話がぎこちなくなりがち、というところに目をつけました。 原因の一つに複数人の声が混ざった時に聞き分けづらいことがあると考え、そんな問題を解決するためにオンラインで複数人の声が混ざっても聞き取れるように、そんなツールを作りました。 ZoomのURLを入れると、同じURLを入力した人同士を自動で音が被らないように音が聞こえる方向を調整してくれます。 課題の目の付け所、アプローチなどが評価されて、元々の賞にはなかった特別賞が急遽作られて表彰していただけたのはとても嬉しかったです。 結び グループ内で他会社と一緒に何かイベントをやるというのは初めてだったので、別の会社のカルチャーに触れることが出来てとても刺激的でした。 また会社をまたいで何かやりたいですね。 他の受賞作品などはヤフーのテックブログにて。 https://techblog.yahoo.co.jp/entry/2020071430011124/
アバター
こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLサーバ実装時に遭遇するN+1問題と、その解決のために使えるライブラリを紹介します。 フィールド単位でresolverを用意する N+1問題 GoのDataLoaderライブラリ DataLoaderの仕組み DataLoaderのサンプルコード DataLoaderとDataDog APM むすび 採用情報 フィールド単位でresolverを用意する GraphQLでは、クライアントのクエリに応じてオンデマンドに結果を取得できます。 たとえば、以下のクエリを投げると… { accommodation(accommodationId: "00001050") { name } } 以下のようなレスポンスが取得できます。 { " data ": { " accommodation ": { " name ": " マンダリン オリエンタル 東京 " } } } ここで、施設のクチコミレーティングを取得したい場合、以下のようなクエリを投げることができます。 { accommodation(accommodationId: "00001050") { name rating } } このとき、サーバサイドではクエリによって必要なカラム(場合によっては、JOINするテーブル)が動的に変わります。バックエンドで動的にSQLを組み立てるのも1つの方法でしょう。しかし、SQLの組み立てロジックが複雑になったり、生成されるSQLが巨大でパフォーマンスの悪いものになったりするといった懸念点があります。 別のアプローチとして、追加のJOINが必要になるフィールドには GraphQL resolverを別に用意して、GraphQLサーバにレスポンスの組み立てを任せる、というものもあります。このようにすると、各resolverの実装をシンプルに保ちつつ、複雑なクエリに応答することができます。 一休.comでも使用している gqlgen というGoのGraphQLライブラリでは、以下の手順でフィールド単位のresolverを用意できます。 (1) GraphQLのスキーマと、gqlgenの設定ファイルを用意する # schema.graphql type Accommodation { name: String! rating: Float! } # gqlgen.yml models: Accommodation: fields: rating: resolver: true # この設定がキモ (2) go generate して、インタフェースを満たす Resolverのインタフェースは以下のようになります。 // generated.go type AccommodationResolver interface { Rating(ctx context.Context, obj *Accommodation) ( float64 , error ) } これを満たす実装は以下のように書けます。 // resolver.go func (r *accommodationResolver) Rating(ctx context.Context, obj *Accommodation) ( float64 , error ) { summary, err := appcontext.From(ctx).Loader.ReviewSummary.LoadByAccommodationID(ctx, obj.AccommodationID) if err != nil { return 0 , err } return summary.Rating, nil } N+1問題 このようにすると、無駄なデータの取得を避けつつ、resolverの実装がシンプルに保つことができます。しかし、以下のようなクエリを処理する際には問題が発生します。 { accommodation(accommodationId: "00001050") { name rating neighborhoods { name rating } } } ここでは、ある施設の近隣施設を取得して、それらのratingを取得しています。仮に、クチコミのレーティング取得が select rating from review_summary where accommodation_id = ? のようなクエリで実装されていると、このクエリが近隣施設の数だけ実行されることになります。このように、関連レコードの件数の分、追加データ取得用のクエリが発生する状態をN+1問題と呼びます。 このときのSQLの流れは以下のようになります。 -- 親の accommodation と rating を取得 select name from accommodation where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; -- 近隣施設を取得 select accommodation_id, name from neighborhood_accommodation where accommodation_id = ?; -- 近隣施設の数だけ rating を取得するクエリが発行される。。。 select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; select rating from review_summary where accommodation_id = ?; -- ↑ではなく、↓のように一括で取ってほしい select rating, accommodation_id from review_summary where accommodation_id in (?, ?, ?, ?, ?); このような場合、RailsなどではORMの 一括読み込み 機能を利用します。 一方、gqlgenでは、各resolverは平行に実行されるので、ORMによる一括読み込みは利用できません。このような場合に利用可能な、データ取得をバッチ化する仕組みが DataLoader です。DataLoaderのオリジナルはJavaScript実装の graphql/dataloader ですが、様々な言語のDataLoader実装が公開されています。また、DataLoaderはGraphQLサーバで使うために作られたライブラリですが、GraphQLとは関係なく、REST APIなどでも利用できます。 GoのDataLoaderライブラリ Go製の有力なDataLoaderライブラリは、私が把握している範囲では以下の2つです。 https://github.com/graph-gophers/dataloader https://github.com/vektah/dataloaden 前者は graph-gophers/graphql-go 、後者は gqlgen の作者によるライブラリです。 一休.comではgqlgenを使っているため、当初は dataloaden の方を試しました。dataloadenはgqlgenと同じくgo generateによるコード生成ライブラリとなっており、型安全なDataLoaderを生成できるという特長があります。しかし、モデルの配置方法などに制約が強く、私たちの用途には合いませんでした。 そこで、今は graph-gophers/dataloader を使っています。 DataLoaderの仕組み サンプルコードに入る前に、DataLoaderの仕組みについて解説します。DataLoaderは前述したようにデータ取得をバッチ化するためのライブラリですが、そのための仕組みとしては「一定時間待って、その間に実行されたデータ取得リクエストをバッチ化する」というアプローチを取っています。 「一定時間」は、1msや16msなどといった値になります。この値が大きくなるとバッチ化できる範囲が広がりますが、その分レスポンスタイムが遅くなるおそれがあります。 graph-gophers/dataloader では、dataloader.Loader の Load() メソッドを呼び出すと、 Thunk という型の関数が返ってきます。この関数はJavaScriptのPromiseのようなもので、一定時間待った後で値が取得できます。 thunk := dataloader.Load(ctx, key) 実際のサーバでは、 Load() は平行して呼ばれるため、各goroutineが Thunk を受け取ります。 // goroutine A thunk := dataloader.Load(ctx, key) // goroutine B thunk := dataloader.Load(ctx, key) // goroutine C thunk := dataloader.Load(ctx, key) このthunkを呼び出すと、結果を取得できます。 thunk := dataloader.Load(ctx, key) result, err := thunk() 一定の待ち時間の間に呼び出された Load() のkeyを覚えておいて、一括でデータ取得を行うのがDataLoaderの仕組みです。 // ここで 1ms のタイマースタート s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000001" ) // 0.5ms経過… // この呼び出しは↑と一緒にバッチ化される s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000002" ) // 1ms 経過:↑の2件をまとめて、以下のクエリを発行し、結果を返す // select accommodation_id, rating from review_summary where accommodation_id in ('00000001', '00000002') // この呼び出しは別のバッチになる s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000003" ) DataLoaderのサンプルコード 完全な形のサンプルコードとしては、 hatena/go-Intern-Bookmark がオススメです。ここでは、このサンプルコードを題材に graph-gophers/dataloader の使い方を解説します。 DataLoaderを使うには、まず以下のようにバッチでデータ取得を行う関数を用意します(コードは簡略化しています)。 // loader/entry.go func newEntryLoader(app service.BookmarkApp) dataloader.BatchFunc { return func (ctx context.Context, keys dataloader.Keys) []*dataloader.Result { entryIDs := keysToEntryIDs(keys) entrys, _ := app.ListEntriesByIDs(entryIDs) // ここがキモ。 select * from entry where id in (...) を投げる return entrysToResults(entrys) } } 次に、この関数を context に保持させます。なぜ context に保持させるのかというと、DataLoaderのキャッシュ機能は リクエスト単位のデータのキャッシュを意図している からです。リクエスト毎に内容が空になる context は、DataLoaderを保持させる場所にぴったりです。これによって、バッチ化の対象は同一リクエスト内の Load() の呼び出しに限定されます。 contextへの追加はミドルウェアで行います。 // web/server.go func (s *server) attatchLoaderMiddleware(next http.Handler) http.Handler { loaders := loader.New(s.app) return http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r.WithContext(loaders.Attach(r.Context()))) }) } このようにしてcontextに登録したDataLoaderは、以下のようにして呼び出せます。 // resolver/bookmark_resolver.go // hatena/go-Intern-Bookmark は graph-gophers/graphql-go を使っているため、 // resolverの書き方がgqlgenとは異なる func (b *bookmarkResolver) Entry(ctx context.Context) (*entryResolver, error ) { // LoadEntry は context から DataLoader を取得し、Load() を呼び出して、結果を Entry 構造体にして返す entry, err := loader.LoadEntry(ctx, b.bookmark.EntryID) if err != nil { return nil , err } return &entryResolver{entry: entry}, nil } DataLoaderとDataDog APM 一休で使っている DataDog のAPM(Application Performance Monitoring)だと、以下のようなトレースが見えます。resolverが平行に実行されている様子が分かりやすいです。 紫色がDB問い合わせで、Ratingの取得は1回のDB問い合わせにまとめられていることがわかります。また、APMを活用すると、「待ち時間が短すぎて、複数のバッチに分かれてしまっていないか?」といった調査も簡単にできます 👍 むすび 今回はGoのDataLoaderライブラリの使い方を紹介しました。DataLoaderはややトリッキーですが、ハイパフォーマンスなGraphQLサーバの実装には欠かせないライブラリだと思います。 採用情報 一休では、GoやGraphQLに強みのあるエンジニアを募集しています! 一休.comのバックエンドは .NET Framework から Go への移行を進めていて、バックエンドでGoを書く割合が少しずつ増えているところです。 hrmos.co
アバター
一休エンジニアのインタビュー記事が公開されました。現場で働くエンジニアの生の声が詰まっていますので、ぜひご覧ください。 logmi.jp logmi.jp
アバター
来週1/29(水)にエンジニア向けの採用PRイベントとして 一休.comのプロダクト改善事例と開発の裏側 を開催します。 一休では、主力サービスである 一休.com、一休.comレストランのプロダクト開発に関わるエンジニア職種の方を積極採用中です。 本イベントでは約2年に渡る一休.comのプロダクト改善の歴史を振り返りながら、実際に取り組んだ課題と改善に対するアプローチについてエンジニアリングマネージャーの田中( id:kentana20 )がお話します。 トークセッションの後は、CTOの伊藤 ( id:naoya ) と2人でパネルディスカッションをしながら参加者のみなさまからの質問にもお答えします。 イベントの詳細、参加方法については以下のconnpassイベントページをご覧ください。皆様のご参加をお待ちしています! ikyu.connpass.com
アバター
こんにちは。 システム本部CTO室のakasakasです。 今回は、Datadog Log Management を使ってアプリケーション稼働モニタリングをしている話をしたいと思います。 一休のモニタリング周りの話 インフラのリソースモニタリング 外形監視 モニタリング観点で一休が抱えていた課題 Datadog Log Management Datadog Log Management からダッシュボード作成 Datadog Log Management からアラート作成 必要なメトリクスはカスタムメトリクスを作る graph_snapshot API を使って、デイリーレポート まとめ 最後に 一休のモニタリング周りの話 Datadog Log Management とアプリケーション稼働モニタリングの話をする前に、一休でどのような監視をしているのか?という話を簡単にします。 一休ではDatadogをモニタリングツールとして使っています。 主な用途は2つあります。 インフラのリソースモニタリング 外形監視 インフラのリソースモニタリング インフラメトリクスのダッシュボードとアラートの設定は運用として乗っています。 具体的には、サービス(宿泊・レストランetc)毎のアプリケーションサーバやDBサーバのモニタリングをしています。 CPUで閾値を超えたら、Slack通知が飛び、エンジニアが対応するという形をとっています。 インフラメトリクスのダッシュボード 外形監視 Datadog Synthetics API Tests を使って、外形監視をしています。 こちらも同様に、外形監視で異常が起きたら、Slackに通知が飛び、エンジニアが対応します。 Synthetics API Tests モニタリング観点で一休が抱えていた課題 インフラレイヤーでのモニタリングはできているが、アプリケーションレイヤーでのモニタリングはできていないというのが課題感としてありました。 ここでいうアプリケーションレイヤーでのモニタリングとは 予約が正常にできているかどうか エラーが多発してないか? 予約通知メールが正常に送られているかどうか メール送信件数が適切か?異常に多い、少ないということはないか? 検索導線でのリクエスト数がどの程度あるのか?エラーがどの程度あるのか? というサービスの状態がヘルシーかどうかという観点です。 ※レイテンシーやエラーレートといったAPMとは異なります。Datadog APMは一部のサービスで運用しています。 これらを時系列で監視し(e.g. 10分毎の予約件数/1日ごとのメール送信件数) 異変があれば、アラートを飛ばすという仕組みがあれば、いち早く障害に気づけると考えました。 Datadog Log Management このアプリケーション観点の監視をするために、Datadog Log Managementが有効だと考えました。 Datadog Log Management は Datadog 上でログを管理するサービスです。 一休では昨年ログ管理サービスをLogentriesからDatadog Log Management に完全移行しました。 導入方法や詳細な使い方は割愛します。 docs.datadoghq.com Datadog Log Management を使って、アプリケーションログ・アクセスログをベースに時系列の予約状況・検索数の推移・メール送信件数etcを集計&ダッシュボードでグラフ化&アラートの設定ができれば、アプリケーション稼働モニタリングが実現できると考えました。 Datadog Log Management からダッシュボード作成 実際にDatadog Log Management から作成したアプリケーションモニタリングのダッシュボードがこちらです。 宿泊スマートフォン予約状況 宿泊PC・スマホ検索導線のアクセス推移とエラー状況 グラフの作成方法は LogEvents を選択 タグで絞り込み のみで、簡単です。 Datadog Log Management からアラート作成 予約状況の監視もアラートで検知することもできます。 New Monitor から Logs を選択し、検索クエリを指定すれば、Monitorが作成できます。 必要なメトリクスはカスタムメトリクスを作る Datadog Log Management では取得できないメトリクスもあると思います。 その場合は、Datadog API を使って、カスタムメトリクスを作ります。 メトリクス API については下記をご覧ください。 docs.datadoghq.com Datadog API を扱う際はRubyとPythonでそれぞれ API Clientがあるので、そちらを使うのがいいと思います。 GitHub - DataDog/datadogpy: The Datadog Python library GitHub - DataDog/dogapi-rb: Ruby client for Datadog's API カスタムメトリクスを作る例として、一休では検索にSolrを使っています。 SolrのIndex数を監視したいという場合は、SolrからIndex数を取得し、APIを使ってカスタムメトリクスを作成しDatadogに送信します。 具体的には下記のようなスクリプトをLambdaで定期実行するイメージです。 from datadog import initialize, api import time import requests options = { 'api_key' : '<DATADOG_API_KEY>' } initialize(**options) # Solrにリクエスト r = requests.get( '<Solr Endpoint>' ) # Index数取得 index_count = r.json()[ 'index_count' ] now = time.time() # Solrのindex数をカスタムメトリクスにして、Datadogに送信 api.Metric.send(metric= "solr.index.count" , points=(now, index_count), type = "count" ) カスタムメトリクスが作成できれば、Datadog上でダッシュボードとアラートが設定できます。 カスタムメトリクスから作成したSorのインデックス数 Datadog Log Management から取得できないが、監視したい項目については カスタムメトリクスを作るのもアリだと思います。 graph_snapshot API を使って、デイリーレポート ただ、単純に ダッシュボード作りました アラート作りました だけだと、せっかく作ったダッシュボードやアラートがエンジニアから忘れ去られそうという懸念がありました。 なので、「アプリケーションちゃんと動いているよ!エラーちょっと多いよ!」というのを伝える意味も込めて、デイリーレポートをslackに投稿するようにしました。 下記のようなイメージです。 アプリケーション稼働モニタリングのデイリーレポート デイリーレポートをすることで、「エラーちょっと多いから確認した方がよくない?」みたいなことになり、調査&対応するという方向でエンジニアが動いてくれます。 これは graph_snapshot API を使って、キャプチャを作り、Slackに投稿するスクリプトをLambdaで日時で動かしています。 graph_snapshot API については下記をご覧ください。 docs.datadoghq.com graph_snapshot API については細かいところを含めて、いくつか注意点があるので書いときます。 1.デフォルトの Rate Limitiing がけっこう厳しい https://docs.datadoghq.com/ja/api/?lang=bash#rate-limiting に記載がある通り、 graph_snapshot API 呼び出しのレート制限値は、60/時間/Organization です。これは、オンデマンドで増やすことができます。 とあるので、無邪気にAPIを叩いていると、すぐに引っかかります。 2. graph_snapshot API のタイムゾーンがUTC固定 graph_snapshot API のタイムゾーンはUTCになっていて、任意のタイムゾーンに変更できません。 3. API リクエストで渡すパラメータがちょっと複雑 graph_snapshot API でグラフを作成する場合のAPIリクエストでJSONを扱う場合があるので、ちょっと面倒です。 DashBoardと同様のグラフを作りたい場合は、該当するグラフのJSONをリクエストにつめる必要があります。 GitHub - DataDog/datadogpy: The Datadog Python library を使ったサンプル例が以下になりますが、JSONが長くなってしまうのが少し煩わしく感じるかもしれません。 from datadog import initialize, api import time options = { 'api_key' : '<DATADOG_API_KEY>' , 'app_key' : '<DATADOG_APPLICATION_KEY>' } initialize(**options) # Take a graph snapshot end = int (time.time()) start = end - ( 60 * 60 ) resp = api.Graph.create( graph_def= '{ \ "viz": "timeseries", \ "requests": [ \ { \ "q": "xxxxxxxxxxx", \ "type": "bars", \ "style": { \ "palette": "dog_classic", \ "type": "solid", \ "width": "normal" \ } \ } \ ], \ "yaxis": { \ "scale": "linear", \ "min": "auto", \ "max": "auto", \ "includeZero": true, \ "label": "" \ }, \ "markers": [] \ }' , start=start, end=end ) print (resp[ "snapshot_url" ]) まとめ 今回は、Datadog Log Management を使って、アプリケーション稼働モニタリングを実現した話をしました。 単純なログ管理ツールとして使うだけでも、Datadog Log Management は便利ですが、 ダッシュボードやアラートなどを組み合わせることで、アプリケーションの状態が一目でわかるというのはいいと思いました。 最後に Datadogのサポートの皆様にはいつも助けられています。 どんな問い合わせに対しても、いつも丁寧にサポート頂いているDatadogの皆様に御礼申し上げます。
アバター
Amazon EKS Windows Container を使ってみる。 今年の10月に、Amazon EKSがWindows ワーカーノードのサポートを開始しました。 aws.amazon.com 一休では、今年の初めから、既存アプリケーションのEKS移行を行っており、夏には、ほぼすべてのLinux系アプリケーションをEKSへ移行することができました。 user-first.ikyu.co.jp これを踏まえ、Windows系のウェブアプリケーションもEKSへ移行できないか、技術検証を行っています。具体的な検証ポイントは以下のふたつです。 Amazon EKS で、Linuxコンテナ同様、Windows コンテナが動作するか。 既存のWindowsのWebアプリケーション(ASP.NETアプリケーションをDockerコンテナ化できるか。 2については、公開されている各種チュートリアルやサンプルなどを参考に、動作させることができました。 この記事では、主に、1.の検証でわかったことや注意点を紹介したいと思います。 この記事は 一休.com Advent Calendar 2019 の20日目の記事です。 Amazon EKS でWindowsのワーカーノードを作ってみる。 まず、eksctlを使って新しいeksクラスタを作成し、Windowsのワーカーノードを作ってみます。 作成のコマンドは以下の通りです。 eksctl create cluster -f cluster.yaml --install-vpc-controllers --install-vpc-controllers という引数を渡すことで、Windowsノードグループに必要なリソースを追加します。 cluster.yamlの内容は以下の通りです。 apiVersion : eksctl.io/v1alpha5 kind : ClusterConfig metadata : name : dev-win region : ap-northeast-1 version : "1.14" vpc : id : "vpc-123456789" cidr : "10.0.0.0/16" subnets : private : ap-northeast-1a : id : "subnet-xxxxxxxx" cidr : "10.0.144.0/24" ap-northeast-1c : id : "subnet-yyyyyyyy" cidr : "10.0.145.0/24" ap-northeast-1d : id : "subnet-zzzzzzzz" cidr : "10.0.146.0/24" iam : withOIDC : true nodeGroups : - name : ng-control-1 labels : { role : workers } tags : { Stack : development, Site : ikyucom, Role : eks-node, k8s.io/cluster-autoscaler/wincluster : owned, k8s.io/cluster-autoscaler/enabled : "true" } instanceType : t3.medium desiredCapacity : 2 maxSize : 2 ebsOptimized : true privateNetworking : true securityGroups : attachIDs : [ sg-xxxxx ] withShared : true ssh : allow : true publicKeyPath : xxxx-key - name : ng-win-1 amiFamily : WindowsServer2019FullContainer labels : { role : workers } tags : { Stack : development, Site : ikyucom, Role : eks-node, k8s.io/cluster-autoscaler/wincluster : owned, k8s.io/cluster-autoscaler/enabled : "true" } instanceType : t3.medium desiredCapacity : 3 maxSize : 3 ebsOptimized : true privateNetworking : true securityGroups : attachIDs : [ sg-xxxxx ] withShared : true ssh : allow : true publicKeyPath : xxxx-key 注目点はふたつです。 withOIDC: true を設定しています。IAMのアクセス許可をOIDC(OpenID Connect)を使って、Kubernetes サービスアカウントに割り当てるために必要な設定です。詳細は後述します。 ノードグループをふたつ作成しています。LinuxのノードグループとWindowsのノードグループです。これは、一部のシステム系のPodが、Linuxでしか動かないためです。 作成したクラスタにポッドをデプロイする。 システム系のポッドをデプロイする。 一休では、 aws-alb-ingress-controller と external-dns を使ってWebアプリケーションを提供しています。Windowsのコンテナアプリケーションでもこのふたつを使っていきたいです。 ただし、このふたつは、Windowsノードグループ上では動作しません。Linux上で動作させる必要があります。 これを実現するために、node selectorを使います。Linux ノードにはデフォルトで、 kubernetes.io/os: linux というラベルがついています(Windowsの場合は、 kubernetes.io/os: windows がつきます)。 これを踏まえると、aws-alb-ingress-controllerのyamlは以下の通りになります。 apiVersion : apps/v1 kind : Deployment metadata : labels : app.kubernetes.io/name : alb-ingress-controller name : alb-ingress-controller namespace : kube-system spec : selector : matchLabels : app.kubernetes.io/name : alb-ingress-controller template : metadata : labels : app.kubernetes.io/name : alb-ingress-controller spec : containers : - name : alb-ingress-controller args : - --ingress-class=alb - --cluster-name=dev-win - --aws-vpc-id=vpc-123456789 - --aws-region=ap-northeast-1 image : docker.io/amazon/aws-alb-ingress-controller:v1.1.3 ports : - containerPort : 10254 name : health protocol : TCP serviceAccountName : aws-alb-ingress-controller nodeSelector : kubernetes.io/os : linux external-dns は以下のようになります。 apiVersion : extensions/v1beta1 kind : Deployment metadata : name : external-dns namespace : kube-system spec : strategy : type : Recreate template : metadata : labels : app : external-dns spec : serviceAccountName : external-dns containers : - name : external-dns image : registry.opensource.zalan.do/teapot/external-dns:v0.5.17 args : - --log-level=info - --domain-filter=dev.com - --policy=upsert-only - --provider=aws - --registry=txt - --interval=1m - --source=service - --source=ingress ports : - containerPort : 7979 protocol : TCP livenessProbe : failureThreshold : 3 httpGet : path : /healthz port : 7979 scheme : HTTP periodSeconds : 10 successThreshold : 1 timeoutSeconds : 1 nodeSelector : kubernetes.io/os : linux このふたつのyamlをapplyすることで、両方ともLinuxノードで動かすことができます。 ※ サービスアカウントの設定については後述します。 Webアプリケーションをデプロイする。 こちらもnode selectorを使って、windowsノードグループにデプロイさせるようにします。 事前にデプロイした、alb-ingress-controllerとexternal-dnsを使って、クラスタ外からアクセスできるウェブアプリケーションとして、デプロイするには以下のyamlになります。 ## Deploymentの定義 apiVersion : apps/v1 kind : Deployment metadata : name : test-web-app spec : selector : matchLabels : app : test-web-app replicas : 1 template : metadata : labels : app : test-web-app spec : containers : - name : test-web-app image : xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/test-web-app:latest ports : - name : http containerPort : 80 imagePullPolicy : IfNotPresent livenessProbe : httpGet : port : 80 path : /prob failureThreshold : 5 periodSeconds : 5 readinessProbe : httpGet : port : 80 path : /prob failureThreshold : 5 periodSeconds : 5 dnsConfig : options : - name : ndots value : '1' serviceAccountName : test-web-app nodeSelector : kubernetes.io/os : windows --- ## Serviceの定義 apiVersion : v1 kind : Service metadata : name : test-web-app namespace : default spec : ports : - port : 80 protocol : TCP targetPort : 80 selector : app : test-web-app type : NodePort --- ## Ingressの定義 apiVersion : extensions/v1beta1 kind : Ingress metadata : labels : app : test-web-app name : test-web-app annotations : alb.ingress.kubernetes.io/certificate-arn : arn:aws:acm:ap-northeast-1:xxxxxxxxxxxx:certificate/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee alb.ingress.kubernetes.io/healthcheck-path : /health alb.ingress.kubernetes.io/listen-ports : '[{"HTTPS":443}]' alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/security-groups : security-group-name alb.ingress.kubernetes.io/subnets : subnet-xxxxxxxx,subnet-yyyyyyyy,subnet-zzzzzzzz kubernetes.io/ingress.class : alb external-dns.alpha.kubernetes.io/hostname : testapp.dev.com spec : rules : - http : paths : - path : /* backend : serviceName : test-web-app servicePort : 80 これをapplyすることで、無事、Windowsノードにウェブアプリケーションをポッドを配置できました。 AWSリソースへのアクセス権限はどうするか ここまでの説明で大事な点を割愛しています。alb-ingress-controllerやexternal-dnsのAWSのリソースへのアクセス権限についてです。また、デプロイするウェブアプリケーションも要件によってはAWSのリソースにアクセスするでしょう。 このような場合、 kube2iam や kiam を使うことでポッド単位でAWSリソースに対するアクセス権限の制御が行えます。 実際、一休では、すでにサービスインしているクラスタではkube2iamを使っています。 しかし、kube2iamは、Deamonsetであり、Windowsノードでは動作しません。したがって、別の方法で、同じことを実現する必要があります。 kube2iamなしでIAM ロールを Kubernetes サービスアカウントに関連付ける すでにいくつかの記事で紹介されていますが、今年の9月に、EKSがネイティブで IAM ロールを Kubernetes サービスアカウントに関連付ける仕組みを提供し始めました。 aws.amazon.com dev.classmethod.jp この仕組みを使えば、kube2iamを使わずに、podのAWSリソースに対するアクセス権限の制御ができそうです。 実際にうまくいくかどうか試してみます。ここでは、aws-alb-ingress-controllerがきちんとapplication load balancerを作成できるかどうか確認してみます。 まず、上述したクラスタ定義を使って、eksctlでクラスタを作ります。すると、AWSコンソールのIAMの画面のIDプロバイダーに、下記のように、OpenID Connectのプロバイダが作成されます。 withOIDC: true を設定したのはこのためです。 また、AWSコンソールで新しく作成されたEKSクラスタの設定を見ると、上述のOpenID ConnectプロバイダのURLが、表示されます。 次に、alb-ingress-controller用ロールに信頼関係を設定を設定します。 公式ブログのチュートリアル では、 eksctl create iamserviceaccount コマンドを使って、新規にKubernetesサービスアカウントと対応するIAMロールを作成しています。このコマンドを実行するだけで、以下の3つの必要な設定が、一発で完了します。 - IAMロールの新規作成 - Kubernetesサービスアカウントの作成 - KubernetesサービスアカウントがIAMロールを引き受けるようにする信頼ポリシーの設定 一方、わたしたちのケースでは、新規にIAMロールを作るらずに、既存のEKSクラスタで使っているaws-alb-ingress-controller用のロール alb-ingress-controller-role を使いまわしたいです。 このため、 eksctl create iamserviceaccount コマンドは使わずに、手動で設定してみます。 といっても設定は簡単です。↓の通り、対象のロールの信頼関係編集ボタンクリックします。 そして、次のようなポリシーを設定します。 { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Allow ", " Principal ": { " Federated ": " arn:aws:iam::xxxxxxxxxxxx:oidc-provider/oidc.eks.ap-northeast-1.amazonaws.com/id/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx " } , " Action ": " sts:AssumeRoleWithWebIdentity ", " Condition ": { " StringEquals ": { " oidc.eks.ap-northeast-1.amazonaws.com/id/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:sub ": " system:serviceaccount:kube-system:aws-alb-ingress-controller ", " oidc.eks.ap-northeast-1.amazonaws.com/id/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:aud ": " sts.amazonaws.com " } } } ] } xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx には、先ほど作成された OpenID Connectプロバイダのarnの識別子が入ります。 そして、Kubernetes側にaws-alb-ingress-controller用のサービスアカウントを作成します。↑のポリシーに書いた通り、サービスアカウント名は、 aws-alb-ingress-controller で作成します。 yamlは以下の通り。 apiVersion : v1 kind : ServiceAccount metadata : name : aws-alb-ingress-controller namespace : kube-system annotations : eks.amazonaws.com/role-arn : arn:aws:iam::xxxxxxxxxxxx:role/alb-ingress-controller-role # ロールやロールのバインディングも必要ですが、ここでは割愛します。 annotationsに、 eks.amazonaws.com/role-arn というキーで、ロールのarnを指定します。 あと、はこのサービスアカウントをapplyします。 AWSコンソールのロードバランサの画面で、クラスタ名のタグで検索してみると、↓の通り、albが作成されています。 これで、windowsノードにデプロイしたPodが外部からのリクエストを受けれるようになりました。 WIndowsノードの課題 これで、Windowsのウェブアプリケーションも、プロダクション環境で、EKSで動かせる、と思いきや、ひとつ大きなハードルがありました。 Datadogが、Windowsノードが含まれたKubernetesクラスタの監視をサポートしていないのです。 Linuxノードだけであれば、Datadogのhelmチャートを入れるだけで、必要なメトリクスをほぼすべて収集できます。 一休は監視/ログ管理/APMをすべてDatadogに集約しているので、WindowsノードだけDatadog以外の方法を採用する、というのは合理的ではありません。 こちらはDatadog側にリクエストを出している状況です。 まとめ EKSでWIndowsワーカーノードを扱う方法を書きました。クラスタの準備、とアプリケーションの動作確認自体は比較的簡単に行うことができました。 今後は、DatadogのWindows対応を待ちつつ、EKSへの移行を引き金にして、なかなか着手しにくいWindowsウェブアプリケーションのコンテナ化を推進し、必要なリファクタリングを行ったり、細かな技術検証をするフェーズになりそうです。 この記事の筆者について システム本部CTO室所属の 徳武 です。 サービスの技術基盤の開発運用、開発支援、SREを行なっています。
アバター
この記事は 一休.com Advent Calendar 2019 の18日目の記事です。 qiita.com 社内情報システム部 コーポレートエンジニアの大多和( id:rotom )です。 一休ではコーポレートIT、オフィスファシリティを中心に「情シス」業務を行っています。 皆さんはワークフロービルダー、使っていますか 👋 📑ワークフロービルダーとは ワークフロービルダーは、2019年10月にリリースされた新機能で GUI ベースで Slack 上のワークフローを作成し、業務の効率化を図れるものです。 slackhq.com すでに多くの解説記事があるため、ここでの詳細な説明は割愛しますが 有料プラン契約中なら追加料金不要で使える プログラミング不要で作成できる 様々なトリガーでアクションを自動化できる ことから自動化、効率化の中でも導入・運用のコストが低く、気軽に始めることができます。 リリース後、さっそく一休のワークスペースにも導入を行いました 💪 🏠 一休のワークスペースについて 2019年12月現在、一休は従業員数 約 400 名で Slack を全社導入しており、ゲストユーザーを含め、ワークスペースには約 550 名のアクティブメンバーが存在します。 エンジニアの比率は 14% ほどで、多くが営業で構成されている組織ですが、Slack は非常によく使われています。 テック系のスタートアップと比較すると DM 率は少々高めですが、おおむねパブリックチャンネル上で業務が行われています。 昨年の記事でも少し紹介しましたが、情シスや総務を始めとするバックオフィスへの依頼、申請も Slack のパブリックチャンネル上で行われています。 user-first.ikyu.co.jp まずはこららの Slack 上で定型的に行われている依頼、申請についてワークフロービルダーを試してみました! 🖥 物品購入依頼 一休のエンジニア・デザイナーへは Slack 上で CTO の承認後、希望するディスプレイやキーボードなどの周辺機器、ソフトウェアや書籍を購入し支給しています。 https://speakerdeck.com/kensuketanaka/introduce-ikyu こちらも以前は申請者から承認者である CTO へメンションを送り、CTO が承認後に情シス 購買担当へメンションで購入依頼、という運用でした。 フリーフォーマットは依頼しやすい一方で人によって記入内容に差があり、承認者や購買担当にとって必要な情報が足りないこともありました。また、この運用では承認者が購買担当へ手動で依頼する手間も発生しており、効率化の余地がありました。 この購入依頼は以下のようにワークフローで組まれています。 承認者が「承認する」を押すと自動的に購買担当へメンションが飛び、購買担当が「購入完了」を押すと依頼者へその旨を連絡します。 ワークフロー内では変数で Slack ID を利用することができるので、自動的に各担当までメンションを送ることができます。 🗃名刺発注依頼 総務への名刺発注についても専用の Slack チャンネルがありましたが、何となく前の人の内容に合わせる… といった形で依頼がされていました。 名刺についても英語表記の有無、携帯電話番号の有無などのオプションがあるものの、それを選択できる決まったフォーマットがありませんでした。 こちらは以下のようにワークフローを組みました。上長承認などのフェーズが無いため、フローというよりはシンプルなフォームとなっています。 依頼者は以下のフォームに必要な項目を入力し、送信することで総務への依頼が完了します。  💭ワークフロービルダーを使ってみて ワークフロービルダーのよいところは、何よりも GUI ベースの操作により数分でフローやフォームを作成できる、というお手軽さです。 また、Slack の管理者ユーザーだけではなく、ゲストユーザーを除く全ユーザーが作成できるように設定が可能です。 プログラミングスキルも高権限も不要なため、情シス・エンジニアに限らず、バックオフィスメンバー自らが業務の効率化に着手することができます。 一方、現状のワークフローでは日時を持たせることができなかったり、ボタンを押下できる人を指定できなかったり、と SaaS で提供されているワークフローと比較すると機能的にできないことも多いため、あくまで補助的なツールとして運用に組み込むとよいと思いました。 今後のアップデートにも期待しつつ、バックオフィス業務の改善に最大限に活用していきます💪 次は id:rs-tokutake の記事です! user-first.ikyu.co.jp
アバター
こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLをベースに、GoとTypeScriptでスキーマを共有しながら開発を進める方法について紹介します。 この記事は 一休.com Advent Calendar 2019 の16日目の記事です。 GraphQLとは ライブラリの選定 コードファースト vs スキーマファースト Goによるサーバ実装 TypeScriptによるクライアント実装 おわりに 参考文献 GraphQLとは GraphQL は、Facebookによって開発された、Web APIのための クエリ言語 です。その特徴もSQLに似ていて、データの取得や更新を宣言的な記述によって行うことが出来ます。 仕様は公開されており、リファレンス実装として graphql-js がありますが、それ以外にも 様々な言語 でGraphQLサーバを実装できます。 GraphQLでは以下のようなフォーマットで問い合わせ(query)を行います。 { accommodation(accommodationID: "00001290") { accommodationID name } } 結果は以下のようなJSONになります。 { " data ": { " accommodation ": { " accommodationID ": " 00001290 ", " name ": " ザ・リッツ・カールトン東京 " } } ここで、ある施設の近隣施設(neighborhoods)を取得したい、となった場合、クエリを以下のように書き換えます。 { accommodation(accommodationID: "00001290") { accommodationID name neighborhoods { accommodationID name } } } レスポンスは以下のように変わります。 { " data ": { " accommodation ": { " accommodationID ": " 00001290 ", " name ": " ザ・リッツ・カールトン東京 ", " neighborhoods ": [ { " accommodationID ": " 00002708 ", " name ": " 三井ガーデンホテル六本木プレミア " } , { " accommodationID ": " 00000662 ", " name ": " グランド ハイアット 東京 " } ] } } } このように、取得したいデータの形を宣言すると、その通りに返してくれる、というのがGraphQLの特徴です。ポイントは、取得したいデータの形を決める主導権は、クライアントにある、というところ。RESTでは、各APIがリソースを表すため、複数のリソースを取得して、その取得結果を合成したい、といった場合に不便なことがありますが、GraphQLではそういった問題点が解消されています。 ライブラリの選定 GraphQLを使い始める上で最初に考慮すべきことは、「GraphQLの機能をどの程度使うか」という点です。というのも、GraphQLサーバの実装は様々にありますが、GraphQLの仕様を完全に実装しているとは限らないからです。 GoでGraphQLサーバを書くためのライブラリの中で、仕様のカバー率が最も高いのは gqlgen であると思われます。 GoのGraphQLライブラリの機能比較: https://gqlgen.com/feature-comparison/ このgqlgenにしても、 未実装機能がいくつもあります 。たとえば、 Fragments はサポートされていません。 このように、ライブラリの選定に際しては、「自分たちがGraphQLによって実現したいことは何か」をまず考えた上で、その用途に合ったライブラリを選定する必要があります。 GraphQLのサーバサイド実装が最も活発なのはNode.jsのため、GraphQLを最大限に活用した開発をしたい場合は、サーバサイドにはNode.jsを選ぶのが最も無難だと思います。 Apollo をはじめとして、様々なライブラリが開発されています。 一方、Node.js以外でサーバを書く場合でも、GraphQLをRESTのお手軽な代替として使いたい向きもあるでしょう。そのような場合は、ライブラリがGraphQLの仕様をどの程度実装しているか確認したほうがよいです。 コードファースト vs スキーマファースト GraphQLのライブラリ選定において、頭を悩ませるポイントになるのが「コードファースト」と「スキーマファースト」です。 この記事 によると、Node.jsのGraphQLツールでは、スキーマ定義方法に3つの世代があります。 第一世代コードファースト SDLによるスキーマファースト TypeGraphQL , GraphQL Nexus 等の第二世代コードファースト 第二世代コードファーストは、第一世代コードファーストの問題点であるコードの煩雑さを解消しつつ、 スキーマファーストの問題点 も回避する実装です。Goのエコシステムでは、第二世代コードファーストにあたるようなライブラリは出てきていないため、第一世代コードファースト相当かスキーマファーストかの二択になります。 コードファーストでは、初めにサーバサイドの言語でスキーマの定義とスキーマ解決方法(resolver)の実装を同時に行います。以下は graphql-go/graphql を使った例です。 fields := graphql.Fields{ "hello" : &graphql.Field{ Type: graphql.String, Resolve: func (p graphql.ResolveParams) ( interface {}, error ) { return "world" , nil }, }, } rootQuery := graphql.ObjectConfig{Name: "RootQuery" , Fields: fields} 一方、スキーマファーストは、GraphQLのスキーマ定義言語(SDL)によって先にAPIの形を決め、その後スキーマを満たすようにコードを実装していくアプローチです。↑と同等のスキーマは以下のようになります。 schema { query: Query } type Query { hello: String! } これを満たすresolverは、gqlgenでは以下のように書けます。 func (r *queryResolver) Hello(ctx context.Context) ( string , error ) { return "world" , nil } このように、スキーマ定義をサーバサイド言語で書くかSQLで書くかがコードファーストとスキーマファーストとの違いです。この2つのいずれのアプローチを選ぶかで、GraphQLのツールチェインとの連携の容易さが変わってきます。 Webフロントエンドのツールチェインと簡単に連携できるのは、スキーマファーストの方です。コードファーストの場合は、スキーマを何らかの形で書き出して、フロントエンドのツールでも活用できるようにする工夫が必要でしょう。 また、Goの言語特性を考えても、リフレクションや interface{} を活用するコードファーストより、コンパイル時に型を決めてしまうスキーマファースト + go generateの方が向いていると思います。 Goによるサーバ実装 一休.comでは現在、 gqlgen というスキーマファーストのGraphQLサーバライブラリを使用して開発を進めています。 gqlgenの採用事例は国内でもチラホラ見かけますが、たとえば 技術書典 のサイトなどは gqlgenを採用している ようです。 gqlgenでは、まずGraphQLのスキーマ言語でスキーマを定義します。 schema { query: Query } type Query { accommodation(accommodationID: String!): Accommodation } type Accommodation { accommodationID: String! name: String! } 次に、 go generate ./... で、スキーマからコードを自動生成します。 スキーマを元にGoのインタフェースを自動生成(generated.go): ... type ExecutableSchema interface { Schema() *ast.Schema Complexity(typeName, fieldName string , childComplexity int , args map [ string ] interface {}) ( int , bool ) Query(ctx context.Context, op *ast.OperationDefinition) *Response Mutation(ctx context.Context, op *ast.OperationDefinition) *Response Subscription(ctx context.Context, op *ast.OperationDefinition) func () *Response } ... レスポンスに使用する型の自動生成(models_gen.go): // Code generated by github.com/99designs/gqlgen, DO NOT EDIT. package graphql type Accommodation struct { AccommodationID string `json:"accommodationID"` Name string `json:"name"` } クエリを解決するResolverのひな形の自動生成: package graphql //go:generate go run github.com/99designs/gqlgen type Resolver struct {} func NewResolver() *Resolver { return &Resolver{} } func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } type queryResolver struct { *Resolver } func (r *queryResolver) Accommodation(ctx context.Context, accommodationID string ) (*Accommodation, error ) { return nil , nil } あとは、resolverに肉付けをするだけで、GraphQLサーバが実装できます。 func (r *queryResolver) Accommodation(ctx context.Context, accommodationID string ) (*Accommodation, error ) { x, err := r.accommodationsRepository.Find(ctx, r.dao.Read(), accommodationID) if err != nil { return nil , err } return &Accommodation{ AccommodationID: x.AccommodationID.String(), Name: x.Name, }, nil } このとき、GraphQLはあくまでPresentationレイヤーである、という点を意識し、resolverはドメインオブジェクトをレスポンスにマッピングする程度の仕事しかしないようにしておくのが重要だと思っています。 TypeScriptによるクライアント実装 最後に、TypeScriptによるクライアント実装を行います。ここは実際の開発では、サーバサイドの実装と平行することが多いでしょう。 GraphQLはクエリ文字列の入力を受け取り、JSONを返すので、fetchやXHRを使用した実装も可能です。しかし、GraphQLの特徴である型定義を最大限に活かすにはクライアント側にも型がほしいところ。 本節では、 graphql-code-generator を使用して、GraphQLクライアントライブラリ graphql-request を使ったAPIクライアントを生成します。 まず、依存ライブラリを一式入れます。 yarn add graphql-request yarn add -D graphql @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-operations 次に、graphql-code-generatorで使う設定ファイル(codegen.yml)を用意します。 overwrite: true schema: "./api/graphql/schema.graphql" documents: - "./api/graphql/queries/*.graphql" generates: web/src/generated/graphql.ts: plugins: - "typescript" - "typescript-operations" - "typescript-graphql-request" ここでは、スキーマの定義ファイルを /api/graphql/schema.graphql に置き、スキーマに対する操作を記述したドキュメントファイルを /api/graphql/queries/*.graphql に置いています。 ドキュメントは以下のように、実際のアプリケーションで使う操作を定義します。 query accommodation($id: String!) { accommodation(accommodationID: $id) { accommodationID name } } この状態で yarn run graphql-codegen --config codegen.yml を実行すると、APIクライアントが自動生成されます。 ... export function getSdk ( client: GraphQLClient ) { return { accommodation ( variables: AccommodationQueryVariables ) : Promise < AccommodationQuery > { return client.request < AccommodationQuery >( print ( AccommodationDocument ), variables ); } } ; } あとはAPIクライアントを使うだけ。クエリの引数の型が間違っていたりするとコンパイルエラーになりますし、取得したレスポンスにも型がついています。 import { GraphQLClient } from "graphql-request" ; import { getSdk } from "./generated/graphql" ; async function main () { const client = new GraphQLClient ( "http://localhost:8080/graphql" ) const sdk = getSdk ( client ) const { accommodation } = await sdk.accommodation ( { id: "00001290" , } ) console.log ( accommodation.accommodationID ) console.log ( accommodation.name ) } main () おわりに GraphQLを使うことで、GoとTypeScriptでスキーマを共有しながら開発を行う方法を紹介しました。これらの技術は、一休.comでもこれから本番投入、というフェーズなので、まだまだ実運用を考える上では考慮すべきポイントが残っています(たとえば、GraphQLサーバの監視はどうするか)。 本記事で紹介した知見にアップデートがあれば、その都度ブログで記事にしていきたいと思います。 参考文献 The Problems of "Schema-First" GraphQL Server Development GraphQL Code-First and SDL-First, the Current Landscape in Mid-2019
アバター