一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

あなたのプロダクトに Apollo Client は必要ないかもしれない

この記事は一休 × 出前館 Frontend Meetup でお話した内容をブログにまとめたものです。

user-first.ikyu.co.jp

speakerdeck.com

GraphQL クライアントと聞いて一番に思い浮かぶライブラリは何でしょうか?

多くの方にとっては Apollo Client ではないかと思います。npm trends を見ても Apollo Client のダウンロード数は urql や relay などほかのクライアントと比べ圧倒的です。

実際、一休でも 一休.com や YADOLINK で Apollo を利用しています。

サービス GraphQL クライアント
一休.com Apollo Clinet
YADOLINK Apollo Client
レストラン座席管理画面 なし (axios)
(新)EC① urql
(新)EC② urql
(新)予約管理画面 Relay

しかし、Apollo Client は 「一番有名だから」という理由で使っていいほど無難なライブラリではありません。どちらかといえば、使い所を選ぶ癖のあるライブラリだと私は考えています。

この記事は一休での採用事例を交えながら GraphQL クライアントの選び方についてお話します。もしかすると、Apollo Client はあなたのプロダクトに合っていないかもしれません。

Apollo Client は複雑

以下の図は主要なクライアントライブラリをバンドルサイズが小さい順に並べたものです。これは正確な指標ではありませんが、バンドルサイズの大きさと、機能の豊富さ・複雑さは比例していると考えると Apollo や Relay が比較的複雑なライブラリだということがわかります。

name minified + gzipped
cross-fetch 2.8kB
graphql-request 7.6kB
urql 8.5kB
@apollo/client 40kB
react-relay 55kB

バンドルサイズを小さくするために、別のクライアントを使えと言ってるわけではないことに注意してください。バンドルサイズも重要ですが、"必要最低限の機能を持っているライブラリを使う" ことが大切です。

さて、GraphQL クライアントの仕事とは何でしょうか。突き詰めると HTTP リクエストを発行してAPIサーバーと通信することです。実は専用のクライアントを使わずとも fetch で GraphQL サーバーと通信ができます。

では cross-fetch のようなシンプルなクライアントと比べ、なぜ Apollo Client はこんなにもバンドルサイズが大きいのでしょうか? 通信以外に Apollo が提供している機能とは何でしょうか。

その答えは公式ドキュメントに書いてあります。

Apollo Client is a state management library that simplifies managing remote and local data with GraphQL.

― Apollo Clientは、GraphQLを使用してリモートデータとローカルデータを簡単に管理できる状態管理ライブラリです。

Apollo Client は "状態管理" ライブラリなのです。

Apollo Client を導入する際は、まず「このアプリケーションに状態管理ライブラリは必要か?」という問いに答えなければいけません。答えがNoであれば Apollo Client は必要ありません。

かつてSPAと状態管理ライブラリはセットでした。どのプロダクトのコードを覗いても必ず状態管理ライブラリが入っていました。しかしグローバルな状態のデメリットが認知された現代では、状態管理ライブラリはアプリケーションにとっても必須のパーツではありません。

Apollo Client が向いているケース

Apollo Client が向いているアプリケーションとはどんなものでしょうか?

Apollo Client は "Mutation が頻繁に発生し、かつ Mutation 後に refetch できない" 性質を持つアプリケーションで真価を発揮します。例えば、Twitter や Instagram、我々が運営しているものだとYADOLINKのようなSNSに向いているでしょう。キーワードは『無限スクロール』です。

YADOLINK

無限スクロールにより複数ページのデータ取得した後、特定のアイテムに Mutation を実行するときを考えてみます。例えば、投稿に「いいね」するという操作です。「いいね」が完了すると、投稿のハートアイコンに色が付き、いいねがカウントアップします。

Apollo Client は Mutation のレスポンスを元に1ラウンドトリップで特定の投稿の値を書き換えます。これは Apollo Client の特徴である、"正規化されたキャッシュ" のおかげです。

mutation {
  likePost(postId: Int!) {
    postId
    likeCount
    isLiked
  }
}

反応速度を少し犠牲にすれば、 Mutation 後にページを丸ごと再取得することで同じことが実現できます。実際、urql の Document Cache では Mutation 後に関連するクエリを再取得することでUIを更新します。

しかし無限スクロールによるページネーションを実装している場合は、現在の状態を復元するために複数ページ分のリクエストを発行する必要があるため、すべてのデータを再取得する戦略が現実的ではありません。

逆に言うと、そもそも Mutation がほとんど発生しないアプリケーションや、Mutation 後にデータの再取得によってUIの更新をすることが許されるケースでは Apollo を使う必要はありません。

一休.com に Apollo Client は必要ないかもしれない

