一休.com Developers Blog

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

本社を東京ガーデンテラス紀尾井町へ移転し、オフィスファシリティ・コーポレートIT を刷新した話

はじめに

社内情報システム部 / CISO室 所属 コーポレートエンジニアの 大多和(id:rotom)です。

2022年12月5日、一休は本社オフィスを港区赤坂から千代田区紀尾井町の東京ガーデンテラス紀尾井町 紀尾井町タワーへ移転しました。 ヤフーや PayPay、ZOZO をはじめ、Zホールディングス各社やデジタル庁も入居するビルです。

新オフィスのコンセプト、概要についてはプレスリリースをご覧ください。

prtimes.jp

当社は2022年4月に働き方を刷新し、オフィスワークとリモートワークのハイブリッド制を導入しました。従業員がより高いパフォーマンスを発揮できるよう、オフィスワークの日を職種ごとに週1・3・5回に分けています。移転後の座席は、出社回数に応じて、フリーアドレス・グループアドレス・固定席の「ハイブリッド」とし、オフィス勤務時には従業員同士が円滑にコミュニケーションを取れるよう設計しました。

一休は2022年4月よりリモートワークとのハイブリッド勤務となるように働き方が大きく刷新されました。

本社移転に伴い、ただ現状のインフラのまま引っ越すだけではなく、上記のような時勢に合わせた新しいオフィスファシリティの構築、PBX や FAX のほかオンプレミスサーバーを撤廃した、クラウドネイティブでモダンなコーポレートIT への刷新も行いました。 今回はその取り組みの一部をご紹介します。

この記事は corp-engr 情シス Slack(コーポレートエンジニア x 情シス) #1 Advent Calendar 2022 15日目の記事でもあります。他の素晴らしい記事は以下のリンクからご覧ください。

adventar.org

オフィスファシリティ

まずはオフィス環境についてご紹介します。一休の前オフィスは5階・6階・7階の3フロアに分かれていましたが、今回の移転で10階のワンフロアに統合されました。 これまでの3フロアの床面積を合計しても、なお新オフィスのワンフロアのほうが広いという一休史上最大規模のオフィスです。

執務室

執務室はエンジニアに限らず、営業、バックオフィスなどを含む全ての座席にエルゴヒューマンのチェアが設置されています。 後述しますが、今回の移転のタイミングで各席に置かれていた固定電話を廃止し、机上への配線は OA タップのみという非常にすっきりしたデスクになりました。

エンジニアエリア

前述の通り、職種に応じてオフィスへの出社日が決まっており、エンジニア職は週1 オフィスワークの職種です。 そのため、各事業部のソフトウェアエンジニアやデータサイエンティストなどの席は、従来の固定席を廃止しフリーアドレス化しました。

エンジニア席には LG 製の 4K ディスプレイが標準で配備されています。USB PD(Power Delivery)給電に対応しているため、出社時も充電アダプタを持ち歩く必要がなく、USB-C ケーブルを接続すればモニタへの出力と充電を同時に行うことができます。

会議室 / 個室ブース / 集中スペース

コロナ前は会議室で対面で行っていたミーティングは、コロナ禍に Zoom や Google Meet を利用したオンラインミーティングへと移行しました。 そのため、新オフィスでは大人数向けの会議室より、1on1 などで利用できる小規模の会議室や、遮音性の高いオンラインミーティング用の個室ブースや、囲いで覆われた集中ブースで多く設置されています。

会議室の壁はホワイトボードになっているため、ブレインストーミングなどに最適です。

オフィスは非常に広く、座席も大量にあるのですが、エンジニアにはここが一番人気のようです。

また、窓際には予約不要で利用できるフリースペースは多く設置されており、ちょっとした打ち合わせにサクッと利用できます。

晴れた日のカウンター席は景観がよく、素晴らしい夕日が差し込むおすすめスポットです。

ラウンジ