さて、では Apollo Client を採用しているもう一つのアプリケーション、我々の看板サービスである「一休.com」はどうでしょうか?

実は一休.comは Query がメインで Mutation がほとんど使われていません。

ECサイトという性質上、一休上で発行されるリクエストのほとんどは "検索クエリ" です。最も重要な操作である "予約" はもちろん Mutation ですが、予約処理後に別ページへ遷移するので Mutation 後にローカルの状態を更新する必要はありません。他にも、クーポンの獲得・お気に入りの追加といった Mutation がありますが、これらもデータの再取得をすれば十分です。

  • 検索 ... Query
  • 予約 ... Mutation
  • クーポンの獲得 ... Mutation
  • お気に入りの追加 ... Mutation

大は小を兼ねる、という一面もあり Apollo Client は一休.comで必要なユースケース "も" カバーしているため、普段の開発では Apollo を使うデメリット感じることはほとんどありません。

しかしエッジケースにおいて、過分なライブラリを使っているせいでトラブルに巻き込まれることがあります。たとえば、一休.com ではIEからのアクセスに対して、Apollo の Store を fork した自前のキャッシュ機構を使うような実装になっていました。これは Apollo Client のキャッシュの正規化がIE上の特定のデータで非常に時間がかかってしまうためです。ひと月以上の時間をかけて Apollo Client のコードを読み、workaround を実装しました。

また、Apollo のプラグインの対応状況が芳しくないせいで、Nuxt3 へのマイグレーションが遅れてしまっています。

もっと軽量なライブラリを使っていれば、こういったトラブルにも巻き込まれいなかったでしょう。自分たちが必要な "一番ミニマムな実装" を採用することが重要だと学びました。

では何を使えばいいの?

では、Apollo が必要ないというケースではどのクライアントを使えばいいでしょうか?

urql や graphql-request がおすすめです。

Apollo が正規化されたキャッシュを持つのに対して、urql は Document Cache というシンプルなキャッシュ機構を採用している点が特徴です。urql は Mutation の実行後に Mutation の戻り値と同じ __typename を取得している Query をすべて再取得します。少し乱暴なようにも感じますが、この方法で十分というアプリケーションも多くあるはずです。

また、Next.js を使っているなら swr + graphql-request という組み合わせも良いでしょう。graphql-request はもっともシンプルな GrapQL クライアントで、状態管理機能を持ちません。クライアント固有の状態がなく、APIレスポンスのキャッシュとして状態を扱うだけであればこの組み合わせがマッチします。swr は Vercel が作っているだけあって、Next と組み合わせてSSRするのも簡単なので要件によってはこちらを検討してみてください。

複雑なアプリケーションには Apollo を使えばいい?

ここまで、「シンプルなアプリケーションにはシンプルな GraphQL クライアントを使おう」というお話をしてきました。では、複雑なアプリケーションでは Apollo Clinet を使うのが正解なのでしょうか?

実はそうではありません。複雑なアプリケーションの中には Apollo と相性が悪いものも存在します。それはサーバーのAPIキャッシュとは別に、リッチな状態を持つアプリケーションです。例えば、われわれが運営しているアプリケーションだと、レストランの席管理画面などがこれに該当します。

座席管理画面

これは日付ごとに何席を一休レストランに提供するかを管理する "在庫カレンダー" と呼ばれる画面です。カレンダー内で日付を選んで、席毎にその日の提供座席数を決定します。曜日一括操作などもあり、サーバーとすぐに同期しない状態操作が多く存在します。

Apollo にはサーバーと同期する状態の他にローカル固有のデータを操作する機能も提供しています。これは Reduxや Vuex が提供する状態管理と同等のものです。一休.com では一部のこの Local State Management の仕組みを利用していますが...正直なところ Redux, Recoil などの専門の状態管理ライブラリと比べてインターフェースがこなれておらず、あまり使いやすいとは言えません。

Apollo で "ローカルの状態管理もできる" ことは確かですが、複雑な状態管理には素直に状態管理ライブラリを入れることをおすすめします。この場合は GraphQL クライアントで状態管理する必要はなくなるので、Recoil + graphql-request などの組み合わせ検討すると良いでしょう。

上記の座席管理画面では GraphQL クライアントは使わずに axios でAPIサーバーと通信し、Vuex で状態管理を行っています。axios で GraphQL の型の恩恵をあまり受けられていないので...もし作り直すとしたら axios ではなく、 graphql-request を使い、GraphQL Code Generator でコードと型の自動生成を行いたいです。

もう一つのリッチなクライアント、Relay の話

最後に、Apollo Client と並んで高機能な Relay について少し触れます。Relay も正規化されたキャッシュを持つ GraphQL クライアントです。ユースケースとしては Apollo Client とほぼ同じだと考えていいでしょう。

では Apollo ではなく、 Relay を使うべきなのはどういったときでしょうか? 以下のケースでは Relay を検討してもいいでしょう。

  1. コードの自動生成 + Fragment Colocation したい
  2. ページネーションされた要素に対して頻繁に要素の追加・削除を行う
  3. React の Experimental な機能をいち早く試したい

Relay には Relay Compiler というコンパイラが付属しています。コンパイラの仕事はコード内の graphql タグから型情報を含むファイルを自動生成することです。GraphQL Code Generator 相当の働きをしているというとわかりやすいかもしれません。また Relay でアプリケーションを作ると自然と Fragment と Component が一致する Fragment Colocation スタイルになります。

Relay には便利なディレクティブがいくつか追加されていますが、私が注目しているのは @appendNode @prependNode ディレクティブです。これは connection に対する要素の追加を宣言的に行えるディレクティブです。Apollo Client で要素を追加する際は手続き的にはキャッシュを操作する必要がありますが、Relay ではそれらの操作はライブラリ内に隠蔽されます。Facebook で利用されているだけあってSNSを作るのに便利な機能がほかにもあります。

Relay は Meta 製のライブラリだけあって React の実験的な機能の取り込みが早いです。Suspense についても Suspense が Experimental の頃から対応していました。今後も先進的な機能が先取りされる可能性があります。ドキュメントが足りないのが懸念点ですが、トータルでは筋がよく、React エコシステムの未来を反映しているライブラリだと考えています。

一休ではとある新規サービスの予約管理画面を Relay を使って開発中です。 社内向け管理画面のようなシンプルな管理画面にはシンプルな状態管理機構を持つ graphql-rquest / urql が向いていると思います。ただ、この予約画面はレストランの方も使うSaaSのような位置づけの管理画面なのでUXを重視して Relay を採用しています。

YADOLINKはSNSですし、GraphQL Code Generator でのコード生成、Fragment Colocation も採用しているため、現在リリース中のアプリケーションの中ではYADOLINKが一番 Relay と相性が良さそうです。YADOLINKのアプリ版を React Native で作ることを検討しているので、その際は Relay が第一候補です。

結局、何を使えばいいのか

さて、まとめです。

Apollo Client が圧倒的な知名度を持っているので Apollo Client を批判するような内容になってしまいましたがそうではありません。 Apollo Client が向いているアプリケーションもあれば、もっとシンプルなクライアントで十分な場合もあります。

最後に簡単なフローチャートを掲載します。

ECサイトや管理画面には Apollo は too much かもしれません。 Apollo Client や Relay、urql + GraphCache のようなリッチなクライアントはSNSのようなユーザーの Mutation が頻繁に発生するサイトに向いています。

GraphQL クライアントの選び方

GraphQL + Go による画像投稿機能の実装談・・・Exif 情報の削除、AWS S3 での画像管理、ユーザー体験の模索など

こんにちは。宿泊プロダクト開発部 UI開発チーム エンジニアの香西です。

半年ほど前に、一休.comとヤフートラベルで、クチコミ画像の投稿機能をリリースしました。
ユーザーに画像をアップロードしてもらう機能は、これまで一休.comとヤフートラベルには無かったため、試行錯誤しながらの開発となりました。
今回はその時の開発についてお話したいと思います。

背景

以前から、一休.comとヤフートラベルにはクチコミを投稿する機能は存在していました。
実際に宿泊したユーザーのみクチコミを投稿することができるため信憑性の高いクチコミではあるものの、ユーザーが投稿できるのは文字情報のみでした。近年、あらゆるサービスにおいてクチコミの重要性が高まってきていることもあり、視覚情報を増やしてクチコミの質をあげるべく、画像を投稿できるようにしよう!ということで、クチコミ画像の投稿機能の開発がはじまりました。

全体像

画像の保管場所には、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. クチコミ入力画面(ここで投稿する画像を選択する)
  2. クチコミ入力確認画面(選択した画像を確認する)
  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.Encodergba を渡していました。

// 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 バケットに画像をアップロード

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

一休 × 出前館 で Frontend Meetup を開催します!

一休と、「出前館」を運営する株式会社出前館でオンライン・イベントを開催します。

今回はフロントエンド開発をテーマとして両社のエンジニアにお話いただきながら、様々な学びを得ることを目的としたイベントです。

イベント後のアーカイブ動画を公開しませんのでご興味がある方はぜひご参加ください。

  • 日時:6/30(木) 18:00~20:00
  • 費用:無料
  • 場所:オンライン(Zoom)

お申し込みは以下のリンクからお願いします。

ikyu.connpass.com