社内で「ラウンジ」と呼ばれているのが休憩や食事、社内イベントなどに利用されるリフレッシュスペースです。写真の「イソテーブル」と呼ばれる什器はヤフーで使われていたものを譲り受けました。新オフィスのコンセプト「サステナビリティ」を体現しており、早速ティーブレイクやちょっとした打合せで活用されています。

前オフィスでは、コロナ前はラウンジのあったフロアに全就業員が集まり、経営陣からの実績報告やケータリングの料理やお酒を楽しむパーティーが開催されていました。 また、技術コミュニティのIT勉強会や、外部の経営者をお招きした MANABIBA と呼ばれる経営対談も活発に行われていました。

これらのイベントはコロナ禍では YouTube Live や Zoom ウェビナーを活用した配信方式への切り替わっていました。 オフィスワークとリモートワークのハイブリッド制となった今、このラウンジも会場・配信の双方に最適化されたAV システムに刷新しました。

社員食堂 / カフェテリア / コンビニ / コワーキングスペース

東京ガーデンテラス紀尾井町 紀尾井タワーはヤフーをはじめ、Zホールディングス各社が多く入居するオフィスビルです。 一休も同じビルへ同居することでヤフーのオフィス設備の一部を利用させていただいています。

1つ上の11階にはBASE11と呼ばれる社員食堂・カフェテリアがあります。紀尾井町は前オフィスの赤坂と比べ飲食店街からは少し離れた場所に位置するため、外食をするには移動しなければなりません。オフィス内に社員食堂があることで、安くて美味しいバランスの採れた食事を日替わりで食べることができ大変便利です。

about.yahoo.co.jp

同フロアにはZホールディングスのアスクル・出前館が運営するコンビニ Yahoo!マート の店舗もあり、オフィスから出なくても食事や日用品を購入することもできます。

about.yahoo.co.jp

また、コロナ前は一般の方へも公開していたコワーキングスペースの LODGE は現在はZホールディングス従業員向けに公開されています。

lodge.yahoo.co.jp

一休のオフィスはワンフロアですが、このようにヤフーのオフィス設備も利用させていただくことで快適に業務を行える環境が整っています。

コーポレートIT

ここからはコーポレートITの話をします。移転前のオフィスにはサーバールームがあり、オンプレミスで稼働しているサーバーが複数ありました。 これらのオンプレミスサーバーは以前より AWS への移行や SaaS へのリプレイスなどクラウド移行を継続して行っており、移転前の時点ではプロキシサーバーと PBX を残すのみとなっていました。

今回の移転のタイミングでプロキシサーバーをクラウド移行を完了し、固定電話も廃止することで PBX を撤廃し、新オフィスからはサーバールームが無くなりました。

電話 / Dialpad Enterprise

前オフィスではサーバールームに PBX が設置されており、事業部や本部ごとに電話番号を持ち、各席には固定電話機が設置されていました。 コロナ感染拡大に伴い、全社的に在宅勤務の体制が取られた際に、コールセンターや一部部署で先行して利用していたピュアクラウド型のビジネスフォンシステム Dialpad を全社導入しました。

www.dialpad.co.jp

固定電話の番号を Dialpad で発番した 050 の番号に即時転送をかけることで、オフィスに出社することなく、在宅でも受電対応が行える環境を構築しました。 このときの取り組みは大規模な導入になったため、Dialpad Japan の導入事例としてもご紹介いただいております。

japan.blog.dialpad.com

Dialpad はフルクラウドであることから非常に管理がしやすい一方で、IP 電話であるため電話番号が 050 番号でした。 当時は 050 番号から 0120 を始めとするフリーダイヤルへ発信できないことや、03 番号への着信の転送への折返しの際も先方には 050 番号が表示されてしまうため出ていただけないことがある、といった課題感もありました。 この課題を解決するためには自社でゲートウェイを設置する必要があり、フルクラウドである恩恵を得られにくいものでした。

www.softbank.jp

2021年9月に Dialpad Air 0AB-J という新オプションが登場し、ゲートウェイなどの機器を設置不要で 03 番号(0-ABJ)が利用可能になったことから、今回の移転のタイミングでオンプレ PBX を脱却し、Dialpad へ一本化することを決めました。 この際、利用部門が大きく拡大することからグループ数が無制限となる最上位プランであり、Azure AD による SAML/SSO や SCIM にも対応した Enterprise にアップグレードを行いました。

現在は元から固定電話のないエンジニアをのぞく、全ての部門で Dialpad を利用しています。

FAX / FAX PLUS Enterprise

PBX を同じく、オフィスに設置されているオンプレミス機器として FAX の存在がありました。 事前のヒアリングから事業部、管理本部ともに FAX はすでに業務でほぼ利用されていないことは分かっていましたが、官公庁やビルの防災センター、クレジットカード会社など、一部 FAX でしか受付を行えない組織との取引手段として、FAX は今後も残す必要がありました。

従来の FAX はアナログ回線を引き込み複合機から送信するものでしたが、これもオフィスからではないと送受信が行えないため現在の働き方には合っておらず、フルクラウド型へ移行しました。

選定したのは FAX PLUS というスイスのオンライン FAX サービスで、マルチプラットフォームかつ Web アプリから利用可能なものです。

www.fax.plus

海外 SaaS ではあるのですが、日本の FAX 番号を取得することが可能です。2022年12月時点では 050 番号のみ利用可能で、03 番号などの 0-ABJ は利用できません。 050 番号の取得には日本の法律により審査が必要で、登記簿謄本や代表者(または委任を受けた担当者)の本人確認書類などの提出が求められます。サポートとは英語でのやりとりなので難易度は少し高めでした。

FAX PLUS も最上位である Enterprise プランを契約し、Azure AD による SAML/SSO や SCIM を構築しています。

また、Slack とのインテグレーションが可能であり、FAX を受信した際は Web アプリを開くことなく、Slack のチャンネルから直接 PDF で閲覧・ダウロードが可能です。

入退室管理 / Akerun コントローラー

オフィスの入退室を管理するセキュリティ製品も、移転前にオンプレミスサーバー型のものからクラウド型のものへリプレイスを行っていました。Akerun コントローラーという製品です。

akerun.com

Akerun というと、WeWork などのレンタルオフィスに工事不要で設置ができる Akerun Pro のイメージが強いかもしれません。 今回選定している Akerun コントローラーは、専門業者の工事により、既設のオフィスビルの電子錠や自動ドアにも対応したものです。

corp.teamspirit.com

また、Akerun は勤怠管理システムである TeamSpirit と API 連携を行うことが可能であるため、オフィスに出社した際には出勤・退勤が自動打刻されるように設定を行いました。 一休は北海道から沖縄まで多くの拠点を持ちますが、全ての拠点で Akerun へのリプレイスが完了しており、勤怠打刻の自動化は多くの従業員に喜んでいただけています。

終わりに

今回の移転ではモダンなオフィス・コーポレートIT環境へ一気に推進することができており、キラキラなオフィス環境にも見えるかもしれません。 一方で、タイトなスケジュールの中でのオフィス移転は、想定外のトラブルも含め多くの課題もあり、まだまだ改善・進化の余地が残されています

情シス / コーポレートエンジニアとして、エンジニアを含む従業員体験を向上を目指し、皆さんがより快適に業務を行うことができるオフィス・コーポレートIT 環境を目指して、引き続き全力でやっていきます 💪

突然ですが、ここで CM のお時間です

一休ではソフトウェアエンジニアをはじめ、多くの職種で積極的に採用を行っています。 選考をともなわないカジュアル面談からも受け付けておりますので、お気軽にご応募ください 👋

www.ikyu.co.jp

エンジニア採用の Twitter アカウントも開設し、イベント告知などを発信しています。こちらもフォローお待ちしております。

twitter.com

また、今回ご紹介した話はコーポレートITの取り組みの一部です。より深堀りした内容を Business Technology Conference Japan(BTCONJP) というITのカンファレンスでもお話させていただく予定です。 ご都合の合う方はこちらも是非オンラインにてご参加ください 🙏