発表テーマ

  1. プロダクトのタイプ別 GraphQL クライアントの選び方(一休 / 管理画面 / 新規サービス)
  2. 一休/Yahooトラベル、マルチブランドにまたがるデザインシステム
  3. 20年続いているサービスの注文画面をGraphQLを活用して作り直した話
  4. ライフインフラとなるために進めているアクセシビリティ向上への取り組み

多くの方のご参加をお待ちしております!

新サービス『YADOLINK』をリリースしました

新規事業本部、エンジニアの所澤です。

今回は4/19にリリースした一休の新サービス『YADOLINK(ヤドリンク)』についてお話します。

yadolink.com

YADOLINKとは?

YADOLINKトップ
TOPページ

YADOLINKはホテル・旅館に特化した写真投稿SNSです。「宿好きが集まり、心置きなく宿愛を語れ、それが誰かの役に立つ幸せな場所」となることを目指しています。

サービス立ち上げの経緯と開発体制

YADOLINKは一休.com のマーケターの提案からボトムアップで事業化が決まったサービです。 開発チームは社内公募で集められ、ディレクター、デザイナー、エンジニア2名の合計4人で約半年ほどの開発期間を経てリリースされました。

技術選定

技術選定にあたっては、

  1. 既存サービスの技術スタックに縛られない "もっとも良い選択肢" を選ぶこと
  2. 開発スピードを重視してなるべく素朴な作りにすること

を心がけました。

以下が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

Yahoo! トラベルと一休.com のシステム統合プロジェクト

今から二ヶ月ほど前、10/1 に Yahoo! トラベル のリニューアルが完了しました。このリニューアルは、一休.com と Yahoo! トラベルの2システムを一つに統合することで実現しました。

f:id:naoya:20211130144427p:plain

ご存知の通り、ヤフーと一休は同じグループに所属する企業です。ざっくりいうと「同じグループで2つの宿泊予約システムを開発し続けるのは効率が悪いよね」という話があり、今回のシステム統合に至っています。

Yahoo! トラベルと一休のシステム統合は、(1) 2017年頃にホテルの空室管理や予約、決済、精算業務などを担うバックエンドのシステム統合を行い、そして (2) 今回 2021年春先から半年ほどをかけて、ユーザーが利用する画面も含めた全面統合を行いました。全面統合は総勢で 50名ほどのディレクター、エンジニア、デザイナーが関わる一休的には大きな規模のプロジェクトになりましたが、目立ったトラブルもなく、先日無事リリースすることができました。

それぞれ数百万人以上のユーザーを抱える二つのサービスの統合・・・となると、聞こえ的にもまあまあ派手な部類かなと思います。その裏側を少しだけですが、お伝えしていこうと思います。

デジタル・トランスフォーメーション (DX) が進んでいる宿泊予約

まず前提として宿泊予約の業務は、おそらく世間での印象よりも DX が進んでいるという背景を先に。

Yahoo! トラベルや一休は、全国のホテルや旅館から空室情報 (「在庫」と呼ばれます) を預かり、それをオンラインで販売するサイトです。オンラインの旅行会社ということで OTA (Online Travel Agency) と総称されています。

ホテルや旅館の方々は日々 OTA の管理画面から自分たちの施設の空室情報や料金を手で入力して・・・と言いたいところですが、それはずいぶんと昔の話です。

2010年代前半頃にはホテルのフロント業務を担うシステムである「PMS (Property Management System) 」、ホテルからみると複数ある OTA に在庫や料金情報を一括登録してくれる「サイトコントローラー」、そして我々 OTA のシステム。これらバリューチェーンが API でリアルタイム連携するデジタル化が完成しています。

f:id:naoya:20211130144511p:plain

OTA は複数あれど、部屋数は有限。各 OTA に登録された在庫を予約やキャンセルが行われるたび同期しないとオーバーブッキングが発生してしまいます。PMS、サイトコントローラー、OTA 間の情報の流れがデジタル化されたことで、在庫はほぼリアルタイムに同期されているのが昨今です。(そしてホテルの PMS は、そこから更にホテルの基幹システムへ繋がっています)

現代の OTA は、サイトコントローラと通信して在庫・料金設定を受け取りそれを販売、宿泊施設と連絡を取って顧客の決済、予約〜宿泊・精算までのプロセスを管理するのが主な仕事というわけです。ホテルから見ると予約業務のフロントエンドである PMS さえ操作していれば、複数の OTA から予約が集まってくる・・・ざっくりいうとそういうシステム連携が実現されています。

この宿泊予約バリューチェーンのデジタル化により、OTA では日々の空室状況をもとに需給に応じてルーム価格を変動させるのが容易になりました。結果、航空券などと同様、旅行業界はダイナミック・プライシングが当たり前の世界になっています。

話は逸れますが、ほぼ同じ構造のデジタルトランスフォーメーションが、ホテル宿泊業界に10年遅れて、飲食店業界でも進み始めています。なぜ宿泊予約で10年先にそれが起こり、遅れていま飲食業界なのか・・・というテーマも面白い話なので、また別途書いてみたいと思います。

2017年に実施した Yahoo! トラベルと一休.com のバックエンドシステム統合

さて、一休が Zホールディングスグループ (当時は Yahoo! Japan グループ) に参画した 2016 年には、Yahoo! トラベルと一休.com は、当然、それぞれ別のシステムとして動いていました。営業を含む組織も二つの会社に分かれて存在していました。

これは経営的な全体最適の視点からいくと、たとえば同じホテルにグループ内の二つの会社から営業にいってしまうし、同じ機能をいつも二回開発する必要があったりと何かと効率が悪いわけです。複数あるサイトコントローラーともそれぞれのシステムから接続して、それぞれが在庫をもらっているという状況です。「会社が一緒のグループになったのだから、システムも一本化して合理化しようじゃないか」と当然考えますよね。結果のシステム統合プロジェクトです。

なおシステムを統合するといっても、サービスまでは統合しません。一休には一休のお客さんがいてヤフーにはヤフーのお客さんがいるし、一休は高級・ラグジュアリー指向でホテルや旅館を厳選していて、一方のヤフーはビジネスやレジャーを得意としています。顧客基盤も、商品もブランドも違うので「システムは一つに。サービスは二つに」というのがシステム統合の目指すべきところとなりました。

ここまではいいとして、ご想像の通り難しいのはここからです。

特に、どちらのシステムを主体としてマージを行っていくのか (あるいはどちらも捨てずに玉虫色のシステム統合を行うのか)。 ここが最大の論点になります。喧喧諤諤の議論を経て結論「一休のシステムを主とし、ヤフーのバックエンドシステムは捨てる。ヤフートラベルのフロントから業務処理を一休のバックエンドに API 通信で依頼する方式で統合」となりました。

f:id:naoya:20211130144629p:plain

2社間のシステム統合は、ここの意志決定が非常に大事・・・というのが私なりの持論なのですがその辺りの考察は最後に回しましょう。

まだお互いグループになったばかりでいろいろ大変ではありましたが、バックエンドのシステム統合を行って営業活動を一本化することで様々な業務密度があがり、そのシナジーで双方大きく業績を伸ばすことができました。ホテル施設からそれぞれのサービスへの在庫のデータフローも一本化されて綺麗になりました。

2021 年、フロントエンドも含めたシステムの全面統合

バックエンドシステムの統合を行ってから数年間は一休 / ヤフーそれぞれの販売面はそれぞれで開発・運用を行ってきました。 たとえば昨年の Go To Travel の対応なども、バックエンドの業務処理開発は一休にてまとめて実施。フロントはそれぞれが持っているので、それぞれのサービスで対応、みたいな形で実施しています。

ここで改めてグループの宿泊予約事業全体をここから更に成長させていくには、高級にフォーカスしている一休よりもより市場規模の大きいセグメントを対象にしている Yahoo! トラベルの成長が重要と考えました。

一方、一休はその黎明期から OTA をやってきたこともあってより良い顧客体験、ユーザーインタフェース作りには自信があります。そこで一休が構築した UI や顧客体験を Yahoo! トラベルにも横展開できるようバックエンドだけでなくフロントエンドも一休とシステム統合、今後は Yahoo! トラベルを一休が、一休.com と一緒に開発し Yahoo! トラベルのユーザー体験を大きくアップグレードしよう・・・という話になったのが直近の全面統合です。

システムを統合するといっても、やはり Yahoo! トラベルと一休.com は別のブランドとしてそれぞれのサイトでサービス提供していくので、Yahoo! トラベルの基礎になっている顧客体験部品・・・たとえばヤフードメイン、ログインアカウント、ロイヤリティプログラム (プレミアム会員特典)、PayPay や T ポイントなどの決済・ポイント手段・・・は維持しながらもシステム統合を行ってユーザーインタフェースや CRM は一休.com の体験に寄せるというのを基本方針としました。

f:id:naoya:20211130144734p:plain

分かりやすさ重視でざっくり説明していますが、簡単なプロジェクトではありません。冒頭でも触れた通りディレクター、エンジニア、デザイナー総勢で 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 と協議しながら一つ一つ片付けていきました。

これはヤフー CTO 藤門さんのツイート。殴り合いだったらしいです。

さて、上記が決まってくるとビジネス要件決めにまつわる制約事項がクリアになるので、ここから体験・機能の取捨選択など業務要件決めです。業務要件については OTA 固有の話が多いので、ここでは割愛。

実装方針を決める