btcon.jp

新サービス「一休.comふるさと納税」でデザインシステムの活用とFigmaを使いました

プロダクト開発部デザイナーの安松と申します。

10/3、新サービスの「一休.comふるさと納税」がローンチしました。
選んだ宿がある自治体に寄附をすると、一休.comで使える割引クーポンを返礼品として、web上で受け取れるというサービスです。

一休.comの宿泊予約とは違ったサービスですが、予約へとつながるサービスをどのようにデザインに反映させたか、また一休.comの宿泊デザインシステムの活用やFigmaを使ったことを振り返ります。

目次
1) ふるさと納税サイトで意識したこと
2) 宿泊のデザインシステムを活かせるか
3) Figma導入後、初のゼロからデザイン
4) まとめ

1. ふるさと納税サイトで意識したこと

サービスコンセプトのすり合わせ

モックを作成するにあたりビジネスサイドから、サービスの概要やターゲット層に加え、一休.com(宿泊)のUIを活かしたい、とにかくシンプルに…などの要望をもらいました。また、今回のプロジェクトは、短期期間での開発ということもあり、主要な導線の要素が大筋決まっていたので、画面イメージを早く作り詳細をすり合わせながら「どんなユーザー体験にしたいか」のヒアリングや掘り下げ、他のデザイナーからのフィードバックを受けながらの作成です。

デザインにどう反映したか

まずは既に一休.comを使っていただいているユーザーがターゲットです。どのようにデザインに反映したかまとめます。

ユーザーが「寄附」と「予約」を混乱しないように
宿泊と同様に宿から選ぶのですがあくまで「寄附」をするサイト。宿の情報などは最低限にして「予約」に関する宿の情報やプランは、宿泊サイトで確認していただくような作りです。行き来することも考え、メインとなる画像の見せ方も差別化しました。

宿のカードも差別化のため、ベースは活かしつつ意図的にデザインを変更しています。

シンプルかつスムーズに
情報もシンプルにしたので、使用する色も少なくし、目立つアクセントカラーをCTAボタンとして寄附完了までの道しるべを明確にすることで、迷わずスムーズに手続きいただけるよう意識したポイントです。

安心して寄附していただけるように
高額の寄附かつ変更やキャンセルができないため、特に寄附やクーポンに関する注意事項の配置や寄附金額・返礼品としてもらえる割引クーポン額の表示には気を付けました。

2. 宿泊のデザインシステムを活かせるか

ふるさと納税サイトを着手するの少し前から、宿泊ではデザインシステムの導入を始めており、デザインツールをXDからFigmaに移行し、デザインコンポーネントを大小さまざまな粒度で作成しています。こちらを活用することで宿泊のUIを活かしつつ、ふるさと納税サイトを組み立てようと考えました。実装上はコンポーネント化されておらず、宿泊のように、デザインとコードの連動はしていません。

実際にどの部分を採用したか一部をご紹介します。

タイポグラフィー
一休らしさを担うタイポブラフィー全般(書体・サイズ・太さ)はそのまま使用しています。

フォームのUI・パーツ
一休.comのユーザーが使い慣れているUIを活かすため、入力画面や決済画面はページ全体、ラジオボタンや入力フォームなどのパーツもほぼそのまま取り入れています。

タブデザイン

実装面でも宿泊と同様のTailwind CSSを採用していたこともあり、角丸やシャドウ、padding/marginなどの細かい部分も同じように使えたのもよかったことです。

このように別サービスでも、一休.comの宿泊デザインシステムで定義したデザインの基本要素や粒度の細かいコンポーネントが活用でき、「一休.comのUIを活かしたい」にも繋がったのです。また、活用したことで統一や上記で挙げた部分を考える時間は、他の作業に当てることができました。

user-first.ikyu.co.jp

3. Figma導入後、初のゼロからデザイン