更に、アプリケーションの実装の方針も固める必要があります。

  • UI は実装をコンポーネントレベルで共通化したい。そのためソースコードは、一休のシステムと同一ソース (レポジトリ) で一休 / ヤフーの両サービスを実現する
    • コードベースを fork してしまうと、二重管理が発生する。それは避けたい
    • そのため一休の設定コンテキストで Nuxt、Go、.NET などのアプリケーションをビルドすると一休.com のアプリケーションに / Yahoo! トラベル設定コンテキストでビルドすると Yahoo! トラベルアプリケーションがビルドされるようにする。つまり、同じコードベースでもアプリケーションが一休として動くのか、Yahoo! トラベルとして動くのかは静的に確定させる方式
  • この方式で、同一レポジトリではあるものの、それぞれのサイトのアプリケーションは分離できる。他方のサイトの障害がもう一方のサイトへ波及しないよう、デプロイ先の EKS クラスタはそれぞれのサービスごとに別環境で実行し、エッジサーバーのルーティングでトラフィックをドメインに応じたクラスタへ振り分ける
  • フロントエンドは、一休がもともとコンポーネント指向で開発してる。2サイトの UI は基本的にはスタイルこそ違えど、動きは同一になるので小・中規模のコンポーネントは共有し、Tailwind CSS のテーマ機能などを利用してプレゼンテーションレイヤの上位層の実装でその差異を吸収する
    • 根本的に2サイトで要求される挙動が異なるコンポーネントの場合は、それぞれのサイトごとにコンポーネントを開発する
    • (※ という方針でやってみたが、100% うまくいったとは言えない状況 ・・・ 本プロジェクトで得た知見と反省を活かして、デザイン・システムの整理を始めている)

など、開発を進めるにあたり「ここはどうすれば?」というポイントを数名のメンバーで意志決定していきました。

プロジェクトの早い段階で、新システムをデプロイできる環境を用意する

システム統合の全体設計、機能要件、開発方式の基本的なところが固まってくるとチームごとのロードマップがクリアになるので、ぼちぼち開発に着手できる状態になります。

ここで、一休の場合はいつもやっていることなんですが、新しいシステムのビルドパイプラインを開発開始とほぼ同時に構築してしまいます。開発のメインブランチにマージされたアプリケーションが即座にステージングのクラスタにデプロイされるようにします。

こちらのスライド にもあるのですが、一休では本番環境にあるデータベースを (個人情報など秘匿性の高いデータはマスキングした上で) 開発にレプリカする仕組みがあって、開発を本番環境相当のデータを使っておこなう・・・ということを習慣化しています。これを新 Yahoo! トラベルの開発環境にも適用しました。

f:id:naoya:20211130144843p:plain

これにより、プロジェクト開始直後から (まだ何一つ実装はできていませんが) Yahoo! トラベル版ビルドのアプリケーションの動作を、みんなで一箇所で確認できるようになります。しかも本番相当のデータを利用していますので、プロジェクトの早い段階にラフに作った実装で、実際のサービス提供イメージを動くもので確認することができるわけです。

チーム間同士にまたがるシステムの結合部位の特定が容易になりますし、結合テストも気軽に実行できます。常に動くものをベースに議論できるので、関係者間の認識合わせ / 認識ずれの発見もイージーです。ディレクターが日常的にこの環境で進捗を確認することで、要件の対応漏れも早期に発見することができます。

習慣化してやってきたことですが、この手の大規模プロジェクトでは改めて、とても有用なプラクティスだなと感じました。

ついでに、レガシー改善も一気に進める

ところで、こういうビジネス的な大義名分のある大規模開発のタイミングというのは、レガシー改善を一気に推し進めるチャンスです。

普段だとビジネスを停めない前提でレガシー改善をするのに思い切ったシステムリニューアルに踏み込めないこともあるわけですが、こういう大規模プロジェクトのタイミングは、技術基盤を根底から刷新するですとか、そういうことが相対的に小さな扱いになるしついでにやれるのであれば、プロジェクトゴールのための手段としても合理的に肯定しやすい。

ただし、システム統合をはじめるよ、という段階で一緒に新基盤も投入するのはビッグバンリリースになっていろいろと危ういですね。日頃から少しずつ新基盤の開発と導入を進め、プロダクション環境での安定性を確保しながら虎視眈々と、こういう大きく動ける機会を狙う・・・というのがビジネスを停めないレガシー改善戦術のひとつだと思います。

というわけで、以前から進めていた Nuxt + Go での新開発基盤で、システム統合スコープに含まれる領域を塗り替える作業も同時に進めました。

f:id:naoya:20211130144909p:plain

新システムの成果

駆け足で紹介してきました。さすがに1エントリでは詳細まで書き切ることはできないので、今回はこの辺まで。