宿泊でFigmaを使い始めた際、ツールの使い方に慣れず苦悩していました。さらに、既存サービスである宿泊デザインシステムではどのコンポーネントにするかを現状のデザインを見ながら考えていましたが、今回は新規サービスであったため、現状のデザインはない状態で新しいデザインの検討やパターン出しをすることに苦労しました。慣れないツールと新規サービスという2つの難しさが重なったことが辛かったです。

テキストスタイル・色の定義で大失敗、慣れるまで時間がかかった
デザインする中で、宿泊のコンポーネントを変更して使いました。それは問題のないことですが、ふるさと納税側で新しく定義する色を試行錯誤している段階では、Color Stylesで定義せずに進めていました。

結果様々な画面でパターンを出した後に定義を追加したため、作成済みのモックで色やテキストの置き換え作業が発生してしまいました。

仮の状態で後から微調整をするからこそ、定義をしておくべきでした。また、色の選考の際は定義したカラーリングごとにフォルダを作るなどをして、Figmaの便利な機能を活用できそうです。

コンポーネントも散らかり気味で、同じようなものができたり、手動で置き換え作業も何度もしましたが、最終的にページを横断して使うコンポーネントはまとめて管理し、ライブラリとして公開した後は、変更を加えると反映される状態になっていきました。ある程度デザインが固まったときにコンポーネントの見直し・整理をするというのも大事かもしれません。

4. まとめ

デザインに入る前に「どんなユーザー体験にしたいか」を掘り下げ事前に話し合ったことで、新しい機能や改善の際も、検討中の時にそれがぶれていないか?と一つの指標になり、チームが同じ方向を向けたと思います。

また、ふるさと納税では、宿泊のデザインシステムがあって本当によかったと感じています。統一感やスピードの面で活かすことができたためです。ただ、別サービスでも使用するという目的で作られていないため、今後の扱いは改めて検討する必要はありそうです。

そんな中、一休のサービスを横断したガイドラインを作成するプロジェクトが、デザイナー主体で始動しました。どのサービスを使ってもユーザーにとって素晴らしい体験ができるよう、さらに進化をしている最中ですので、今回の経験が少しでも役立てばよいなと思います。

一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。

hrmos.co

閲覧メインのページを検索メインのページに統合しました

こんにちは、プロダクト開発部の野口です。

一休にはたくさんの施設紹介ページがあるのですが、その中でもキュレーションページという流入数が高いページがありました。それをメイン動線であるリストページに統合したので、その経緯や裏側をご紹介します。

一休の施設掲載ページはたくさんある

一休には施設をまとめて掲載するページがたくさん存在します。

リストページ(メイン動線)

https://www.ikyu.com/area/ma000000/t105/si1/?adc=1&asc=01&cid=20221008&cod=20221010&hoi=1,2&lc=2&mtc=003&per_page=20&pn=1&ppc=2&rc=1

キュレーションページ

https://www.ikyu.com/theme/t105/

観光ページ

特集ページ

などなど。。。

その中でも今回はキュレーションページをリストページに統合したというお話です。

どうして統合したの?

キュレーションページは検索するためのページというよりも、検索エンジンからの流入口として用意されているものになります。そのため閲覧数は一休の中でもかなりいいほうなのですが、受動的な情報ばかりなので残念ながら予約までたどり着く割合は低かったのです。

一方でメイン動線であるリストページは検索に特化しており、ユーザが能動的に欲しい情報を要求し、的確な情報を表示できます。そのため予約までたどり着く割合も高く、メイン動線としての役割をしっかりと全うしています。

この2つを統合することで流入数と予約割合のどちらも高めたいというのが狙いになります。

苦労したこと

キュレーションページというのは、「地域xテーマ」ごとにページが生成されており、施設はランキング形式で表示されています。(先に掲載した画像は各キュレーションページの親ページになります)

一方でリストページはさまざまな条件で検索ができるのですが、キュレーションページと同じように「地域、テーマ」による検索もできます。しかもランキング形式での表示もできるため、キュレーションページと同じようなページを再現できます。

しかしながら、同じ条件で検索してもキュレーションページに表示される内容と異なることがあったのです。