そもそも、このシステム統合の結果はどうだったのか気になるところですが

  • 年始から検討、4月頃から開発に着手。当初リリース目標の9月中旬を前倒ししてカットオーバー
  • AB テストの結果、従来サイトのパフォーマンスを、新サイトが大幅に上回ることを確認。100% リリースに至る

となりました。

プロジェクト責任者としては、AB テストの結果をみてほっと胸を撫で下ろしたところでした。

システムの統合によって UI を含む販売面の体験が一休のそれに近づいたことで使いやすくなった・・・というところもありますが、今後の発展性を考えると、Yahoo! トラベルと一休.com のデータウェアハウスが一つ二統合されたということも大きいと思っています。2つの OTA のパフォーマンスを、マスターデータ管理ができた状態で分析ができますし、最近では一休のお家芸にもなったマーケティングオートメーション・・・ 機械学習を利用した、顧客行動に最適化した CRM を Yahoo! トラベルにも展開することができるようになりました。今後が楽しみです。

f:id:naoya:20211130144950p:plain

考察

現在はリリース後の改善を進めているところです。 一休.com と実装を共通化したため、一休.com で検索機能の改善を行うと、少ない手数で続けて Yahoo! トラベルの検索も改善される・・・という開発リソースの効率化が力を発揮し始めています。

以下、システム統合プロジェクトの考察です。

マネジメント視点でみた場合、2社間のシステムを統合するにあたってはトップダウンアプローチで「どちらのシステムを主体にするのか」を、大枠のアーキテクチャ + 業務処理フローも含めて意志決定を行うことが肝要だと感じました。

2社間のシステムの統合は計算機的な意味での「システム」を統合するのだけでなく、組織や業務フローまで含めた統合になります。そこにはそれぞれの会社の社員が関わってきますし、合理的な判断で物事を進めようとすれば、当然いろいろな痛みも伴うわけです。統合前からやっていたプロジェクトが中止になることもあるし、組織の責任者やレポートラインも大きく変わります。二つの組織で異なっていた働き方や文化もある程度、どちらか一方に寄せていく必要があります。

そういう痛みを発生させる責任を引き受けて、合理的なジャッジをしていくというのはトップマネジメントの仕事ではないか・・・と思います。

ことシステムに関して言えば、合理的判断を保留し忖度によってシステムアーキテクチャを歪めてしまうと、諸々が複雑になりその後の開発や業務に大きなダメージを与えてしまう・・・というのはこの記事を読まれている方であれば、想像に難しくはないと思います。

こういう意志決定をボトムアップで正しく実施するのは難しいというか、正直良いアプローチが思いつきませんでした。

とはいえ、トップダウンで、より詳細な要件や実装まで全てを決めていくのは不可能です。従って、ここまで見てきたとおりどちらのシステムを残して、どちらを捨てるのか。連携にまつわる重要箇所のインタフェースをどうするのか。プライバシーやセキュリティ、法務イシューをどのような手段で、クリーンに解決するのか。アプリケーション開発で各チームが共通して守るべき実装方針は何か。こういった全体方針をある程度トップダウンで決めて個々のチームの依存関係をほぐし、独立して動ける状態を作るまでが、マネジメントの最初の仕事だと思います。そこから先は、各チームがボトムアップで自分たちの領域を自分たちのやり方で進めていくのが良いでしょう。

特に一休の場合は内製の開発チームで、もう何年も一緒にやっている組織なのでチームや個々人の強みもお互いによく理解していますし、方針さえ決まればあとはボトムアップで品質、工数感やスケジュールを外さずにゴールに到達できるだろうという予感はありました。

任せるとはいっても規模の大きなプロジェクトになるので、いくつものチームが同時並行で開発を行います。互いの進捗を常に結合し状況を見える化しておくのは、プロジェクト進行の大事なプラクティスです。ビルドパイプラインとステージング環境を早期に構築したのはボトムアップで動く複数のチームの合流ポイントを早い段階で用意し物事がうまく進んでいるのか進んでいないのかを明らかにしたかったため・・・というわけでした。

おまけ

プロジェクトが成功した・・・ということで会社からプロジェクトメンバーの自宅に差し入れが届きました。 みんなで集まってパーティとはいかない時期ですが、こういう労いもまた粋だなあと思いました。

f:id:naoya:20211130145019p:plain

一休では常時、Yahoo! トラベル、一休.com を含む一休のサービスを一緒に開発してくれる社員を募集中です。

hrmos.co hrmos.co

宿泊サイトのPCリストを ASP.NET Web Forms から Go + Nuxt でリニューアルしました

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

宿泊サイトのPCリストページを ASP.NET Web Forms から Go + Nuxt でリニューアルしたお話をさせていただきます。