原因はランキング生成ロジックがそれぞれのページで異なっていたからです。

もちろんどちらも八百長ということはなくデータに基づいてランキングを生成しているのですが、細かい条件が少しずつ異なり、結果別の施設が表示されてしまったのです。

実装方針ですが、キュレーションページには親ページが存在し、親ページでもランキングを掲載しており、その親ページは残すという前提がありました。そのためリストページにキュレーションページのロジックを持ってくる必要があります。しかし一休ではアーキテクチャを更新している最中で、キュレーションページ側は旧アーキテクチャ、リストページ側は新アーキテクチャと別れてしまったためロジックの移植はしないことにしました。代わりにキュレーション側のアーキテクチャで「エリアIDとテーマID」を受け取ってランキングを返すAPIを作成し、それをリストページのアーキテクチャが叩くことにしました。

進捗は順調・・・かと思ったら・・・

大体のページではランキングの結果が一致するようになり開発は順調かと思われたのですが、一部のページに限ってランキングの結果が合わない状況に直面しました。

キュレーションページのURLをよくよく見てみると、なにやら見たことない数字がいました。

https://www.ikyu.com/theme/a140000/g10/101/

「a140000」は東京を表すエリアIDで、「101」というのがテーマIDになります。「g10」は何者だ・・・?

キュレーションページに詳しい方に聞いてみると、どうやらキュレーション関連のページ限定の概念であるジャンルIDをいうものもページ生成に影響しているようで、「地域xテーマ」ではなく「地域xテーマxジャンル」で生成されているとのことでした。

ここで困ったことが起きました。

APIの方は単にジャンルIDも指定できるようにすればよかったのですが、リストページではジャンルIDという概念がないため、そもそもAPIのパラメータに含めることができなかったのです。

ジャンルIDを無視することも検討したのですが、それだとキュレーション親ページに表示されている施設とリストページに表示されている施設が異なってしまう可能性があります。一休ではユーザファーストを掲げているため、そんな体験は許されません。

そこでリストページのアーキテクチャにジャンルIDという概念を組み込むことにしました。「リストページ」に組み込むのではなく、「アーキテクチャ」に組み込みます。このため当初予定していたよりも多くの改修が必要になり、リリース予定日も延長してしまいました。

ですが、最終的には要件を落とすことなく、ユーザ体験も悪化させずにリストページに統合することができました。先のリンクを開くとリダイレクトしてリストページが開くのですが、「g10」の部分を変えることで結果が変わることを確認できると思います。

統合を終えて

今回の統合では要件を落としたり、無理やり実装したりという部分もいくつかあったためちょっと悔しい思いも残っています。

しかし訪れた人が予約した割合を比較してみると、統合前に比べて約1.6倍ほどに増えていました。少しでも使い勝手が良くなったと思い、端的に嬉しかったです。

今後のプロジェクトでもより良い体験を提供できるよう精一杯努力して参ります。

hrmos.co

一休 × 出前館 Frontend Meetup の第2回を開催します!

前回好評だった一休と出前館のオンライン・イベント Frontend Meetup の第2回を開催します。

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

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

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

ikyu.connpass.com

時間 内容 登壇者
18:00 ご挨拶
18:05 - 18:25 一休. com/Yahoo!トラベルのNuxt3移行における開発プロセス 杉田 隆紀
18:25 - 18:45 React 18 に見るユーザーファーストなローディング表示 yoshiyamay
18:45 - 19:05 Testing for Demae-can App 黒澤 慎治
19:05 - 19:35 パネルディスカッション
20:00頃 終了

あなたのプロダクトに 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とヤフートラベルでは、ユーザーに画像をアップロードしてもらう機能の実装は前例が無かったため、試行錯誤しながらの開発となりました。
今回はその時の開発についてお話したいと思います。

背景

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

全体像

画像の保管場所には、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 バケットに画像をアップロード

「非公開バケット」に画像をアップロードするときは、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. ライフインフラとなるために進めているアクセシビリティ向上への取り組み

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