詳しいお話をする前に:PCリストページってどこ?

こちらになります

https://www.ikyu.com/tokyo/140000/

f:id:akasakas:20210722164619p:plain

宿泊PCサイト(検索導線)の問題点

ASP.NET Web Forms のレガシーアーキテクチャによる開発生産性低下

一休.comのほとんどはASP.NET Web Formsベースの独自フレームワークで構築されています。 大規模リプレイスをしたのが2009年頃なので、宿泊サービスを10年以上支えてきてくれました。 それ故、継続して開発をしづらくなってきたというのがあります。

似たような画面があり、修正コストが高い

PCリストには条件検索画面とキーワード検索画面の2つがありました。 見た目は似ているが、別ページなので、機能差分が発生していました。

f:id:akasakas:20210722164728p:plain

Go + Nuxtでリプレイス

Goの選定理由

Goが比較対象(.NET Core, Python)と比べて、総合的なバランスが最も良いと感じたからです。

  • Go
    • ○:パフォーマンス、クロスプラットフォーム、開発環境の成熟度など、バランスが良い
    • ×: Viewを書く言語としてはイマイチ(html/templateとRazorを比べると、圧倒的にRazorの方が良い)
  • .NET Core
    • ○:宿泊メンバーの既存スキルを活かしやすい
    • ×:採用面が弱い
  • Python
    • ○:レストランで採用している
    • ×:パフォーマンスは.NET CoreやGoに見劣りする

Nuxtの選定理由

  • レストランサイトで採用している
  • 公式の日本語ドキュメントが整備されている
  • 技術的ジャンプが少なくモダン開発を始められそう

画面統合でシンプルに

  • 余計な修正コストがかからないように
  • 機能差分が発生しないように

するために、画面統合することでシンプルにしました。

f:id:akasakas:20210722164847p:plain

Before/After

Before

トップページからの検索が2つ(キーワード・条件検索)分かれていたため、修正コストが高くなっていました。

f:id:akasakas:20210722164950p:plain

After

統合したことにより、導線もシンプルになりました。

f:id:akasakas:20210722165014p:plain

現在の移行状況

現時点でのGo+Nuxtにリプレイス済みのページは以下になります。

  • SD トップページ
  • PC リストページ

f:id:akasakas:20210722171327p:plain

目標

中期的には以下を目標としています

  • Go+Nuxtで主要導線をリプレイスすること
  • 主要画面の統廃合

f:id:akasakas:20210722171345p:plain

まとめ

宿泊サイトを ASP.NET Web Forms から Go + Nuxt に移行中です。 一緒にGo + Nuxt で一休宿泊サイトを作り直していきましょう。

hrmos.co

hrmos.co

ヤフーのInternal Hack Dayに参加しました

こんにちは。
宿泊事業本部のいがにんこと山口です。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をハッカソン限定で無制限に使えるようにしていただきました。
今回僕たちは使いませんでしたが次機会があれば使ってみたいですね。

しかも今年はランチの提供があり自宅まで特性お弁当が届くというサービスがありより一層イベントっぽさが出るものとなっていました。(いい感じの弁当だったので写真撮っておけばよかったです)

みんなの避難経路

僕たちは「みんなの避難経路」というものを開発しました。

f:id:igatea:20210818112934p:plain

これはどういうものかというと、災害時の避難所への最適な避難経路が分かる、というものです。
災害時に避難所に行くためにはいくつかの課題があります。

  • どこに避難すればいいのか
  • 水没しやすいところや封鎖されやすいところ、移動が大変なところなど迂回しなければいけない場所はどこか
  • 災害で通れなくなっているところがあるかも

こんな問題を解決したいと思って開発を行いました。

以下の国土交通省が提供している避難所データを元に災害種別に応じて近所の避難所をGoogleMap APIを使用して表示しています。
指定緊急避難場所CSVデータ 市町村別公開日・最終更新日・ダウンロード一覧|国土地理院
避難所までの経路はGoogleMap APIの経路探索APIで引いています。
そこから定期的に現在位置が記録されていき、それが別の同じ避難経路を指定したユーザーに見えるようになっています。
これによってどこが人の通った実績があり確実に通ることができる場所かを把握することができるようになります。
通常経路を他のユーザーが皆避けているようであれば、他のユーザーと同じように経路を変更するという選択が取れるようになります。
課題としては実際の経路自体はそこを考慮して経路変更を行えていないのでハッカソン内では間に合いませんでしたがここも対応したいところですね。

結び

年に1回のイベントで数少ないグループ内他社とのイベントでとても楽しかったです。
また何かやりたいですね。

ヤフーさんの方でも記事を上げているのでそちらもどうぞ https://techblog.yahoo.co.jp/entry/2021081830172653/