TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

939

こんにちは! なんでもディープラーニングでやりたがる癖が抜けず、3ヶ月のディープラーニング禁止令を言い渡されていた後藤です。 本記事ではVASILYで利用しているデータ分析の環境について紹介します。 VASILYではデータ分析が必要な場面で、BigQueryとTableauを組み合わせて利用することが多いため、これらの実際の活用例とTableauの選定理由について紹介したいと思います。 以前、CTOがデータ周りの環境の全体像を紹介しました。 tech.vasily.jp 社内ではBigQueryを中心にデータ周りの環境が構築されており、そこからデータ活用のあらゆる業務へつながります。 データの可視化と社内への共有は主にTableauを使っています。 まずは、BigQueryとTableauの説明から始めます。 BigQuery とは BigQueryとは、Googleが提供しているデータウェアハウスです。 BigQueryは強力なコンピュータリソースを使って、ギガスケールの巨大なテーブルの集計や結合を数秒〜数十秒で安価に行うことができます。VASILYでは、マスターデータやアクセスログ、クローラーのログなど、あらゆるデータがBigQueryに同期され、分析に必要なデータはBigQueryにクエリを投げることで取得できるようになっています。 VASILYにおけるBigQueryへのデータの同期方法は、前回のブログで軽く触れています。詳細については後日公開する予定です。 tech.vasily.jp Tableau とは Tableauは、様々な形式のデータソースから必要なデータを抽出し、ドラッグ&ドロップなどの簡単な操作のみで、データの可視化が素早く行えるBIツールです。VASILYでは、分析やダッシュボードの作成を行うデータサイエンティストがTableau Desktopを使い、ダッシュボードの閲覧がメインの他のメンバーにはTableau Serverでの閲覧権限を発行する、というように使い分けています。 Tableau Desktop デスクトップにインストールするタイプのTableauです。BigQueryにクエリを投げたり、ローカルのcsvファイルを開いて手軽に分析を始めることができます。 大きなデータの集計をBigQueryに任せてしまえば、これを導入するだけでデータを可視化できる便利な分析環境が整います。 Tableau Server Tableau Desktopで作成したダッシュボードをTableau Serverにパブリッシュすることで、ブラウザ上で閲覧できるようになります。他部署への情報共有はTableau Server上で行います。ほかにもクラウド上で完結するTableau Onlineや作ったダッシュボードを一般に公開できるTableau Publicなどがあります。 BigQuery + Tableauの活用例 BigQueryの強力な集計機能とTableauによる直感的な操作による可視化により、データ分析の時間が大幅に削減されます。 この2つを組み合わせたツールをVASILYでは以下のような場面で活用します。 探索的データ解析 仮説検証 KPIの監視や施策の効果測定 データの集計と共有 VASILYにとって貢献度の高かったBigQuery + Tableauの活用例を紹介します。 一部のviewに関してはTableau publicに公開し、Tableauでデータを探索する雰囲気を感じられるようにしました。 データのスライシング IQONのコーディネートに付けられたタグとコーディネートに使われたブランドの関係を期間別に把握したいという要望があった場合、以下のようなダッシュボードを作成します。複数のディメンジョンのあるデータを一枚の図で可視化することは難しいですが、Tableauであれば以下のようなダッシュボードによってインタラクティブにグラフを操作することができます。 このダッシュボードを使うことで、「ガーリー」というタグが使われたコーデに、どのようなブランドのアイテムが使われているのかをひと目で把握することができます。逆に、各ブランドはどのようなタグのコーディネートに使われる傾向にあるかを把握するためのダッシュボードも同様に作ることができます。このようなダッシュボードがあれば、営業的にはこれらの知識をいつでも引き出すことができ話題のタネとなりますし、データサイエンティスト的にはデータの傾向を大まかに把握することで、どんな分布を持っているか、どんなモデリングできそうか、どのような前処理が必要か、などの判断ができるようになります。調べたい仮説がある場合も、すばやく答えに行き着くことができます。 BigQueryの課金状況を可視化 以下のダッシュボードは各チームが日毎にどれくらいの価格のクエリを発行しているのかを可視化したものです。BigQueryは従量課金制であるため、下手なクエリを投げ続けると無駄な課金が発生してしまいます。このようなダッシュボードで調査することで、無駄なクエリを発行しているかどうかを把握することができます。 以下のダッシュボードは、クエリ毎の料金を面積の大きさで表現したものです。BigQueryの課金額を個人ごとに突き詰めていきます。誰がいくら分使い、それが全体のどれくらいの割合を占めるのかを把握することで、節約意識が芽生えるとともに、明らかに無駄なクエリを指摘することができるようになります。ここで問題になったクエリは精査され、テーブルを読み出す回数を減らす工夫をしたり、不要な列を読まないようにすることでコストカットを目指します。 アクセス数の分布 アクセス数の規模を面積の大きさに、アクセスあたりの利益を色の濃度にして可視化した例です。このダッシュボードにより、どの項目がアクセスの規模の割に効率的に利益を生み出しているのかがひと目でわかります。例えば、図の白い部分は、利益を生み出していない項目です。この面積が大きい場合、アクセスの割に利益を生み出していないことを意味します。現状の問題点や打ち手を考えるきっかけとなるダッシュボードなので重宝しています。 現在、VASLYではこのようなダッシュボードが200以上作られ、刻々と変化するVASILYの重要な指標を把握できるようになっています。 Tableauを選んだ理由 様々な場面で利用しているTableauですが、より安価に同じ機能を実現できるツールがないかを調査してみました。より安価になりそうなBIツールとしては以下のものが候補にあがりました。 re:dash exploratory google data studio Power BI Quick Sight Superset 候補の中からVASILYの環境にあるものを改めて調査した結果、結局Tableauがよいという結論になりました。VASILYが重視している要素は「BigQueryとの連携ができる」「バグが少ない」「探索的データ解析ができる」ことです。 BigQueryと連携ができる VASILYのデータ周りの環境はTableauを抜きにしてもBigQueryが欠かせません。アイテムやブランドのレコメンドやランキング、類似画像検索を実現する上でもBigQueryを活用しています。可視化したいデータもサイズが大きい場合が多いので、BigQueryの強力な集計機能を利用したいです。 上記のリストの中では、BigQueryとの連携ができるものとして、Tableau、re:dash、Exploratory、Google Data Studioなどが挙げられます。 一方で、PowerBI, QuickSight, SupersetなどはBigQueryが利用できないため、候補から外しました。 バグが少ない 意思決定に使う分析結果は間違っていないことと常に動き続けることが不可欠です。 BigQueryとの連携がお手軽なre:dashは、OSSでバグが多めということもあり今回は候補から外しました。 また、これはバグではありませんが、ExploratoryとGoogle Data StudioはTableauと同じ感覚で大きなデータを渡すと動かなくなる場面が多く、候補から外しました。 探索的データ解析がしやすい Tableauも簡単な探索的データ解析であればゴリゴリ進めることができますが、このリストのなかではExploratoryも使いやすいです。Rがベースになっているだけあって高機能で、UIからk-means clusteringのようなアルゴリズムも走らせることができます。VASILYではそこまでの高度な解析が必要になる場合、Jupyter Notebookを使います。 運用 BigQuery + Tableauの組み合わせで分析業務を回して、1年以上経ちます。その中で得られた運用の知見を述べます。 ダッシュボードの乱立を防ぐ Tableauを使うと、あまりにも手軽にviewが作れるため、他部署からたくさんの依頼が来るようになりました。我々も必要だと思い、ダッシュボードを大量に作りましたが、結局閲覧されないダッシュボードも乱立してしまい、かけた時間が無駄になることがありました。そこで、作成前にダッシュボードの必要性をデータチームで検討し、本当に知りたいことはなにか、かける時間に対して価値のあるダッシュボードになるかどうかをディスカッションしてから作り始めるようにしました。他部署の作業時間を大幅に減らせる見込みのあるダッシュボードのみを作成するようにしています。いまでは無駄なダッシュボードは一切作られず、データチームがダッシュボード作成にかける時間も減りました。 定期的に見るものはSlackに投稿する 業務に関係のある数値は日頃から追う必要がありますが、毎日Tableau Serverにアクセスしてチェックするのは負担です。そこで、重要な指標は自動的にSlackに投稿し強制的に目に触れるようにしています。毎日ほぼ強制的に数値を閲覧させられるため、大体の数値の感覚と、前日までの状態の良し悪しがわかるようになります。会社の飲み会で行ったクイズ大会で、KPIを答えさせる問題が出題されましたが見事に全員が答えられており、成果は出ていると感じています。 迷ったらサポートに聞く 保守費用を払っているため、カスタマーサポートを利用することができます。作業中にバグが出て作業が進まない場合は、悩まずにサポートに連絡して対応してもらいます。早ければその日のうちに回答がもらえ、なにかしらの行動が取れるようになります。 テクニックは先人に学ぶ Tableauをマスターし自在に使いこなせる人々のことをTableau Jediと呼ぶそうです。こちらからJedi達による美しいダッシュボードが閲覧&ダウンロードすることができます。 public.tableau.com ダッシュボード作成の際、やりたい操作が見つからない場合は、Tableau communityに質問を投げかけると、上級者がサンプルを作って見せてくれることもあります。かつて似たような問題に困っていた人の知見なども集められており、参考にすることが多いです。 最後に VASILYには分析しきれていない面白いデータが大量に眠っています。 我こそは思った方、データサイエンティストとして我々と切磋琢磨しませんか?
アバター
こんにちは、バックエンドエンジニアの塩崎です。 先日、VASILYバックエンドチームにインターン生が来てくれました。 この記事では彼がインターンで作ってくれた機能や、インターン中のスケジュールなどを紹介します。 インターンに来たのはこんな学生 インターンに来たのはこの春に大学4年生になったばかりの、柴犬大好き系エンジニアのT君です。 好きな言語はClojureというなかなかギークな学生さんでした。 インターンに来てもらう前に提出してもらった事前課題では、コードの綺麗さが光っていました。 この課題はRuby on Railsで「とあるお題」に従ったWeb APIを作るものなのですが、Rubyでありながら変数の再代入を嫌う傾向はさすが関数型に慣れているだけのことはあるなと感じました。 やってもらったタスク 目標 今回のインターンでT君にやってもらったタスクはMySQLからBigQueryのデータ同期の高速化・安定化です。 VASILYでは分析用データベースにBigQueryを使用しており、MySQLに保存されているマスターデータを毎日BigQueryに同期しています。 この処理を行うシステムは約2年前に書かれたもので、プロダクトの成長に伴うデータ量の増加に耐えることができなくなり始めていました。 BigQueryに保存されたデータはVASILYの機械学習システムの入力データとして使われ、ユーザーさんへの価値を届けるために使用されます。 また、会社としての意思決定を行うための資料であるダッシュボードの情報はBigQueryに保存されたデータを可視化したものです。 そのため、この同期処理が正しく行われている状態を維持することは非常に大切なことです。 彼にはこの同期処理を行うシステムを一から作り直してもらいました。 日程 T君には10日間インターンとしてVASILYで働いてもらいました。 技術調査から始めて、必要な機能の実装、本番環境へのデプロイまでをこの期間で行ってもらいました。 そして最終日には役員の前での成果発表会を行いました。 彼がどのようなスケジュールでインターンのタスクをこなしたのかを紹介します。 1〜3日目 データの同期処理そのものはembulkを使用することになりましたので、インターンの前半部分は技術調査をしてもらいました。 embulkが今回の用途に対して十分な機能を持っているかどうか、性能は十分かどうかなどの調査をしてもらいました。 具体的には絵文字(特にUTF8で表現した時に4byteになるような絵文字)を問題なく同期できるかどうか、数十GBの巨大なテーブルを同期する時の処理時間などを調査をお願いしました。 また、embulkのプラグインを調べてもらう中で、プラグインのドキュメントの不備を見つけていたので、その修正のPullRequestを出すようにそそのかしました。 彼にとっての初OSSコントリビュートだったそうです。 4〜6日目 インターン中盤にembulk単体では目標を達成できないことが分かったため、embulkのラッパーを作ることになりました。 embulkは1つのテーブルの同期設定を1つのYAMLで管理するため、複数個のテーブルを同期する場合にはこのYAMLファイルの管理が課題になります。 また、同期に失敗した場合のエラー通知や、同期のログを保存する機能もembulkそのものにはありません。 これらの課題を解決するためのラッパーを彼に書いてもらうことにしました。 VASILYのサーバーサイドは主にRubyが使用されており、Rubyに詳しいエンジニアが多いので、とりあえずRubyで書いてみることを勧めてみました。 ですが、Goで書いてみたいという彼の意見を採用し、Goでラッパーを書いてもらいました。 メンターである僕自身もGoにそこまで詳しいわけではなかったので、この瞬間からタスクがメンターにとってもチャレンジングなものに昇華しました。 彼は自発的にGoに関する資料を調べ、必要なライブラリなどを導入してくれましたので、今から振り返ると、Goを採用して良かったと思います。 7〜9日目 インターンの後半部分ではCIやデプロイなどの運用に関する部分を作ってもらいました。 CircleCIでGoのテストを実行したり、CircleCIのコンテナ内でクロスコンパイルしたGoのバイナリをプライベートネットワークの中のEC2インスタンスに配置したりする部分を実装してもらいました。 これらは彼にとって未知の領域でしたので、いろいろと調べながら実装することが多かった様子でした。 10日目 最終日にはVASILYの役員の前で成果発表を行いました。 役員の一人であるCTOの今村はエンジニアである、VASILYの技術のプロフェッショナルですが、他の役員はそうではありません。 エンジニアではない人に対して自分が行った成果を説明することにやや戸惑う様子もありましたが、非常にわかりやすく説明できており、役員から好評をもらいました。 最終的にできたもの 最終的にインターンの成果物として出来上がったものを紹介します。 以下の図は彼の成果発表のスライドから抜粋したものです。 これらの機能の詳細は機会があればTECH BLOGで紹介したいと思います。 設定された時間になると、Goで書かれたembulkラッパーをcronが呼び出します。 このラッパーはMySQLから同期するべきテーブルのスキーマ情報を読み込み、embulkの同期設定ファイル(YAML)を生成します。 そして、そのYAMLファイルを引数にしてembulkを起動し、データの同期処理を行います。 また、BigQueryに同期する時にGCSにテーブルのダンプを置くことによって、同期の安定化・高速化を図っています。 図中のほうれん草の画像から下の部分はログ収集部分です。 詳細なログはテキストファイル形式でサーバー内に保存されます。 要約されたログはVASILYのログ収集基盤によって収集され、最終的にはS3とBigQueryに動作ログが保存されます。 このログ収集基盤については以下の記事で詳しく説明されていますので、興味のある方はご覧ください。 tech.vasily.jp 本人の感想 10日間のインターンは、とにかく楽しすぎる10日間でした。 初日にミドルウェアを色々使いたいと要望を出したところ、今回の課題をやることになり、やりたいことをインターン生にやらせてくれるという柔軟さに驚きました。 やりたいことと、課題がマッチしていたおかげで積極的に楽しく取り組むことができました。 開発では技術調査から、プログラミング、テスト、CI、デプロイまで一気通貫で取り組むことができました。 CIやデプロイまわりは普段あまり経験する機会がなかったのですごく勉強になりました。 技術調査中にメンターの塩崎さんに指導してもらいながら初OSSコントリビュートも果たすことができました。 これからどんどんコントリビュートしていきたいと思います! 会社はとにかく賑やかで、気軽に質問したり、話をしたりと楽しく過ごすことができました。 今回作成したものが実際に利用されているということで自信にもつながりました。 インターンの経験を活かしてどんどんレベルアップして、塩崎さんみたいなすごいエンジニアになりたいと思っています!! まとめ VASILYのバックエンドチームにインターンに来てくれたT君のおかげでMySQLからBigQueryへデータ同期を行うシステムがモダンなものになり、安定して動作するようになりました。 BigQueryに格納されたマスターデータはBIツールであるTableauによって可視化され、それに基づいて意思決定が行われます。 そのため、安定して同期が行えるようになったことは意思決定を正しく行う上でとても意味のあることでもあります。 VASILYのインターンは本番環境で動かすための機能を作ってもらうことが大きな特徴です。 そのため、インターン生を「お客様」としては扱いません。 本番環境のコードに触れる以上、インターン生も1人のプロフェッショナルであるという思いを持って指導を行います。 そんな環境でスキルを磨きたいという熱意に溢れる学生の皆様はぜひ以下のバナーからご応募ください。
アバター
iOSエンジニアの庄司です。 今回は開発中のアプリで使った UIFeedbackGenerator についてご紹介します。 UIFeedbackGenerator とは、iOS 10以降で利用できるHaptic Feedback (触覚フィードバック) のAPIです。 この記事の要約 一般的な UIFeedbackGenerator の使い方を紹介。 iOS Human Interface Guideline でどのように推奨されているか解説。 自分はこんな場面で導入してみました。 UIFeedbackGenerator によるフィードバックを簡単に実装できる ライブラリ を公開しました。 Haptic Feedback とは 「触覚フィードバック」と訳します(今後 "Haptic Feedback" と呼びます)。 iPhone 6s 以降に搭載された Taptic Engineというハードウェアによる振動で、ユーザーのアクションに対するフィードバックを表現します。 Haptic Feedback の例 iPhone 7のホームボタンを押した時の振動 ホーム画面のアプリアイコンを強めに押してQuick Actionsが開く時の振動 UISlider のスライダーが両端にぶつかった時の小さな振動 UISwitch の ON / OFF を切り替えた時の小さな振動 UIFeedbackGeneratorの使い方 表題の UIFeedbackGenerator は抽象クラスで、Haptic Feedbackを発生させる実クラスは UIImpactFeedbackGenerator , UISelectionFeedbackGenerator , UINotificationFeedbackGenerator の3つです。 下記の表にそれぞれ特徴をまとめました。 UIFeedbackGenerator のサブクラス 種類 用途 UIImpactFeedbackGenerator 3種類 UIImpactFeedbackStyle ( .light , .medium , .heavy ) UIの衝突やスナップを表現します UISelectionFeedbackGenerator 1種類のみ 一連の離散値を動きを伝えます UINotificationFeedbackGenerator 3種類 UINotificationFeedbackType ( .success , .warning , .error ) タスクやアクションの成功/警告/失敗を通知する 実装方法 例) UIImpactFeedbackGenerator class ViewController : UIViewController { private let feedbackGenerator : Any ? = { if #available(iOS 10.0 , * ) { let generator : UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style : .light) generator.prepare() return generator } else { return nil } }() @IBAction private func light () { if #available(iOS 10.0 , * ), let generator = feedbackGenerator as ? UIImpactFeedbackGenerator { generator.impactOccurred() } } この先のiOS Human Interface Guidelineでも説明していますが、Haptic Feedbackの実行は遅延することがあるため、Haptic Feedbackと同時に音声を流すときなど、実行前の適切なタイミングで UIFeedbackGenerator の prepare() メソッドを呼ぶことが推奨されています。 prepare() を呼んでおくことで数秒間準備状態になり、Haptic Feedback発生のレイテンシーを下げることができます。 prepare() のタイミングが早すぎたり、Haptic Feedback発生と同時に呼んだ場合は効果がありません。 prepare() メソッドを呼ぶことはオプションですが、 ドキュメント では強く推奨されています。 iOS 10以降のAPIのため、iOS 9以下のOSをサポートする際は、 UImpactFeedbackGenerator のプロパティを生成するタイミングと、Haptic Feedbackを発生させるタイミングで、iOSのバージョン判別が必要になります。 Haptic Feedbackの使いどころ iOS Human Interface Guidelines に Haptic Feedback を実装する上での注意点がまとめられています。 下記はそのガイドラインの意訳と解説です。 ここでいうフィードバックとは、アクションの結果をユーザーにフィードバックする手段です。 アラート表現はフィードバック方法としては強力ですが、重要な情報を含まないアラートを多用していると、そのうち重要な情報も含めてアラートそのものを無視するようになります。 そこでHaptic Feedbackを使いましょう。 ユーザーに注意喚起してアクションを強化する物理的フィードバック(振動)を発生させます。 Success. 入金成功や車の解錠成功を伝えます。 Use haptics judiciously. フィードバック全体の意味が薄れてしまうので、多用は避けましょう。 In general, provide haptic feedback in response to user-initiated actions. ユーザーのアクションに合わせてフィードバックを発生させましょう。任意のタイミングでのフィードバックは意識を中断させ、誤解を生みます。 Don't redefine feedback types. フィードバックタイプを意図どおりに使用してください。タスクが成功したことをユーザーに通知するには、 UINotificationFeedbackGenerator の UINotificationFeedbackType.success を使用し、それ以外のものは使わないようにしましょう。 Fine tune your visual experience for haptics. 視覚と触覚のフィードバックを同時に発生させることでアクションと結果のより深いつながりを提供します。 例) 操作のエラーを表現する時に音と一緒にHaptic Feedbackを発生させる。 Don’t rely on a single mode of communication. 全ての端末がHaptic Feedbackをサポートしているわけではないので、アクションの結果をHaptic Feedbackだけにしてはいけません。 Use haptics when visual feedback may be occluded. オブジェクトをスクリーン上の場所にドラッグするときなどは、指で隠れてしまいます。特定の場所や値に到達したときにユーザーにHaptic Feedbackで通知することを検討してください。 Prepare the system before initiating feedback. Haptic Feedbackは遅延が伴うことがあります。フィードバックを発生させる直前にシステムを準備することが最善です。そうしないと、Haptic Feedbackが遅すぎて、ユーザーの行動や画面に表示されている内容から切り離されているように感じる可能性があります。 Synchronize haptics with accompanying sound. Haptic Feedbackは音を発生させません。Haptic Feedbackと同時に音が必要なら、各自で音声同期処理を実装する必要があります。 こんな場面で導入してみました 公開しているライブラリ RangeSeekSlider の、段階的に値を変えられるスライダーで、値が変わったことを伝えるためにHaptic Feedbackを発生させるようにしました。(前の項目では Use haptics when visual feedback may be occluded. に当たるものです。) 値が変わったタイミングで UISelectionFeedbackGenerator のフィードバックを呼び出しています。 下記のGIFの「Range with Step」のオレンジ色のスライダーです。 ライブラリ化しました TapticEngine と言うライブラリにして公開しました。 150行程度 の小さいライブラリです。 まだシェアが多く切ることができないiOS 9と同居して使えるようにしました。 OS判定の必要がありません。 Taptic Engine が搭載されていない端末、iOS 9端末では処理を呼び出しても何も動作しません。 // Triggers a impact feedback between small, light user interface elements. (`UIImpactFeedbackStyle.light`) TapticEngine.impact.feedback(.light) // Triggers a impact feedback between moderately sized user interface elements. (`UIImpactFeedbackStyle.medium`) TapticEngine.impact.feedback(.medium) // Triggers a impact feedback between large, heavy user interface elements. (`UIImpactFeedbackStyle.heavy`) TapticEngine.impact.feedback(.heavy) // Triggers a selection feedback to communicate movement through a series of discrete values. TapticEngine.selection.feedback() // Triggers a notification feedback, indicating that a task has completed successfully. (`UINotificationFeedbackType.success`) TapticEngine.notification.feedback(.success) // Triggers a notification feedback, indicating that a task has produced a warning. (`UINotificationFeedbackType.warning`) TapticEngine.notification.feedback(.warning) // Triggers a notification feedback, indicating that a task has failed. (`UINotificationFeedbackType.error`) TapticEngine.notification.feedback(.error) // Prepare a impact feedback for `UIImpactFeedbackStyle.light`. TapticEngine.impact.prepare(.light) // Prepare a selection feedback. TapticEngine.selection.prepare() // Prepare a notification feedback. TapticEngine.notification.prepare() 最後に 今回は、 UIFeedbackGenerator の使い方について紹介しました。公開したライブラリは小さなライブラリなので、気になることがあれば是非ソースコードを読んでPRを送ってください。 VASILYでアプリを作りながらOSS開発もやりたいiOSエンジニアを募集しています。興味がある方は以下のリンクをご覧ください。
アバター
こんにちは! バックエンドエンジニアのりほやんです! 2017年の2月28日にIQONはリブランディングを行い、タグラインを "わたしの「好き」がここにある” に刷新しました。 この “わたしの「好き」がここにある” という体験をユーザーにしていただくには、IQONに掲載されている商品情報がとても重要になります。 そして、正確な商品情報の掲載にはクローラーが正しく運用され稼働していることが必要不可欠です。 本記事では、IQONの商品情報を支えるクローラーの運用をどのように仕組み化しているかについてご紹介します。 クローラーを作成、運用されている方々のお役に立てたら幸いです。 弊社テックブログでは以前、『速くクロールすること』に注目した記事を公開しましたが、今回は『正しくクロールすること』に注目しました。 『速くクロールすること』に注目した 『Docker / Apache Mesos / Marathon による3倍速いIQONクローラーの構築』 を合わせて読んでいただくと、より深く弊社のクローラーを理解していただけると思います。 tech.vasily.jp IQONのクローラー運用 IQONでは、毎日数百の提携ECサイト、数百万ページのクロールを行っています。 そして、提携しているすべてのクロール元サイトから辿ることができる全ての商品ページを取得し、なおかつタイトル・価格・ブランド・在庫などのすべての情報が記載されている内容のまま、IQONに掲載されていなければいけません。 そこで、下記の2つの条件を満たすことをクローラーが正常稼働している条件としています。 正しい商品情報がクロールされている すべての商品がクロールされている VASILYではそれぞれの条件を満たすために、以下のことを行っています。 正しい商品情報がクロールされている 作成: リニューアルや修正に強いXPathを作成する 作成: 細かくパースチェックを行う 運用: パースエラーが発生していないかをチェック すべての商品がクロールされている 運用: 商品詳細総ページ数に変動がないかチェック 運用: 新商品の追加数に変動がないかチェック では実際に、それぞれのチェックや重要なポイントをクローラ作成時と運用時に分けてご紹介します。 クローラー作成 リニューアルや修正に強いXPathを作成する VASILYでは、クローラー作成時の要素パースにXPathを用いています。 XPathはXML文章中の要素、属性値などを指定するための言語です。 クローラー運用の負担を軽減させるため、クローラー作成時にリニューアルや修正にも強いXPathを作成することが大切です。 クローラー作成に便利なXPathの記法を2つご紹介します。 複数のclassが指定されている要素の取得 下記のようにclassが複数指定されている要素を取得したい場合のXPathを考えてみます。 // 商品タイトル < div class = "title class1 class2" > ロングカーディガン </ div > 上記は『ロングカーディガン』という商品タイトルを表すHTMLです。 ロングカーディガンという文字を取得したい場合、どのようなXPathになるでしょうか。 classを指定して要素を取得する場合、下記のようなXPathがすぐに思いつくと思います。 //div[@class='title class1 class2'] => ロングカーディガン しかし、リニューアルなどで紐付いているclassが以下のように変更された場合、先ほどのXPathでは要素が取得できなくなってしまいます。 // 商品タイトル < div class = "title class1 class3" > ロングカーディガン </ div > そこで、指定する文字列が含まれている要素を取得することができるcontainsを使います。 『ロングカーディガン』は商品タイトルなので、titleというclass名は変更されにくいと考えられます。 このように変更されにくいclass名を指定して、下記のように記述します。 //div[contains(@class,'title')] => ロングカーディガン このXPathは、titleというclassが含まれるdiv要素を取得します。 このXPathであれば、先ほどのように紐づくclassがclass2からclass3に変更されたとしても、titleというclassが指定されている限りタイトルの取得が可能です。 テーブル内の要素を取得する場合 次にテーブル内の要素を取得するパース処理を考えてみます。 < table class = "table" > < tbody > < tr > < th > カテゴリ </ th > < td > カーディガン </ td > </ tr > < tr > < th > 素材 </ th > < td > 綿100% </ td > </ tr > < tr > < th > 性別 </ th > < td > レディース </ td > </ tr > </ tbody > </ table > このようなテーブルからレディースという要素を取得したい場合、tr要素の3番目のtd要素という情報から下記のように書くことができます。 //table/tbody/tr[3]/td しかし、上記のようにtrの3番目というような指定をするとテーブルの要素が増減した際に、違う情報を取得してしまう可能性があります。 // 要素が増えてしまった時、trの3番目の要素は原産国になってしまう。 < table class = "table" > < tbody > < tr > < th > カテゴリ </ th > < td > カーディガン </ td > </ tr > < tr > < th > 素材 </ th > < td > 綿100% </ td > </ tr > < tr > < th > 原産国 </ th > < td > 日本 </ td > </ tr > < tr > < th > 性別 </ th > < td > レディース </ td > </ tr > </ tbody > </ table > //table/tbody/tr[contains(th,'性別')]/td => 原産国 そこで、tdに紐づくth要素を指定することで、要素の増減に左右されず性別を取得することができます。 //table/tbody/tr[contains(th,'性別')]/td => レディース 細かくパースのチェックを行う 要素をパースする処理を書き終えたら、正しいパースを行えているかを確認するために、パースチェックを行います。 パースのチェックスクリプトを作成しておくと便利です。 この際、下記の2つのチェックを行うスクリプトを用意します。 1つのパースをチェックする すべてのパースをチェックする すべてのパース処理を書いた後にすべてのパースをチェックするだけでなく、1つパース処理を作成する度にパースのチェックを行う方が、ミスに気づきやすく最終的に運用の負担が少なくなります。 このようなクローラー作成作業を効率化するために、VASILYでは、Crawler Generatorという社内ツールが開発されています。 このツールでは、クロールする項目ごとにXPathや、正規表現などを設定するだけでパース処理が作成できるため、効率的にクローラーを作成できます。 また、項目ごとにプレビュー機能がついており細かく項目のチェックを行うことで、間違った情報をパースしてしまっていることにもすぐに気づくことができます。 クローラー運用時 クローラーを作成した後も、クロール元サイトのリニューアルなどにより正しいデータ取得できなくなることがあります。 この正しいパースができなくなっているという異常を未然に防ぐことは難しいため、いち早く異常を検知し、修正することが大事です。 VASILYではクローラーの異常を検知するために、3つのチェックを行なっています。 パースエラーが発生していないか 商品詳細総ページ数に変動がないか 新商品の追加数に変動がないか パースエラーが発生していないか 初めは正しい情報をパースできていても、クロール元サイトのリニューアルなどで、正しい情報をパースできなくなることがあります。 そこで処理を行った商品ページ総数のうち、ある一定以上パースが失敗している場合を、異常として検知します。 しかし異常が検知されたクローラーの何のパースが失敗しているかを特定・調査しなければいけません。 この時、ログが散在していたり・使いにくい状態だと調査に時間がかかってしまいます。 そこで、ログの集約・調査に、BigQueryを用いています。 BigQueryにログ集約テーブルを作成し、クロールの処理ログを流すことで、エラーの特定調査を効率的に行えるようにしています。 あるサイトでパース異常が検知されたら、どこのパースが失敗しているかをBigQueryのログ集約テーブルから調査し、修正を行います。 商品詳細総ページ数に変動がないか IQONのクローラーは毎日クロール元の商品リストページから、全商品詳細ページを取得しています。 ECサイトの商品総数は日々多少の増減はしますが、大幅に変化することはあまりありません。 そこで毎日の総商品詳細ページ数を監視し、大幅な変化が見られるサイトを異常とみなしチェックすることで、すべての商品をクロールしていることを保証しています。 この大幅な変化が見られるサイトを検知するために、標準偏差を用います。 毎日クロールするページ総数が変わらないことを期待し、直近1週間のクロール数と今日のクロール数から、標準偏差を求め異常かどうかを検知しています。 ここで直近1週間のデータを使用している理由は、様々な日数で試した結果、異常がない日が続く日数として1週間が適切であったからです。 一週間以上の長い期間を対象とすると、過去に異常検出された日が入ってしまい、異常でないサイトも異常として検知されてしまうことがあります。 また、異常とみなす閾値については、初めは厳しく設定し運用する中で調整しています。 新商品の追加数に変動がないか 商品詳細総ページ数チェックに加えて、新商品追加数に変動がないかをチェックしています。 しかし新商品が毎日追加されるサイトもあれば、更新頻度が低いサイトもあり商品追加頻度は各ECサイトごとに様々です。 そこで、2つのチェックを行い異常を検知しています。 まず過去新規アイテム追加0である日が最大何日続いていたかを調べ、その連続日数を超えている場合、異常として検知するというチェックを行っています。 具体的には、直近1か月のクロール情報から日ごとの新規アイテム追加数を取得し、アイテム追加が0である日の最大継続日数を算出します。 今日のアイテム追加数が0であるサイトの中から、最大継続日数を更新しているものを異常とみなし検知します。 ここで直近1か月のデータを用いているのは、先ほどと同じく異常が正しく検知されるのに適した日数が1か月であったからです。 状況によって適した日数は変わりますが、だいたいの目安として考えていただけると幸いです。 また、毎日アイテム追加が行なわれているサイトが、急にアイテム追加されなくなった場合も検知しています。 具体的には、連続10日間アイテム追加されていたが、突然アイテム追加数が0になった場合を異常とみなして検知しています。 連続10日間アイテム追加があるということは基本的に毎日アイテムが追加されるサイトと考え、毎日アイテム追加があるはずのサイトが急にアイテム追加されなくなった場合は、こちらのクロールに異常があると考えこのチェックを導入しています。 過去10日間のデータを用いているのは、パースのエラーなどの要因でアイテム追加に影響があったサイトも、この検知の対象になるように調整した結果です。 修正対象クローラーを分かりやすく確認するために これまでIQONがクローラーをどのようなチェックで運用しているかをご紹介しました。 これらはチェックの方法であり、毎日どのサイトで異常が発生しているかを一目で確認できるものが必要です。 VASILYでは、昨年末まで修正対象のクローラーを検知するために専用のダッシュボードを作成し運用に使用していました。 しかし、誰がどのクローラーの修正を行ったかがわからなかったり、見るべき指標が多く、異常を検知できていない時があるという問題がありました。 そこで改めて効率的にクローラーの異常をわかりやすく検知し運用するためにはどうすれば良いかを考えた結果、クローラーチェックツールの開発に至りました。 このツールでは、運用に必要な下記3つのチェック項目それぞれについて正常か異常かが一目でわかり、なおかつ対応が必要なクローラーがどれかをすぐに確認することができます。 また修正状況を確認することもできます。 商品詳細ページ数に変動がないか 新規アイテム追加に異常はないか パースのエラーが発生していないか すべてのサイトに対して3つのチェックを行い、異常が検知されたチェックを赤く表示しています。 この時どれくらいエラーが起きているかという数値は表示せず、エラーが起きているサイトはすべてその日中に修正すべきと考え、異常か正常かという表示のみを行っています。 担当の欄には、バックエンドチームの名前がランダムに割り振られ、毎日割り振られた担当のクローラーの修正を行います。 異常が修正されたことを確認した後、画面上のFixedボタンを押すことで、対応完了したことが他の人にもわかるようになっています。 このツールを用いることで、どのクローラーで異常が発生しているかが一目でわかりまた誰がどのクローラーを修正したかが分かりやすくなり、効率的にクローラーを運用できるようになりました。 まとめ 以上、IQONのクローラー運用についてご紹介しました。 IQONでは、数百のECサイトをクロールしているため、クローラーを効率的に作成・運用するために仕組み化を行っています。 現在、クローラーの異常は当日中に検知・修正し、再稼働させていることで毎日正しい情報をIQONに掲載することができています。 ぜひご紹介した仕組みの中で参考になるものがあれば導入してみてください♩ 最後に クローラーの運用はとても地道です。しかしIQONに、商品情報が正しく掲載されていることは必要不可欠です。 そして正しい商品情報を掲載するためのクローラーを、効率的に運用することにとてもやりがいを感じます! VASILYでは、クロール・効率化・仕組み化に興味がある人を大募集しています。 春季インターンの募集も行っておりますのでご興味のある方は、是非弊社に遊びに来てください!
アバター
こんにちは、バックエンドエンジニアのじょーです。大規模なサービスのAPIを開発する際に、ルールを決めずに開発していると無秩序なコードが散見される運用がしづらいAPIになってしまいます。また、ルールを決めたとしても共有が上手くいかないなどの理由で守られなくなってしまうこともあると思います。 本記事では、APIを運用しやすくするために、ただルールを決定しただけではなく、ルールを守るためにそれぞれ仕組み化をしたことを紹介します。 APIのレスポンスを統一する デコレーターを使ってレスポンスの定義を綺麗に書く パラメーターを統一する Validatorによりパラメーターの明記を強制する コーディング規約を守る LinterとSideCIを導入して修正とレビューの自動化 Linterのルールを適度に調節する 1. APIのレスポンスを統一する ここで言うAPIのレスポンスを統一するというのは、返すAPIフォーマットのブレをなくすことです。フォーマットが一定のルールに従うことで使う側はもちろんのこと、作る側にもメリットがあります。 レスポンスを統一するのは当たり前のように感じますが、開発者が複数人いる際は細かい部分にブレが生じてきます。 そこで、APIレスポンスを統一するために実践しているテクニックを紹介します。 デコレーターを使ってレスポンスの定義を綺麗に書く APIを作る際に、DBのレコードをそのまま加工せずに返す例は少ないです。レコード自体に情報を足して返したり、別の情報を付加して返すことがほとんどだと思います。 その際、レスポンスを定義する処理をモデルに書いているとあっという間にモデルが肥大化してしまいます。場合によっては、同じような処理が乱立してレスポンスの統一が難しくなる可能性があります。 そのような場合、レスポンスを定義するコードはモデルとは別ファイルに切り出した方が処理が一箇所にまとまって見やすくなり、レスポンスを統一することも簡単になります。 弊社では、 draper というgemの Draper::Decorator と、 fieldgroup という、レスポンスのまとまりを定義する独自の手法を使ってモデルとは別ファイルに処理を切り出しています。 まず、 Draper::Decorator の処理を簡単に説明します。 Draper::Decorator を使うと、モデルをwrapして、レスポンスに付加したい情報を定義することができます。 下記は、紐づくショップの情報をブランドのレスポンスに追加したい場合の例です。 モデル名 + Decorator という命名規則のクラスを宣言し、 Draper::Decorator を継承して使います。 class BrandDecorator < Draper :: Decorator # ブランドモデルのレコードをwrapする記述 delegate_all # 付加したい情報の記述 # 'context'を外部から指定して受け取ることが可能 def shop Rails .logger.info ' shop情報ですよ ' if context[ :logger ] == true Shop .find(object.shop_id).attributes end end このようにクラスを定義すると、Brandモデルのインスタンスに .decorate メソッドを使ってアクセスしブランドの情報を引くことができるようになります。 また、 .decorate(context: { logger: true }) のように指定すると、メソッドでcontextの内容を受け取ることができます。 # もともとレコードに存在するキーはそのままレコードの内容を返す > Brand .find( 1 ).decorate.name => ' Lawrys Farm ' # Decoratorに宣言したキーはメソッドで定義した内容を返す > Brand .find( 1 ).decorate.shop => { id : 1 , name : ' 代官山店 ' } # contextを指定 > Brand .find( 1 ).decorate( context : { logging : true }).shop => ' shop情報ですよ ' => { id : 1 , name : ' 代官山店 ' } ( Draper::Decorator の機能の詳細は こちら を参照してください。) この Draper::Decorator の機能とプラスして、 fieldgroup という、レスポンスのまとまりを定義して自由にレスポンスのサイズを変えられる仕組みを独自で作っています。レスポンスのまとまりを1ファイルに定義することで、そのファイルを見るだけでどのようなレスポンスが返るかわかりやすくなったり、レスポンスのキーを足したり減らしたりすることが容易になります。 下記に、ディレクトリ構成と、実際の fieldgroup の実装内容を載せます。 ディレクトリ構成 app/decorators - item_decorator.rb - user_decorator.rb - brand_decorator.rb class BrandDecorator < Draper :: Decorator include FieldgroupDefinedable delegate_all define_fieldgroup :small , [ :id , :name , :kana , :initial ] define_fieldgroup :medium , [ :shop ] define_fieldgroup :large , [ :total_item_count ] def shop # 付加したいショップ情報を記述 Shop .find(object.shop_id).attributes end def total_item_count # ブランドにひも付くアイテム数を取得する処理 end end このように、大、中、小(large, medium, small)に分けてレスポンスを定義しています。 リストページなどの少ない情報を返す際にはsmall、詳細ページにはlargeを指定するなどしてシーンに合わせて使い分けることができます。 実際に中、小のグループを引いた例がこちらです。 # フィールドグループsmallを指定 > Brand .find( 1 ).decorate( context : { fieldgroup : ' small ' }).to_hash => { id : 1 , name : ' Lawrys Farm ' , kana : ' ローリーズファーム ' , initial : ' L ' } # フィールドグループmediumを指定 > Brand .find( 1 ).decorate( context : { fieldgroup : ' medium ' }).to_hash => { id : 1 , name : ' Lawrys Farm ' , kana : ' ローリーズファーム ' , initial : ' L ' , shop : { id : 1 , name : ' 代官山店 ' } } 上記では .decorate にプラスして、 .to_hash をつけています。 .to_hash をつけることで define_fieldgroup で定義した内容のまとまりがHashで返るような仕組みを独自開発しています。また、contextに fieldgroup を指定することで指定したサイズのレスポンスが返る実装になっています。 .to_hash でレスポンスをまとめて返す処理と、 define_fieldgroup の内部の詳しい実装は以下のようになっています。 module FieldgroupDefinedable extend ActiveSupport :: Concern included do class << self def define_fieldgroup (name, keys) @fieldgroup_keys ||= {} @fieldgroup_keys [name] = keys end def fieldgroup_keys (name) @fieldgroup_keys [name] end end # contextで指定されたfieldgroupのサイズを取得する処理 def hash_group_name context[ :fieldgroup ].present? ? context[ :fieldgroup ].to_sym : :large end # 各サイズで定義されたキーの内容を引いてきて、JOINする処理 def to_hash case hash_group_name when :small to_hash_fieldgroup( :small ) when :medium to_hash_fieldgroup( :small ) .merge(to_hash_fieldgroup( :medium )) when :large to_hash_fieldgroup( :small ) .merge(to_hash_fieldgroup( :medium )) .merge(to_hash_fieldgroup( :large )) end end def fieldgroup_keys (name) self .class.fieldgroup_keys(name) end private # Draper::Decoratorの機能を利用してfieldgroupに定義された各キーを引き、Hashに直す処理 def to_hash_fieldgroup (group_name) Hash [fieldgroup_keys(group_name).map { | k | [k, send(k)] }] end end end 上記のモジュールをデコレーターのクラスにincludeすることで、 .to_hash と fieldgroup を使って、レスポンスをまとめて返すことが可能になります。 to_hash_fieldgroup メソッドが少しトリッキーな手法を使っていますが、 Brand.find(1).decorate オブジェクトに対して、 define_fieldgroup に定義されたkey名と値にアクセスするためのメソッド名が同一なことを利用して値を取得し、Hashにしています。 このように、1ファイルにまとめることでどのような項目のレスポンスが返るかが見やすくなり、レスポンスの統一が容易になります。また、レスポンスの変更もスムーズに行うことができるようになります。 2. パラメーターを統一する APIのパラメーターを統一するというのは、同じ意味を持つパラメーターが違う命名で乱立するのを防ぐことや、受け取るパラメーターのルールをしっかり決めることです。 APIが受け取るパラメーターを統一することで、使う側にメリットがありますし、開発側もデバッグが楽になります。 そこで、パラメーターを統一するために実践しているテクニックを紹介します。 Validatorによりパラメーターの明記を強制する パラメーターが統一されない一番の原因は、現在受け取るパラメーターが何なのかコードを深く読まないとわからないことだと考えています。 ドキュメントを作成することが強制されていたり、ルールがしっかり決まっていればまだいいかもしれませんが、ドキュメントやルールがある場合でも、更新することを怠ってしまうと網羅的にパラメーターを把握できなくなります。 それにより、現状どのようなパラメーターが使われているかを知ることが大変になり、パラメーターにブレが発生します。 例えば昇順、降順を指定するパラメーター名が order と sort でブレてしまったり、それらが受け取る値が ASC DESC を受け取れたはずが小文字の asc desc しか受け取れないなどです。 そこで、全てのエントリーポイントに共通のValidatorを挟み、YAMLに記述のないパラメーターが渡された場合はエラーが出るようにしています。そうすることで、必ずYAMLに記述するという強制力が生まれます。 また、YAMLにパラメーターに関する細かいルールも一緒に記述できるようにしています。 下記が、実際のValidateの設定YAMLの例です。 YAMLはモデルと対になっています。 show: # アクション名 id: # 受け取るparameter type: Int # 型の指定 required: true # 必須 index: id: type: Int required: true initial: type: String limit: max: 100 # 上限値 page: order: within: ['ASC', 'DESC'] # 受け取る値の限定 アクションごとに受け取るパラメーターを列挙し、各パラメーターに対しての条件を記述しています。 受け取れる条件は下記の表のようになっています。 条件 定義 例 required 必須項目 true blank 不可 true format 正規表現でのフォーマット指定 /[<\=>]\s*\$\d+/ is 受け取る値の限定 1 within 受け取る値の限定(複数) ['DESC', 'ASC'] min 最小値 200 max 最大値 2000 min_length 最小長 200 max_length 最大長 2000 こうすることで、細かいルールも含めて現状のパラメーターを知ることができます。 パラメータ管理の手法として、 rails_param のようなgemも存在しています。 しかし、 rails_param だとコントローラーに設定を書かなければならないのでメソッドが長くなってしまうということ、パラメーター設定の記述に対する強制力が弱いことなどから今回は自前の実装にしています。Railsにもともと備わっている permit も同様にコントローラーに直接書かなければならないことや、書くことに強制力がないので今回は使っていません。 以下が実装例です。 # frozen_string_literal: true require ' yaml ' require ' exceptions ' module ValidateParams class InvalidParameterError < InvalidParameter attr_reader :param , :options def initialize (message, param = nil , options = nil ) @param = param @options = options super (message) end end module_function def validate_param (params) # YAMLを取得して設定を元にvalidate_inspectionをかける処理 validate_inspection(params, name, type, options = {}) end def validate_inspection (params, name, type, options = {}) return unless params.include?(name) || options[ :required ] begin param = coerce(params[name], type, options) validate(param, options) return if options[ :no_cast ] params[name] = param rescue InvalidParameterError => e raise InvalidParameterError .new(e.message, param, options) end end # YAMLで設定した型チェック処理 def coerce (param, type, options = {}) return param if param.is_a?(type) || param.nil? if [ Integer , Float , String ].include?(type) coerce_primitive_type(param, type) elsif type == Array delimiter = options[ :delimiter ] || ' , ' coerce_array(param, type, delimiter) elsif [ Date , Time , DateTime ].include?(type) coerce_datetime(param, type) elsif [ TrueClass , FalseClass , :boolean ].include?(type) coerce_boolean(param) else nil end rescue ArgumentError raise InvalidParameterError , " ' #{ param } ' is not a valid #{ type }" end # YAMLで設定した細かいValidate処理 def validate (param, options) options.each do | key , value | case key when :required validate_required(param, value) when :blank validate_blank(param, value) when :format validate_format(param, value) when :is validate_is(param, value) when :within validate_within(param, value) when :min validate_min(param, value) when :max validate_max(param, value) when :min_length validate_min_length(param, value) when :max_length validate_max_length(param, value) end end end --------以下各型チェック処理--------- def coerce_primitive_type (param, type) Kernel .__send__(type.to_s, param) end . . . --------以下各validate処理--------- def validate_required (param, value) raise InvalidParameterError , ' Parameter is required ' if value && param.nil? end . . . end こちらをapplication_controllerの最初に挟むことで、すべてのエントリーポイントにおいてValidatorが動作します。 class ApplicatonController < ActionController :: Base before_action do ValidateParams .validate_param(params) end end このようにしてValidatorを挟むことによりパラメーターを見える化し、次に新しいパラメーターを作る際に同じような役割のパラメーターが以前になかったかYAMLを見るだけで確認することができます。また、各パラメーターが持つルールも一目でわかるようになります。強制力が働いていることによって開発者に依存せずにパラメーターの可視化をすることができます。 3. コーディング規約を守る コーディング規約を定めて特定のプログラミング作法に従うことは、コードの可読性を高め、間違いを減らす効果があります。 しかし、 コーディング規約の自動化 にも述べられているように、規約をただ定めたとしても意識のみで守り続けることは難しいことです。 そこで、チームでコーディング規約を守るためにしたことを紹介します。 LinterとSideCIを導入して修正とレビューの自動化 Linterと SideCI を使って、コーディング規約に従って自動的にコードを修正したり、修正するべき内容をGithubのPullRequest上でチームにシェアできるようにしています。 まず、LinterとSideCIそれぞれについて少し説明します。 Linterとは、静的コードを解析してコードを正しい文法や推奨された書き方を指摘してくれるツールです。 弊社では、下記のLinterを導入しています。 linter名 デフォルトconfig 特徴 rubocop rubocop default config Ruby style guide に基いて作られたLinter reek reek default config 読みづらさや保守しづらいコードを指摘してくれるLinter brakeman brakeman default config セキュリティチェック系のLinter rails best practice rails best practice default config Railsのベストプラクティスを基にしたLinter 各Linterの導入は、詳しく書かれている記事が存在しているのでそちらを参照することをお勧めします。 下記のように、Linterにかけたいスクリプトを指定して、Linterのコマンドを叩くと自動的に修正がかかります。また、機械的に修正できない場合は修正すべき箇所を文章で指摘してくれます。 こちらはrubocopの修正をかけた際の例です。 5行目の指摘は、早期リターンを勧めてくれていますが、機械的に修正すると危険な項目なので指摘のみです。 6行目の指摘は、インデントの指摘を自動で修正してくれています。 このように、自動的にコードを見て問題箇所を指摘したり、修正してくれます。 次にSideCIがどういったサービスかについて少し説明します。 SideCIは、GitHubとLinterを利用したコードレビューを行うサービスです。 GitHubのPullRequest上でSideCIの実行結果を受け取る事ができます。 下記は実際にSideCIから指摘が入った例で、PullRequestに自動的に修正コメントを残してくれます。(最近では、指摘の一部に日本語が導入されました!) その際に、どの種類のLinterから指摘が入ったかと指摘内容を一緒に表示してくれます。 Linterを導入するだけでは開発者がLinterをかけ忘れたり怠ったりした場合、レビュワーが気づいて指摘する手間が発生していましたが、SideCIを使うことでPullRequest上に指摘が自動的に可視化され、直すべきなのに直っていない箇所が一目でわかります。 弊社では、SideCIから来たコメントは全て修正しないとPullRequestをマージしないことになっています。また、緊急対応などの特例で指摘を見逃して欲しい場合はその理由をレビュワーに説明したりコメントをPullRequestに残すルールにしています。 以上がSideCIの説明になります。 SideCI上で自分達が使いたいLinterを選択し、プロジェクトのホームに各Linterの設定ファイルを置くだけでLinterとSideCIを組み合わせて使えるようになります。Linterでコードを自動修正し、修正できてない場合はSideCIを通して指摘してもらうことで規約のチェックを自動化しています。 こうすることで、コーディング規約を守ることを個人的な判断のみに任せず、チーム全体でコーディング規約を守ることができます。 Linterのルールを適度に調節する 上記で、SideCIから来た指摘を全て修正しないとマージしないようにするルールと言いましたが、それを実現するためには各Linterの設定が適切である必要があります。 弊社では、最初チーム内で特定のコーディング規約が存在していなかったため、各Linterのデフォルト設定からスタートして徐々にカスタマイズしていきました。コーディング規約は、チームにとって不要なルールが混ざっていたり、設定がキツすぎるとチームメンバーの負担が大きくなり、不満も募ります。(各Linterのデフォルトの設定はかなりキツめになっています。) なので、実践しながらチームにとって適度なコーディング規約の作成をしていくという方法をとりました。 Linterの設定を適度に調節する際に、 SideCIを使ってLinterの指摘箇所をGithubのコメントを通してシェアする ルールがキツすぎる場合はgithubのissueに議題をあげて議論する 合意が取れる、または1週間以上反論が出ない場合はissueの内容を適用する という方法を繰り返してLinterを調節していきました。 下に実際に上がっていたissueの例です。 このように議論しながら適切な設定を作っていきました。 例1 例2 現在はメンバーの合意が取れている適度なルールになって運用されているため、無理なく無駄なくコーディング規約を守ることに成功しています。 まとめ 以上、APIを長く運用するために仕組み化した下記の3つを紹介しました。 APIのレスポンスを統一するために、 デコレーター と fieldgroup を導入 パラメーターを統一するために全処理共通のValidatorを導入 LinterとSideCIでコーディング規約の徹底 参考になるものがあったら是非試してみてください。 VASILYでは春季インターンの募集を行っています。 APIやバックエンド開発に興味のある方、是非弊社に遊びに来てください!
アバター
わーい!コンテナたのしー!🐾 こんにちは。流行りには積極的に乗っていきたい。インフラエンジニアの光野です。 弊社が運営するファッションサイト IQON では、日々200以上の提携ECサイトから100万のオーダーで商品をクロールしています。 新商品の追加・商品の在庫状況・セールの開催など情報は日々変化するため、弊社において「正しくクロールすること」と「速くクロールすること」は肝心カナメの要素です。 本記事では、特に「速くクロールする」という目的で構築した コンテナベースの新クローラーシステム を紹介いたします。 このクローラーシステムは、最終的に クロール時間67%減 、 維持コスト70%減 という成果が得られました。 キーワード: コンテナ, Docker, Apache Mesos, Marathon, AWS Lambda, Amazon EC2 SpotFleet 問題解決手段の検討 -> コンテナ採用 コンテナを採用するまでの経緯を簡単にまとめます。 なお、途中経過を飛ばして最終的な構成から見たい方は こちら からご覧ください。 刷新のモチベーション2016 昨年末の時点で、IQONクローラーは2つの大きな問題を抱えていました。 季節ごとの新商品追加時期など、クロール対象の急激な変化に追従するのが難しい クロール中とそれ以外で負荷の差が激しく、計算資源の無駄が多い 2の影響は弊社で完結しますが、1はユーザ体験の損失につながります *1 。 これを根本的に解決することをモチベーションとして次の3つを満たすべく刷新に着手します。 クロール量の急激な変化に対応してワーカー数を簡単に変更できる 維持コストを抑える(金銭的な削減) 保守コストを抑える(人の手間削減) 改修箇所の選定 幸いにも、どの部分を改修すれば良いかは着手時点で明確でした。 クローラーの実行環境 です。 昨年時点でのアプリケーションとその実行環境は次の通りです。 アプリケーション2016 昨年末時点のIQONのクローラーはver 4.5です。 ver 1.0: PHPで書かれたクローラー 2.0: Rubyで書き換え 3.0: resqueベースの分散処理に移行 4.0: sidekiqベースの分散処理に移行 + クローラー作成ツールの導入 4.5: shoryukenベースの分散処理に移行 ver 4.5は4.0のマイナーアップデート版で、大まかな構成は こちらの資料 にあるものと変わっていません。 クロール処理をステップごとに分解 ステップ毎にそれを担当するプログラム(ワーカー)を準備 ワーカーが並列分散処理を行う という構造です。各ワーカーはそのプロセス毎に完結しており、自分以外のプロセスを意識する必要はありません。 実行環境2016 昨年末時点のIQONクローラー実行環境はとてもシンプルです。 クローラー専用のインスタンスを用意 前述のワーカーをsupervisorでデーモン化 インスタンスのメトリクスを元に、インスタンス毎にワーカーのプロセス数を決定 メッセージ・キューのdequeue速度を元にインスタンスを必要なだけ並べる という構成です。AutoScalingはありません。 コンテナの採用 先の通り、昨年末時点のIQONクローラーはそのアプリケーションに比べて実行環境に柔軟性がありませんでした。 せっかくの分散処理ワーカーもインスタンスに縛られてはその力を発揮しきれません。宝の持ち腐れです。 これを解決するためにIQONクローラーver5.0では実行環境の改善ということを目的にDockerコンテナの導入を行いました。 今までの仕組みの延長線上で、AutoScalingやデプロイの仕組みを整備するという選択肢もありましたが、 かねてより「複数種類のワーカーが多数動く」という状況が「PaaSの上で色々なアプリケーションが動く」という状況に近いと感じていたこともあり、 PaaSを効率化するために生まれた Docker とは相性がいいに違いないという判断でコンテナ化を採用します。 コンテナ採用 -> オーケストレーションツール決定 コンテナの採用を決定後、まず着手したのがオーケストレーションツールの検証です。 オーケストレーションツール Dockerコンテナを本番運用する上で、非常に重要なのがコンテナオーケストレーションツールです(以後、単にオーケストレーションツール)。 Dockerのライフサイクルは1コンテナ単位ですが、本番環境では複数のコンテナを複数のホストにまたがって運用します。 また、弊社ではコンテナイメージの再利用性を高めるために 1コンテナ1プロセス をポリシーにしており、コンテナは複数を論理的に接続して扱うことが前提として求められます *2 。 このようなコンテナ同士を論理的に接続したり、インスタンス群にコンテナを配置したりといったコンテナの組織化(オーケストレーション)を支援してくれるのがオーケストレーションツールです。 オーケストレーションツールの比較検討 今回の刷新では3つのオーケストレーションツール *3 を比較しました。 Kubernetes Marathon Amazon ECS 思い描く実行環境に必要な項目を洗い出し、比較した表が次のものです。 評価項目 Kuberenes Marathon Amazon ECS ELB/ALBとの連携 ◯ x ◯ cron jobのような時間指定でのタスク定義 ◯ x x コンテナのグルーピング ◯ △ (v1.4〜、webui未対応) ◯ マスタのHA構成 ◯ ◯ ◯ (マネージド) Web API ◯ ◯ ◯ Web UI ◯ ◯ ◯ ドキュメントが充実している △ ◯ ◯ クラスタの構築が容易 x ◯ ◯ ホストインスタンスのスケーリングにクラスタが追従する ? ◯ ◯ コンテナデプロイのタイミングでソースコードを配布可能 x ◯ x x: 不可能 / 難しい △: 可能だが制約あり ◯: 可能 このように評価をした結果、作りたい実行環境にとって致命的な x が無く、ツールとしてのアプローチ(後述)がクローラーに最も適している Marathon を採用するに至りました。 補足: ホストインスタンスのスケーリングにクラスタが追従する EC2インスタンスがAutoScalingで増減した際に、それらがクラスタの一部として自動的に認められるかどうかを検討した項目です。 Kubernetesは検証を途中で中断してしまったので ? Marathonはエージェントがマスタノードと通信可能であればクラスタに参加するため ◯ ECSはサービスとしてAutoScalingの機能を提供しているので ◯ としています。 Marathon 今回の刷新で採用したMarathonはそれ単体で動くものではありません。Marathonを使うために、まずApache Mesosを理解する必要があります。 Apache Mesos Apache Mesos は、クラスタを構成するインスタンスのCPUやメモリ、GPUやストレージを1つのリソースプールとして扱う分散システムカーネルです。 Mesosはタスクを受け取ると、リソースに空きがあるインスタンスを見つけてそこで実行します *4 。 利用者からみると、クラスタをあたかも1台の巨大なインスタンスがあるように扱うことが可能です。 Marathonの検証をした時点ではあまり意識していませんでしたが、Mesosの「複数のインスタンスを1つの巨大なインスタンスとして扱う」というアプローチはクローラーと大変相性が良く、積極的にMarathonを採用する後押しにもなっています。 Marathonの役割 Apache Mesosで実行するタスクのライフサイクルは実行から終了コードを受け取るまでです。終了した状態が何であれ、Mesosはそれ以上何もしません。 この動作はWebサービスのようにずっと動き続けて欲しいタスクにとっては不便です。そこでMarathonが登場します。 Marathonは Apache Mesos Framework と呼ばれるもの1つで、Mesosの仕組みの上で タスクに恒常性を与えます 。 アプリケーションのコンテナを4つ、というタスクを投げるとその通りにコンテナを用意し、 仮にコンテナが例外で落ちると要求数を満たすように自動で次のコンテナを用意します。 次のJSONは、実際にコンテナを4つ起動するAPIリクエストの内容です。 メモリ256M、CPUを20% *5 割り当てるコンテナを4つ起動します。 { " parse ": { " id ": " /iqon/crawler/parse ", " container ": { " type ": " DOCKER ", " docker ": { " image ": xxxx } } , " cpus ": 0.2 , " mem ": 256 , " instances ": " 4 " } } fetch機能 Marathonの機能は多岐にわたりますが、ここでは最終的な構成に大きな影響を与えた fetch 機能について紹介いたします。 これのお陰で、比較表で唯一Marathonだけが コンテナデプロイのタイミングでソースコードを配布可能 で ◯ になっています。 fetch はタスクの起動前に、指定したファイルを取得するというMarathonの機能 *6 です。 コンテナをデプロイする際に悩ましいのが、最新のソースコードをどう取得するか、という問題です。 もちろん、コンテナイメージをビルドする際にソースコードを含めることもできますが、 次の問題からコンテナイメージにソースコードを含めることは避けるべきと考えています。 アプリケーションと環境の分離ができない コンテナレジストリに大量のタグが登録される デプロイ時にコンテナイメージのキャッシュが効かない fetch はこの問題を解決します。次のJSONは fetch を利用した場合の例です。 { " parse ": { " id ": " /iqon/crawler/parse ", " container ": { " type ": " DOCKER ", " docker ": { " image ": xxxx } , " volumes ": [ { " containerPath ": " /var/app ", " hostPath ": " ./iqon_crawler ", " mode ": " RW " } ] } , " cpus ": 0.2 , " mem ": 256 , " instances ": " 4 ", " fetch ": [ { " uri ": " https:// xxxx /iqon_crawler.tar.gz ", " executable ": false , " extract ": true , " cache ": false } ] } } fetch と volume の項目は次のように処理されます。 Mesosによる docker run の前に fetch でIQON_crawler.tar.gzが取得/解凍される docker run の --volume で、コンテナに対しマウントされる 検証時に確認した範囲では、同様の機能はMarathonにしかありませんでした。これもまたMarathonの採用を決めた大きな要素です。 オーケストレーションツール決定 -> クラスタ完成 全体構成 オーケストレーションツール選定を経て、IQONクローラーver5.0は最終的に次の形になりました。 ここからは、この実行環境を4つに分けてご紹介します。 アプリケーションのデプロイ まず、アプリケーションのデプロイ部分です。 DockerRegistryには quay.io 、CIには CircleCI を利用しています。 CircleCIがmasterブランチの内容をS3に保存 Marathonへタスクの更新をリクエスト MarathonがMesosへリクエスト MesosがS3から最新のソースコードをfetch Mesosがquay.ioからコンテナイメージをpull Mesosがクラスタのどこかで docker run という流れでデプロイが行われます。 クローラーのリポジトリにはYAMLで書かれたデプロイルールとデプロイ用のスクリプトが用意されており、 CircleCIはそれを使ってMarathonへリクエストを送っています。以下は実際のYAMLファイルから抜粋したものです。 :common : :env : &env :CRAWLER_ENV : production :RACK_ENV : production # ... # private docker reposからpullするための情報 :credentials : &credentials :uri : xxxx :executable : false :extract : true :cache : false :source : &source :uri : https:// xxxx /crawler/app/<%= ENV['CIRCLE_SHA1'] %>.tar.gz :executable : false :extract : true :cache : false :localtime : &localtime :containerPath : /etc/localtime :hostPath : /etc/localtime :mode : RO :network : &network :ipAddress : :networkName : mesos_slave_host_nw # networkがUSERのときはportsを空配列で明示的に宣言しておく必要がある :ports : [] :parse : :id : /iqon/crawler/parse :dependencies : - /iqon/crawler/td-agent :args : - ./scripts/start_shoryuken_worker.rb - --log-level=warn - --worker=parse :env : <<: *env :container : :docker : :image : <%= ENV['DOCKER_APP_IMAGE'] %> :network : USER :parameters : - :key : net-alias :value : parse :volumes : - <<: *localtime - :containerPath : /var/app :hostPath : ./iqon_crawler :mode : RW :type : DOCKER :fetch : - <<: *credentials - <<: *source <<: *network デプロイの4と5で取得しているのは、YAML中に fetch と image で指定されている要素です。 なお、デプロイについては2つの工夫を盛り込みました。 デプロイルールの定義はYAMLで行う YAMLはERBでパースする MarathonへのアクセスはJSONですが、JSONで長い定義を行うのはいささか苦痛です。 そこで、一度YAMLで記述してそれをJSONに変換する形を取ることで、可能な限りDRYに管理できるようにしています。 コメントも書けるため、補足も随時行います。 また、一度ERBでパースすることで環境変数を使えるようにしています。 これでデプロイ毎に変わる部分を気にすること無く fetch で指定することが可能になりました。 コンテナ数のコントロール アプリケーションのデプロイができたので、次はそれをコントロールします。 適当な数のコンテナをデプロイして終わり、であれば良いのですが求めるものは状況に応じてコンテナ数(ワーカー数)が変わる実行環境です。 そこで、Lambda Functionを用いて、CloudWatchとMarathonを繋いでいます。 まず、次のようなYAMLを定義します。 :cloudwatch : :alarm : :iqon-crawler-production-parse-visible-high : :rule : # CloudWatch Alarmに登録する内容 :metric_name : 'ApproximateNumberOfMessagesVisible' :namespace : 'AWS/SQS' :statistic : 'Average' # ... :action : # ruleを受けてLambda Functionがmarathonをどうするか :alarm : :method : 'PATCH' :path : '/v2/apps/' :body : '[{"id": "/iqon/crawler/parse","instances": 32}]' :ok : # ... :insufficient_data_actions : # ... そして、このYAMLをアラーム用のデプロイスクリプトで読み込み、次のように展開します。 YAML中のrule要素を使ってCloudWatchのアラームを登録 YAML中のaction要素を事前に用意しておいたLambda Functionのテンプレートに埋め込む テンプレートをAWS Lambdaに登録 ここでの工夫は関連する処理を1つのファイルで近くに置くということです。 トリガーとアクションを分離しない アプリケーション側でコンテナ配置ルールを管理 トリガーとアクションを並べ、CloudWatchの更新とLambdaへの反映までを一括で行えるようにすることで変更のし忘れを防止しています。 DaemonSetの更新 3つ目は特別なコンテナの管理です。ここが今回もっとも苦労した部分です。 このクローラー実行環境を支える mesos agent node は、Amazon EC2 SpotFleetで宣言されており、 インスタンス自体もまた負荷に応じて増減するようになっています。 SpotFleetが作るインスタンスは起動と同時にユーザスクリプトで systemctl restart mesos-slave が宣言されているため、起動と同時にMesosクラスタの一員となります。 Mesosクラスタ的にはこれで終わりなのですが、この時 AutoScalingに合わせて、td-agentコンテナの数を更新する いう非常に重要な処理を実行しています。 オーケストレーションツール比較表にありますが、Marathonには複数のコンテナを論理的に繋ぐ機能がありません。 Martahon 1.4系で待望のPod機能 *7 が実装されたものの、2017/03/17時点で最新のMarathon 1.4.1では宣言されたPodがWeb UIから見えないという状況のため、導入はまだ時期尚早です。 そして、このPodが無いことで問題になったのがtd-agentコンテナです。 Podであれば必ず同じホスト上にコンテナが配置されますが、Podが無い場合アプリケーションコンテナとtd-agentコンテナは別ホストにデプロイされる可能性があります。 画像右の「偏った状態」になると、右側のインスタンスで動いているコンテナはtd-agentと通信できません。この場合、そのコンテナのログは全て捨てられてしまいます。 これの回避のため、必ず各ホストに1つのtd-agentコンテナが置かれる状況を作る仕組みDaemonSet *8 を動かしています。 DaemonSet実現のために、次の要素を組み合わせます。 Docker Network User Defined Network net-alias Marathon constraints dependencies CloudWatch Events Lambda Function まずDockerのUser Defined Network(以下、UDN)を作成します。 docker network create --subnet=172.20.0.0/16 mesos_slave_host_nw 次にtd-agentの宣言で net-alias を指定します。 # デプロイスクリプト用YAMLのtd-agentコンテナの宣言(抜粋) :td_agent : :id : /iqon/crawler/td-agent :container : :docker : :image : xxxx :network : USER :parameters : - :key : net-alias :value : tdagent これで同一ホストにデプロイされたコンテナからは tdagent という名前解決が可能になります。 UDNを作成するのはこの net-alias がUDN上でしか使えないためです。 次に constraints を宣言し、デプロイに制約を加えます。 :td_agent : :id : /iqon/crawler/td-agent :container : :docker : :image : xxxx :network : USER :parameters : - :key : net-alias :value : tdagent :constraints : - - hostname - UNIQUE Maratahon(Mesos)はタスクを余裕のあるインスタンスで実行しますが、実行先インスタンスに幾つかの制約を持たせる可能です。 hostname , UNIQUE は、そのタスクを各ホストで高々1つまでしか起動できなくするという制約です。 次にCloudWatch Alarmを登録する仕組みを応用して、CloudWatch Eventを登録します。 :autoscaling : :daemonize-container : :rule : :schedule_expression : "cron(*/2 * * * ? *)" :action : :method : 'PATCH' :path : '/v2/apps/' :targets : [ 'iqon/crawler/td-agent' ] 2分置きにイベントを発生させ、対応するLambda Functionを発火させます。 Lambda FunctionはMesosクラスタを構成するインスタンス数を取得し、actionに従ってMarathonにコンテナ数変更のリクエストを投げます。 [ { " id ": " iqon/crawler/td-agent ", " instances ": " Mesosクラスタのインスタンス数 " } ] 最後に、アプリケーションコンテナへ dependencies と env でtd-agentを指定してやれば完了です。 :parse : :id : /iqon/crawler/parse :dependencies : - /iqon/crawler/td-agent :env : :TD_AGENT_HOST : tdagent # アプリケーション側で環境変数を読む dependencies が宣言されると、そのタスクは dependencies で宣言されたタスクが実行されるまで自身の実行を待ちます。 ここまでの流れを整理します。 td-agentコンテナはUDNのnet-aliesを利用し、tdagentという名前で名前解決が可能になる td-agentコンテナは constraints に従って1ホスト1コンテナが保証されている アプリケーションコンテナはtd-agentコンテナが起動するまで起動を待つ 起動後はtdagentという名前でtd-agentコンテナを探す Lambda Functionによる変更で、ホスト数分だけtd-agentコンテナが用意されるため、 constraints と合わせて各ホストには必ずtd-agentが存在する ここの説明ではtd-agentに限っていますが、必要に応じて別のタスクも管理可能なようになっています。 コンテナイメージの更新 最後はコンテナイメージの世代更新についてです。 IQONクローラーのリポジトリでは、masterブランチ以外にdockerbuildという特別なブランチを用意しています。 circle.ymlからdockerbuildに関する部分を抜粋したものが次のYAMLです。 machine : environment : DOCKER_APP_IMAGE : xxxx CONTAINER_NAME : yyyy services : - docker dependencies : pre : - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS quay.io - docker pull ${DOCKER_APP_IMAGE}; true override : - if [ $ { CIRCLE_BRANCH } = 'dockerbuild' ] ; then docker build -t ${CONTAINER_NAME}:${CIRCLE_SHA1} .; else true ; fi test : # ... deployment : registry : branch : "dockerbuild" commands : - docker push ${CONTAINER_NAME}:${CIRCLE_SHA1} dockerbuildブランチ以外では、 DOCKER_APP_IMAGE で宣言されたコンテナイメージをpullして利用します。 一方、dockerbuildブランチではpullされたコンテナイメージだけでなく、リポジトリのDockerfileを使いコンテナイメージを作成します。 テストを通過すればコンテナイメージがquay.ioにpushされるため、タイミングをみて DOCKER_APP_IMAGE を更新しmasterへマージします。 知らぬ間にコンテナが入れ替わって不具合発生というリスクを最小限に抑えるため、ここでも全てをアプリケーションのリポジトリにまとめる形式を採用しています。 刷新の効果 さて、少なくない工数をかけて行ったIQONクローラーver5.0(コンテナ化)ですが、その効果は目覚ましいものがありました。 効果 クロール時間 67%減 維持コスト 70%減 保守コスト Web UIで変更。設定が固まったらYAMLに反映 柔軟にワーカー数を変更できるようになったことで、 クロール開始直後はHTMLをダウンロードするワーカーを増やす クロールの後半ではHTMLをパースするワーカーを増やす ということが可能になり、その結果クロール時間は刷新前の3分の1になりました(67%減)。 また、インスタンスの維持コストも大幅に削減できました。瞬間的にはこれまでの3倍近い計算資源を用意して尚、トータルでは70%の維持コスト削減となっています。 これはSpotFleetを採用した影響が大きいですが、コンテナのメトリクスとしてワーカーが必要とするCPUとメモリが可視化されたため、集約度を上げられたことも要因の1つです。 3つめの保守コストについては言うまでもありません。 インスタンス起動・Chef実行・デプロイ・ワーカー起動という作業がWeb UI1つで誰でも行えるようになりました。 クラスタの運用はありますが、その手間を差し引いても極めて大きな保守コスト削減ができています。 まとめ この記事では、Docker / Apache Mesos / Marathonを使った新クローラーシステムの紹介をいたしました。 長々と読んでいただき誠にありがとうございます。少しでも得るものがありましたら何よりです。 今回のクローラー刷新では、アプリケーションを柔軟に実行できる環境の構築を目指して取り組み、 クロール実行時間の短縮と維持コスト、保守コストの大幅な削減とおよそ最高の結果を得ることができました。 またこの記事では、話の都合上クラスタ構築中の試行錯誤やコンテナ移行後の開発環境に関しての話題を丸々省略しております。 そちらは別記事としてまとめるつもりですが、もし興味を持っていただけるようであれば、はてブやSNSでリアクションをいただければ幸いです。まとめの励みになります。 なお、動き出したばかりのシステムということもあり、インスタンス数やワーカー数もまだまだ最適化できる余地が残っています。 今後はより安定して無駄がないものになるよう、運用しつつ工夫を続けるつもりです。 VASILYでは、そんな現状に満足せず技術的チャレンジを好むエンジニアを募集しております。興味を持っていただけた方は是非下のリンクからご応募下さい。 *1 : 実際に、情報の反映が遅れたことが原因で「IQONとECサイトで掲載価格が違う」というお問い合わせをいただくことがありました *2 : クローラーであればアプリケーション+td-agentといった組み合わせ *3 : その他のオーケストレーションツールとしてDocker Swarm, HashiCorp Nomadがありますが、ドキュメントを読んでみてしっくりこなかったため検討対象から外しています *4 : 当然、クラスタに所属するインスタンスはタスクの実行環境を持っておく必要があります *5 : cpusの設定はDockerの cpu-sharesオプション に反映されます。そのためインスタンスに余裕さえあれば必要に応じて20%以上が割り当てられることもあります *6 : 正確にはApache Mesosが提供する fetcher という機能をMarathonが呼んでいます *7 : KubernetesにはPodというコンテナのグルーピング機能がありPodに所属するは同一ホスト上にあることが保証されます。MarathonのPodも同様です。 *8 : Kubernetesには、各ホストで必ず動いていて欲しいコンテナを宣言するDaemonSetという仕組みがあり、それに倣いました
アバター
iOSエンジニアの庄司 ( @WorldDownTown ) です。 最近、業務で新しいiOSアプリを立て続けにいくつか開発する機会に恵まれました。 そんな中、いくつもアプリを使っていると、どのアプリでもよく使う処理があぶり出されてきます。 そういう処理はSwiftのExtensionとして別ファイルに書き出し、他のアプリへも切り出しやすいように個別のFrameworkにして管理しています。 Frameworkの管理については過去の こちらの記事 を参考にしてみてください。 今記事では、最近の開発でよく使ったExtension集をご紹介します。 Swift標準ライブラリ Date private let formatter : DateFormatter = { let formatter : DateFormatter = DateFormatter() formatter.timeZone = NSTimeZone.system formatter.locale = Locale(identifier : "en_US_POSIX" ) formatter.calendar = Calendar(identifier : .gregorian) return formatter }() public extension Date { // Date→String func string (format : String = "yyyy-MM-dd'T'HH:mm:ssZ" ) -> String { formatter.dateFormat = format return formatter.string(from : self ) } // String → Date init ?(dateString : String , dateFormat : String = "yyyy-MM-dd'T'HH:mm:ssZ" ) { formatter.dateFormat = dateFormat guard let date = formatter.date(from : dateString ) else { return nil } self = date } } Date().string(format : "yyyy/MM/dd" ) // 2017/02/26 Date(dateString : "2016-02-26T10:17:30Z" ) // Date 同じモデルクラスを使って日付を表示する場合でも、リストページでは日付だけ、詳細ページでは時間も表示したいというときに便利です。 またイニシャライザの方は、ユーザーが入力した日付文字列から Date インスタンスを作ることができます。 Dictionary // Dictionary同士を`+`演算子でマージできるようにする public func +< K, V > (lhs : [K: V] , rhs : [K: V] ) -> [K: V] { var lhs = lhs for (key, value) in rhs { lhs[key] = value } return lhs } [ "key1" : 0 ] + [ "key1" : 1 , "key2" : 2 ] // ["key2": 2, "key1": 1] APIリクエスト時の動的パラメータと固定のパラメータのDictionaryをマージするときなどに使います。 Int private let formatter : NumberFormatter = NumberFormatter() public extension Int { private func formattedString (style : NumberFormatter.Style , localeIdentifier : String ) -> String { formatter.numberStyle = style formatter.locale = Locale(identifier : localeIdentifier ) return formatter.string(from : self as NSNumber) ?? "" } // カンマ区切りString var formattedJPString : String { return formattedString(style : .decimal, localeIdentifier : "ja_JP" ) } // 日本円表記のString var JPYString : String { return formattedString(style : .currency, localeIdentifier : "ja_JP" ) } // USドル表記のString var USDString : String { return formattedString(style : .currency, localeIdentifier : "en_US" ) } } let million : Int = 1_000_000 million.formattedJPString // 1,000,000 million.JPYString // ¥1,000,000 million.USDString // $1,000,000.00 どのアプリも価格を表示するところは多数あると思いますが、計算型プロパティ一つで面倒なカンマ区切り処理を書けるので、コードがかなりスッキリします。 UIKit UIColor public extension UIColor { // RGBのイニシャライザ public convenience init (rgb : UInt , alpha : CGFloat = 1.0 ) { let red : CGFloat = CGFloat((rgb & 0xff0000 ) >> 16 ) / 255.0 let green : CGFloat = CGFloat((rgb & 0x00ff00 ) >> 8 ) / 255.0 let blue : CGFloat = CGFloat(rgb & 0x0000ff ) / 255.0 self . init (red : red , green : green , blue : blue , alpha : alpha ) } public struct iq { // プロジェクトに合わせた名前で良い public static let pink : UIColor = UIColor(rgb : 0xfa4664 ) public static let textBlack : UIColor = UIColor(rgb : 0x333333 ) } } UIColor.iq.pink // #fa4664 UIColor.iq.textBlack // #333333 一つのプロジェクトで使う色数は、数え切れる程度になることが多いので、 UIColor のExtensionに名前を付けて管理します。 Extensionの中でstructを定義して、その中に色をまとめることで、 UIColor の本来の名前空間を汚してしまうことがなくなりますし、開発者が拡張しているというのがコードの読み手にも伝わりやすくなります。 UIView public extension UIView { // 子Viewを親Viewのサイズいっぱいに表示するための制約を設定する func addConstraints ( for childView : UIView , insets : UIEdgeInsets = .zero) { childView.translatesAutoresizingMaskIntoConstraints = false topAnchor.constraint(equalTo : childView.topAnchor , constant : insets.top ).isActive = true bottomAnchor.constraint(equalTo : childView.bottomAnchor , constant : insets.bottom ).isActive = true leadingAnchor.constraint(equalTo : childView.leadingAnchor , constant : insets.left ).isActive = true trailingAnchor.constraint(equalTo : childView.trailingAnchor , constant : insets.right ).isActive = true } } view.addSubview(childView) view.addConstraints( for : childView ) let insets : UIEdgeInsets = UIEdgeInsets(top : 10.0 , left : 10.0 , bottom : 10.0 , right : 10.0 ) view.addConstraints( for : childView , insets : insets ) Interface Builderではなく、コードでviewを addSubview するとき、親Viewと同じサイズにしたいときに使います。 デフォルト引数は省略可能ですが、 UIEdgeInsets でマージンを指定することもできます。 UIViewController public extension UIViewController { // ViewControllerのファクトリーメソッド static func create () -> Self { let name : String = " \(type(of: self) )" .components(separatedBy : "." ).first ! return instantiate(storyboardName : name ) } private static func instantiate <T> (storyboardName : String ) -> T { let storyboard : UIStoryboard = UIStoryboard(name : storyboardName , bundle : nil ) let vc : UIViewController ? = storyboard.instantiateInitialViewController() return vc as ! T } } let vc = SomeViewController.create() VASILYのiOS開発では、 1ViewController / 1Storyboard でViewControllerを管理し、ViewControllerのクラス名とStoryboardのファイル名を揃えるルールで運用しています。 そのため、文字列を使わずにViewControllerを初期化することができます。 サードパーティライブラリ SVProgressHUD import SVProgressHUD public extension SVProgressHUD { public struct iq { // プロジェクトに合わせた名前で良い // プロジェクト固有の初期設定 public static func setup () { SVProgressHUD.setDefaultStyle(.custom) SVProgressHUD.setFont(UIFont.boldSystemFont(ofSize : 14.0 )) SVProgressHUD.setForegroundColor(UIColor.iq.pink) SVProgressHUD.setBackgroundColor(UIColor.white.withAlphaComponent( 0.9 )) SVProgressHUD.setMinimumDismissTimeInterval( 2.0 ) } public static func show (maskType : SVProgressHUDMaskType = .none) { SVProgressHUD.setDefaultMaskType(maskType) SVProgressHUD.show() } } } サードパーティライブラリは、アプリ全体で設定を有効にするために、AppDelegateに処理を書くことがよくあります。 複数のライブラリを使っていると、 application(_:didFinishLaunchingWithOptions:) -> Bool が 各ライブラリの初期設定処理で膨れ上がってしまいます。 上記のExtensionは、複数行の処理をExtensionにまとめることによってこの問題を回避できるようになります。 ここでも、サードパーティライブラリの名前空間を汚さないように処理をstructの中に分けて書いています。 // AppDelegate func application (_ application : UIApplication , didFinishLaunchingWithOptions options : [Hashale: Any] ) -> Bool { SVProgressHUD.iq.setup() ... return true } また、現在のSVProgressHUDはMaskTypeがグローバルに設定されてしまうため、MaskTypeを変更したいときは、 setDefaultMaskType(_:) を実行してから表示する必要があります。 このExtensionでは2行必要な処理を一つのメソッドでMaskTypeを引数に取れるように変更しています。 小さな変更ですが、SVProgressHUDはどのアプリでも頻繁に登場するため、冗長なコードを減らすことができます。 // 従来 SVProgressHUD.setDefaultMaskType(.clear) SVProgressHUD.show() SVProgressHUD.setDefaultMaskType(.none) SVProgressHUD.show() // Extension SVProgressHUD.iq.show(maskType : .clear) SVProgressHUD.iq.show() // デフォルト値:.none まとめ iOSアプリ開発でよくある処理のExtensionの数々を紹介しました。 これらの処理が一つのFrameworkにまとまっていると、新しいアプリを作る時にも共通処理を持ってくることができます。 新規開発時に試してみてください。 現在VASILYでは、IQON以外にも新規でいくつかiOS/Androidアプリを開発しています。 ゼロからアプリを一緒に開発してくれるiOSエンジニアを募集しています。興味がある方は以下のリンクをご覧ください。
アバター
こんにちは。 iOSエンジニアの遠藤です。 最近ユーザー詳細ページのリニューアルをすることになり、UIStackViewで実装しました。 UIStackViewを使ってとてもシンプルに実装できたので、UIStackViewで詳細ページを実装するメリットと実装について紹介します。 はじめに このような表示コンテンツの多い詳細ページを実装する際に、みなさんは何を使用していますか? UIStackViewはiOS 9から追加されたクラスですが、 まだUITableViewやUICollectionViewで詳細ページを実装されている人も多いのではないでしょうか? IQONも詳細ページをUITableViewやUICollectionViewを使って実装してきました。 しかし、UITableView、UICollectionViewは同じモジュールの繰り返しを表示するのには適していますが、詳細ページなどの違うモジュールを表示するのには、ビジネスロジックのコードなどでDataSourceがとても複雑になってしまいました。 UIStackViewを使うことで、複雑になりがちな詳細ページがとてもシンプルに実装することができました。 詳細ページを実装する際に、少しでも参考になれば幸いです。 UIStakcViewを使って詳細ページを実装する際のメリット・デメリット UIStackViewの基本的な使い方は下記の記事が分かりやすくておすすめです。 遅ればせながら UIStackView 入門 - Qiita メリット データの出し分けが簡単 モジュールのサイズ計算をしなくていい モジュール間のスペースを気にしなくていい UITableViewやUICollectionViewを使用して実装していたときに、一番大変なのはDataSourceの実装ではないでしょうか? class DetailViewController : UICollectionViewController { private func cellIdentifier (_ indexPath : IndexPath ) -> String { switch indexPath.section { case 0 : switch indexPath.item { case 0 : return UserDetailHeaderCell.cellIdentifier() case 1 : ・・・ } } override func numberOfSections ( in collectionView : UICollectionView ) -> Int { return (viewModel.user != nil ) ? 10 : 0 } override func collectionView (_ collectionView : UICollectionView , numberOfItemsInSection section : Int ) -> Int { guard let section = UserDetailSection(rawValue : section ) else { return 0 } switch section { case .userName : return 2 case .userDescription : return viewModel.shouldShowDescription ? 1 : 0 case .userSets : return viewModel.sets.count ・・・ } } override func collectionView (_ collectionView : UICollectionView , cellForItemAt indexPath : IndexPath ) -> UICollectionViewCell { let cellIdentifier = self .cellIdentifier(indexPath) let cell = collectionView.dequeueReusableCell(withReuseIdentifier : cellIdentifier , for : indexPath ) return cell } func collectionView (_ collectionView : UICollectionView , layout collectionViewLayout : UICollectionViewLayout , sizeForItemAt indexPath : IndexPath ) -> CGSize { if let cachedCellSize = cachedCellSize[indexPath.item] { return cachedCellSize } // cellのサイズ計算 let size : CGFloat = ・・・ let key = " \(indexPath.section) - \(indexPath.item) " cachedCellSize[key] = size return size } } 上記のコードのように、データがある場合とない場合とで表示するモジュール数を変えたりモジュールごとにCellを出し分けたりとコード量が多くなりがちです。 また、Cellのサイズ計算も楽ではありません。 レイアウトを元にCellのサイズを計算したり、スクロールパフォーマンスを悪くしないためにCellのサイズをキャッシュしたりするので複雑になってしまいます。 しかし、UIStackViewを使えばDataSourceもCellのサイズ計算も必要ありません。 UIStackViewはサブビューをhiddenにすることで、非表示にできるのでデータの有り無しの制御が簡単です。 また、UIStackViewはサブビューの intrinsicContentSize をもとにしたサイズで描画されるのでAutoLayoutをきちんと設定すれば、サイズ計算のためのコードを書く必要がありません。 デメリット iOS 9以降でしか使用できない 各モジュール間ごとのスペースを自由に設定することはできない iOS 9以降の機能なので、iOS 8をサポートしたい場合には、バージョンで出し分けの処理を書く必要があることが一番のデメリットだと思います。 また、UIStackViewではモジュール間のスペースを自由に設定することはできないため、デザインでモジュール間のスペースが異なる場合は実装が大変になると思います。 また、デザイナーもモジュールの表示、非表示すべてのパターンを網羅することは大変です。 そこはデザイナーに相談してすべてのモジュール間を等間隔にしてもらうことをおすすめします。 実装について 今回、詳細ページを実装する上で考慮したのはモジュールの出し分けをViewController側に書かないようにすることです。 ViewControllerにモジュールの出し分けを書かないようにすることで、ViewControllerの肥大化を防ぐことができます。また、ViewControllerはViewModelから受け取ったModelをViewに渡すだけにすることでコードがシンプルになります。 実装は以下のとおりです。 // ViewModel.swift class ViewModel { var modelA : Model ? var modelB : Model ? func request () { // APIリクエストして返ってきたレスポンスをもとに各Modelオブジェクトを更新 } } // ViewController.swift class ViewController : UIViewController { private var viewModel : ViewModel = ViewModel() @IBOutlet private weak var viewA : CustomView ! @IBOutlet private weak var viewB : CustomView ! override func viewDidLoad () { super .viewDidLoad() viewModel.request { [weak self ] in self ?.setupLayout() } } private func setupLayout () { viewA.model = viewModel.modelA viewB.model = viewModel.modelB } } // ContentView.swift class CustomView : UIView { var model : Model ? { didSet { setupLayout() } } private func setupLayout () { guard let model = model else { return } isHidden = (model == nil ) } } UITableViewやUICollectionViewで実装するよりもはるかにシンプルになったと思います。 この実装方法だと、表示するモジュールが多くなった場合でもViewControllerが肥大化しなくて済みます。 また、表示しているモジュールの並び替えがとても簡単で、UIStackViewのサブビューの並びを変えるだけで良いのです! まとめ UIStackViewを使った詳細ページの実装について紹介しました。 表示要素が多い詳細ページはUITableViewやUICollectionViewを使用して実装するとViewControllerが肥大化してしまいがちでです。 UIStackViewを使用することでViewControllerの実装が複雑にならず肥大化せずに実装でき、メンテナンスもしやすくなるので詳細ページをUIStackViewで実装するのはおすすめです! ぜひ、詳細ページを実装する際に試してみてください。 さいごに VASILYではデザインの実装にこだわるエンジニアを募集しています! 少しでもご興味のあるかたは以下のリンク先をご確認ください。
アバター
Androidエンジニアの @nissiy です。Androidが発表されてからもうすぐ10年になろうとしています。長いですね。 実は Android版IQON 、今年の4月でリリースしてから丸5年を迎えます。ここまで長くサービスを続けられて、かつ3年連続でベストアプリをいただけたのは、使ってくれているユーザーの方々のおかげであると日々感謝しています。 この5年で様々な追加機能の開発を行ってきました。新機能を1つ追加する度に、古い機能を1つ削除することを徹底して開発を進めてきたものの、長く開発を続けているのでそれなりに巨大なアプリになっています。 今回はAndroid版IQONを長く開発し続けるためにチーム内で徹底しているルールをいくつか紹介したいと思います。 当たり前な話ばかりですが、大きくOSのアップデートを繰り返すAndroidのアプリ開発に取っては大事な話ばかりですので、少しでも参考にしていただけると嬉しいです。 1. 使用しなくなったクラスやリソースファイルはすぐに消す 1つのアプリを長く開発していると節目節目に大きくリファクタリングが行われると思います。そうすると使用しなくなったクラスやリソースファイルが現れますが、そのようなファイルはすぐに消すことを徹底しています。 一度不要になったものを再度使用することなんてほとんどありませんし、仮に必要になったらコミットログから引っ張り出せばいいだけなので容赦なく削除しましょう。 アプリサイズ削減にも直接効くので消し忘れがないように、Android Studioの Inspect Code を実行して探し出すのがポイントです。 2. Googleが提供しているAPIや、Support Libraryを標準に沿って使い倒す Googleが提供しているAPIや、Support Libraryを標準に沿って使い倒すことがAndroidの開発においてはとても重要になってきます。 標準に沿って使うことで、ガイドラインに変更が入ったりした際に移行がとてもスムーズにできるようになりますし、正しいMaterial Design(Androidのデザインガイドライン)の実装に近づけることができます。そのため、標準から大きく外れた使い方はなるべく控えた方がいいと思います。 仕様上、デザイン上、どうしても標準から外れた実装をしなければならない状況になりましたら、ある程度の覚悟を決めてカスタマイズに望みましょう。 3. サードパーティ製のUI系Libraryは慎重に選定する Design Support Libraryが充実してきたおかげで最近は使うことが少なくなったサードパーティ製のUI系Libraryですが、もしも使用しないといけない場合が来たら慎重に選定することをオススメします。 UI系Libraryはガイドライン変更の影響を受けやすいため、標準から大きく外れた実装をしていると移行が大変になるケースが多いです。IQONも過去に何度か苦しめられました... 使うことになった際には、開発が活発であるか、長い間使われていて枯れているか、使用するメリットが大きいか、他社での導入実績があるか、など多くの面を調査して選定することをオススメします。 4. コンストラクタやpublicメソッドの引数にはAnnotationを付けるようにする コンストラクタやpublicメソッドの引数にはAnnotationを付けるように徹底しています。 IQONで良く使われているAnnotationは以下になります。 @NonNull @Nullable @DrawableRes @LayoutRes @StringRes @AnimRes @MenuRes Annotationを付けることで誤ったものを渡したときに警告してくれるようになるので開発中に非常に助かります。アプリの規模が大きく、長く開発を続けているアプリであれば恩恵は大きいと思います。 @NonNull と @Nullable に関しては、チームでNullableになるような実装はしないと堅く決めるのであれば必要ないと思いますが、Nullableになる可能性があるのであれば付けたほうが安全です。 またKotlinで書く場合であれば、Nullableの場合は型に ? を付けるだけでスマートにできるため @NonNull と @Nullable は必要ありません。 5. テキスト、色、余白などの情報はResourceファイルにまとめて必要最低限に保つ テキスト、色、余白などの情報は、レイアウトファイルやJava(Kotlin)側に直接書かず、Resourceファイルに定義して使うように徹底しています。Resourceファイルに定義したものを使うようにすることで、アプリ全体で長期的に統一感を保つ効果があります。 ただし余白に関しては、画面特有の指定がある場合には直接書く場合もあります。 また、定義するだけではダメで 必要最低限に保つ ことが非常に重要です。長く開発を続けていると自然とダブリが発生したりしますが、それを放置せずに常に見直すようにして必要最低限にすることで、定義した情報を使用する際の混乱を防ぐことができます。 6. チームでコードの体裁を揃える Android Studioの Reformat Code や Optimize Imports を実行して、チームでコードの体裁を揃えることも長く開発し続けるには重要だと考えて行っています。 長く開発を続けているとどうしても人の入れ替わりが発生しますが、共通のルールがないとコードの細かい部分で統一感がなくなってきて少しずつ気になりはじめます(各チームメンバーの性格もあると思いますが)。 今では全員Android Studioを使って開発をしており、体裁を整えることに関してはそこまで苦ではないためルールを設け揃えるように徹底しています。 細かい話ではありますが、いろんな性格のメンバーがいる開発現場では大切なルールです。 7. レイアウトファイルはできる限りシンプルにする 以下の2点に関してはレイアウトファイルをコードレビューする際に注意して見るようにしています。 まとめることで削除できるViewは削除する 不要なパラメータは付けないようにする まとめることでViewが削除できたり、階層が減らせたりする場合はコードレビューで積極的に指摘しています。最近の端末であればそこまで大きくパフォーマンスに影響はでませんが、古い端末であれば顕著に影響が出る場合があるのでムダがあればドンドン減らしましょう。 特に仕様変更やリファクタリングで修正が入った場合には見落としがちなので注意して見るようにしています。 仕様変更やリファクタリングによって使うViewが変わった際には元のViewで使っていたパラメータが残っている場合が多いので注意してみるようにしています。RelativeLayoutにorientationのパラメータが付いているケースはとてもよく見かけます。 また、あってもなくてもUIに影響がでないようなパラメータも付けないようにしています。本当に必要なときにだけ必要なパラメータを付けることでXMLがスッキリし改修する際に手が入れやすくなります。 ただし、Android Studioが警告してくれるものに関しては準拠するようにしています。 8. APIをシンプルな形で使う APIの仕様を把握しシンプルな形でAPIを使うことは、アプリを長く開発し続けるのに重要だと考えています。Android DevelopersのDocumentを読んだり、実際にコードを読んだりしてみると、メソッドがオーバーロードされていてよりシンプルな形で使えるようになっていることがあります。 例えば ActionBar の setTitle のメソッドは、引数が CharSequence のものと、 @StringResのint のもの、2つが提供されています。 それにも関わらず getSupportActionBar().setTitle(getString(resId)) と書いてしまっているコードをよく見かけるので、APIの仕様をしっかりと把握してよりシンプルになるようにしています。 9. 新しい仕組み・古い仕組みを併用している場合、古い仕組みにはdeprecatedを付ける アプリを長く開発していると場所によって古い仕組みが残ってしまっているケースが多いと思います。すぐにリファクタリングをして古い仕組みを撤廃できれば全く問題ないのですが、依存している箇所が多いと改修に多大な時間がかかってしまうため併用している場合も多いのではないかと思います。 IQONの場合もAPIクライアントがその状態に当たります。ほとんどのAPIリクエストに関してはRetrofitを使うように書き換えたのですが、一部Apache HTTP Clientをベースに作られたAPIクライアントが残っています。そのまま併用していると間違って新規で使われてしまう恐れがあるため、古いAPIクライアントには deprecated を付けて古い仕組みである、リファクタリングの対象である、ということを明確にしています。 最後に Androidアプリを長く開発し続けるために気をつけている9個のルールを紹介しましたが、Android特有のルールがありつつも結局のところは、 ムダなコードは書かない シンプルに作る 他のメンバー(未来の自分)のことを考えて作る これに尽きると思います。 この10年でAndroidは世界で一番使われているモバイルOSになりました。これから長く開発されていくアプリが増えてくると思いますが、今回紹介したことがあまりできていない場合は見直してみることをオススメします。 最後の最後に、現在VASILYではIQONだけではなく、新規でいくつかAndroidアプリを開発しています。 100%Kotlinで書いているプロジェクトもあったりしますので興味がある方は一度遊びに来てください。
アバター
こんにちは、バックエンドエンジニアの塩崎です。 今まではiQONの全文検索用のインデックスには形態素解析だけを用いていましたが、先日Ngramも併用することで検索を改善しました。 その結果、検索結果のヒット数が向上し、なおかつ検索ノイズの増加を軽微なものに抑えることができました。 この記事では、Ngramを併用することのメリット、およびそれをApache Solrで利用する方法について紹介します。 欲しい情報が見つからないとは そもそも、「検索したけど欲しい情報が見つからない状態」とはどのような状態でしょうか? ここではその状態を以下の2つの状態に分解して考えてみます。 欲しい情報の数が少ない 1つ目の状態は「欲しい情報が検索結果中に少ない」状態です。 例えば、旅行情報サイトで「東京」と検索した時にDBの中には数千件のデータがあるのに検索結果数がわずか数件しかないような状態です。 欲しくない情報の数が多い 2つ目の状態は「検索結果中の欲しくない情報の数が多い」状態です。 「東京」と検索した時に、東京の情報以外に京都、大阪などの他の地域の情報が検索結果に含まれるような状態です。 実際は2つの状態が同時に起こっている 実際の欲しいものが見つからない状態は上記の2つの状態が同時に発生していることが多いです。 つまり、ユーザーの欲しい情報全てを検索結果として返しておらず、また、ユーザーの欲しくない情報も検索結果として返している状態です。 ベン図で表すと以下のようになります。 DBに格納されている全情報を、検索結果として返したか否か、欲しかった情報か否かという2軸で分類しています。 そして、これ以降の説明のためにそれぞれの集合に名前をつけます。 検索結果として返していて、なおかつユーザーの求めていた情報を「正しい結果」、検索結果として返しているのに、ユーザーが求めていない情報を「検索ノイズ」、ユーザーが求めているのに、検索結果に含ませない情報を「検索漏れ」とします。 ちなみに、情報検索の分野では、それぞれを「TruePositive」、「FlasePositive」、「FalseNegative」と呼びます。 この図で検索漏れが多い状態が上で説明した状態の1つ目の状態、検索ノイズが多い状態が2つ目の状態に相当します。検索結果の改善とは検索ノイズと検索漏れの数を減らし、検索結果と欲しい情報を一致させることに他なりません。 しかし、これら2つを減らすことはトレードオフの関係にあります。どちらか1つを改善することによってもう1つが悪化してしまうことが多くあります。 検索インデックスの種類とその利点・欠点 全文検索エンジンの中で行なわれているインデックス処理と、その前段階に行なわれる単語分解について説明します。 全文検索エンジンは検索処理を高速化するために、転置インデックスというインデックスを生成します。 転置インデックスは文章中の単語をキー、その単語が出現するドキュメントの配列をバリューとする連想配列です。 このあたりについては先日公開したTECH BLOGの記事の前半部分で詳しく説明しているので、よろしければご覧ください。 Solr 6でneologdが組み込まれたkuromojiを使う方法 転置インデックスの生成のためには、ドキュメントを単語に分解する必要があります。 日本語のような単語と単語の間が空白で区切られていない言語を単語分解するために、Ngramと形態素解析という2つの手法があります。 それぞれの特徴について説明します。 Ngram Ngramは文章を機械的にN文字づつに区切って単語分解する方法です。 何文字ごとに区切るかでNの部分が変わり、特にN=2の場合はbigram、N=3の場合はtrigramとも言います。 これ以降では説明のためにN=2(bigram)を利用します。 bigramではドキュメントを2文字ごとに区切って、その結果を単語としてみなします。 例えば、「東京都美術館」というドキュメントは以下の5つの単語に分解されます。 「東京」「京都」「都美」「美術」「術館」 そして、検索を行うときには、検索クエリを同様に単語分割して、それらのANDで結合します。 例えば、「美術館」という検索クエリに対しては「美術 AND 術館」という条件で検索を行います。 このようにすることによってN文字以上の任意の部分文字列について検索漏れがなくなることが保証されるのがNgramの利点です。 一方で「京都」という検索クエリに対してもこの「東京都美術館」がヒットしてしまうことがNgramの欠点です。 利点:部分一致検索ができることが保証されている 欠点:検索ノイズが多い 形態素解析 形態素解析による単語分割は、事前に用意した辞書を用いて、文法的に意味のある単位で単語分割を行います。 そのため、Ngramを使用した時の問題は起こりづらいことが利点です。 一方で、辞書を事前に用意する必要があり、辞書の性能が低いと検索性能が悪化してしまいます。 例えば「外国人参政権」を「外国」「人参」「政権」と分解してしまうと、そのドキュメントは「参政権」という検索クエリにヒットしなくなってしまいます。 また、SolrやElasticSearchに標準で搭載されている形態素解析器であるkuromojiはIPA辞書を利用していますが、この辞書は特定領域の固有名詞にそこまで強いわけではないという弱点があります。 そのため、本来は1単語になるべき固有名詞が複数の単語に分解されてしまうことがままあります。 例:「ロクシタン」 → 「ロク」「シタン」 一方で、固有名詞を一単語とすることにも別の問題があります。 例えば、「関西国際空港」という固有名詞を一単語としてインデックスすると、「国際空港」や「空港」といった検索クエリで「関西国際空港」が含まれるドキュメントがヒットしなくなってしまいます。 これを解消するために、「関西国際空港」という単語をさらに分割して、「関西」「国際」「空港」の3単語と合わせて合計で4単語をインデックスする機能がkuromojiに備わっています。 しかし、このような挙動も辞書や形態素解析器依存であり、必ずしも部分一致検索ができる保証はありません。 利点:検索ノイズが少ない 欠点:辞書の性能によっては検索漏れが多い 形態素解析とNgramの併用 Ngram、形態素解析の利点欠点を検索ノイズの数、検索漏れの数という観点で以下の表にまとめます。 検索ノイズ 検索漏れ Ngram 多い 少ない 形態素解析 少ない 多い この図からも検索ノイズを減らすことと検索漏れを減らすことがトレードオフの関係にあることがわかります。 しかし、これら2つを併用することによって、それぞれを単独で使用する場合に比べて、検索漏れの数を減らし、検索ノイズの数を実質的に減らすことが可能です。 併用するための具体的な手法は以下のようなものになります。 インデックス生成処理 ドキュメントに対してNgramによるインデックスを生成する 形態素解析によるインデックスも生成する 検索処理 Ngramと形態素解析によるインデックスに対して同時に検索を行う 形態素解析によるインデックスの結果が先頭になりやすいように重みを付けて検索結果をマージする Icons made by Madebyoliver from www.flaticon.com is licensed by CC 3.0 BY 2つの検索結果をマージすることによって、検索漏れを減らします。 しかし、ただ単にマージしてしまうと、検索ノイズが増えてしまいます。 形態素解析の結果を先頭になりやすいようにマージすることが、この併用処理のキモです。 ユーザーが検索結果を見るときにはその先頭から見るため、先頭に求める情報があった場合はそこで満足してそれ以降の結果を見るのをストップすることがあります。 そのようなケースにおいては実質的に検索ノイズは増えていないと考えることができます。 形態素解析とNgramを併用するクエリをSolrで作る方法 具体的なSolrの設定ファイルの書き方、クエリの書き方について説明します。 これらの動作はSolr 6.2.1で確認していますが、特定のバージョンに依存した書き方はしていないため、他のバージョンのSolrでも動くかと思います。 インデックス生成方法 まずはインデックスの生成部分です。 以下の3つの情報をmanaged-schemaに書き込みます。 フィールドタイプ定義 Ngramでインデックスの生成を行うためのfieldTypeであるtext_ja_ngramと、形態素解析でインデックスの生成を行うフィールドであるtext_jaを定義しています。 <fieldType name = "text_ja_ngram" class = "solr.TextField" autoGeneratePhraseQueries = "true" positionIncrementGap = "100" > <analyzer> <charFilter class = "solr.ICUNormalizer2CharFilterFactory" name = "nfkc" /> <tokenizer class = "solr.NGramTokenizerFactory" minGramSize = "2" maxGramSize = "2" /> <filter class = "solr.LowerCaseFilterFactory" /> </analyzer> </fieldType> <fieldType name = "text_ja" class = "solr.TextField" autoGeneratePhraseQueries = "false" positionIncrementGap = "100" > <analyzer> <charFilter class = "solr.ICUNormalizer2CharFilterFactory" name = "nfkc" /> <tokenizer class = "solr.JapaneseTokenizerFactory" mode = "search" userDictionary = "lang/userdict_ja.txt" /> <filter class = "solr.SynonymFilterFactory" synonyms = "synonyms.txt" ignoreCase = "true" expand = "true" /> <filter class = "solr.JapaneseBaseFormFilterFactory" /> <filter class = "solr.JapanesePartOfSpeechStopFilterFactory" tags = "lang/stoptags_ja.txt" /> <filter class = "solr.StopFilterFactory" words = "lang/stopwords_ja.txt" ignoreCase = "true" /> <filter class = "solr.JapaneseKatakanaStemFilterFactory" minimumLength = "4" /> <filter class = "solr.LowerCaseFilterFactory" /> </analyzer> </fieldType> フィールド定義 そして、これらのfieldTypeのfieldを定義します。 <field name = "search_ngram" type = "text_ja_ngram" multiValued = "true" indexed = "true" required = "false" stored = "true" /> <field name = "search" type = "text_ja" multiValued = "true" indexed = "true" required = "false" stored = "true" /> 他のフィールドからのデータのコピー 最後に、copyFieldを使い、検索対象としたいフィールドを上記の2つのフィールドにコピーします。 <copyField source = "title" dest = "search_ngram" /> <copyField source = "title" dest = "search" /> <copyField source = "description" dest = "search_ngram" /> <copyField source = "description" dest = "search" /> ... これでインデックス生成部分の設定は完了です。 試しにAnalysis機能でこれらが正常に働いていることを確認してみます。 Ngramによるインデックス 形態素解析によるインデックス クエリの投げ方 次にこれらのフィールドに対して投げるクエリを説明します。 複数のフィールドに対して横断的に検索をするために、eDisMaxクエリを利用します。 以下のようなパラメーターでクエリを投げることで、両方のフィールドを利用して検索を行うことができます。 q=<検索したい文字列> defType=edismax qf=search 100 +search_ngram 50 qfで指定している100と50はそれぞれのフィールドの重みです。 重みが大きいほど、そのフィールドでヒットしたドキュメントが先頭に出やすくなります。 このあたりのチューニングは検索結果を見ながら値を変えてゆく必要があります。 まとめ 形態素解析とNgramの併用によって、検索漏れを減らし、検索ノイズの増加を抑制することができました。 その結果として、特にiQONではブランド名の略語で商品の検索ができるようになりました。 例えば、「JIMMY」という検索クエリで「JIMMY CHOO」の商品をヒットさせることができるようになりました。 iQONで扱う商品数は日に日に増えており、昨年10月の時点では1200万点に達しました。 ファッションアプリ「iQON」、掲載アイテム数が累計1,200万点突破! 〜新規ファッションECと提携し更なる事業拡大を加速〜 これらの膨大な商品の中からユーザーの好みに合った商品を探すことは非常に難しい課題です。 本記事で紹介した手法はこの課題に対する1つのアプローチであり、iQONではこれ以外にも様々な方法でユーザーに欲しい商品が見つかる体験を提供することを考えています。 例えば以下の記事ではディプラーニングを活用した画像ベースでの検索を紹介しています。 ディープラーニングによるファッションアイテム検出と検索 VAEとGANを活用したファッションアイテム検索システム このチャレンジングな課題を解決してみたいという方は以下のリンクからご応募ください。
アバター
こんにちは、VASILYバックエンドエンジニアの塩崎です。 今年の就職活動のスタートは3月になるそうなので、学生の皆さんは自分の進路について色々と悩んでいる時期かと思います。 今回のTECH BLOGは、Rubyの開発者であり、VASILYの技術顧問でもある、まつもとゆきひろ氏(以下、Matzさん)にエンジニアの就職活動について色々と質問した内容をお届けします。 将来エンジニアとして活躍したい学生さんの助けになればと思います。 Matzさんが学生の頃のインターン ― Matzさんが学生の頃のエンジニアの就活は今と比べてどうでしたか?今のようなインターンは当時からあったんでしょうか? インターン自体は当時からあったんですが、今のように就職に直結するものではありませんでしたね。社会経験みたいな形でインターンに行きました。内定が目的のインターンではなく、バイト感覚でした。 ― 今のインターンは内定直結型が多いですが、それについてはどう思いますか? 良いことだと思いますよ。 お互いに相手のことを知った状態で次の段階に進めるので、書類だけで選考するよりも良いと思います。書類だけを書くのが上手い人が来てもしょうがないですしね(笑) 企業側からすると、インターン生に何をやってもらうのかを決めるのは難しいことだとは思います。ですが、そのあたりを体系化できるとお互いに正直になってマッチングできるので、最高ですよね。私の頃はがっつり仕事をさせてもらえるようなインターン先ってほとんどなかったんですよ。 インターンについては今の方が良くなったと思います。 エントリーシートは最悪の発明!? ― 逆に今の方が悪くなったことは、何なんですか? それはエントリーシートですね。 今のエントリーシートって何十通も一気に出せるじゃないですか。あれって、企業と学生さんの双方にとって最悪なんですよね。企業からすると自分たちに興味がない学生のエントリーシートが送りつけられてくるわけですよね。学生さんからすると自分が出したエントリーシートが他の大勢の人のエントリーシートの中に埋もれちゃいますよね。 あれは最悪の発明ですよ。 大手の就活サイトがエントリーを無駄に急かすようなことをして軽く炎上したこともありました。エントリーシートは世の中から無くした方がいいと思います。 ― インターンは学生さんの本気度を見る手段として有効だと思いますか? 本当にそう思います。 インターンに行くってことは内定が出たら入社したいという意味ですからね。インターンの期間も2週間〜1か月くらいあって、そのくらい働くと本気になりますよね。そういう意味ではインターンはエントリーシートよりも良いシステムだと思います。 今の就活のシステムってハックする必要があって、みんなと同じ方法をしてしまうと、運が良くないと就職できなくなってしまいますよね。 学生さんでも中途として転職するのと同じようなハックが必要になってきます。学生さんのうちから情報発信をしたりとか、みんなが目指すような大企業ではないところを目指すとか。そういう、就活に対する見方を変えるようなハックをしないと、就職が辛い時代になっている気がします。 Matzさんがインターンをするとしたら? ― もしMatzさんが今の時代の学生さんだったら、どんな会社にインターンに行ってみたいですか? 大企業ではない方がいいんじゃないかなぁと思います。 今もまだありますし、私たちの頃はもっと強かったんですけど、大企業志向ってありますよね。でも今は大企業でも倒産したりとか、リストラがあったりとか、大企業に入るメリットがだいぶ減ってます。 あと、大企業では組織防衛のため、全体として保守的になる傾向があります。そのせいで、承認フローが複雑だったり、一人一人の裁量が小さかったりしますよね。そういう事情を考えると、自由を重んじるエンジニアになりたい学生さんは大企業じゃないところを目指すのがいいんじゃないかなと思います。 小さい企業だからリスクが低くなるというわけではないので、そこは見分けなきゃいけないですよね。テクノロジーに力を入れているかとか、エンジニアを大切にしているかとか。でも、現代においては大企業じゃないとこを選ぶ方が幸せになれる確率が上がる気がします。小さい会社の中で、エンジニアを大切にしていたり、テクノロジー的に面白そうなことをしているところを選ぶといいと思います。例えばVASILYさんみたいな(笑) エンジニアを人間として扱っているかも重要ですね。 エンジニアを交換可能なものとして扱っている会社って結構あるんですよね。そういうところでは技術的に突出した人はリスクになるんですよね。その人がいなくなるとプロジェクト全体が失敗してしまうので、その人の専門性を使わないという経営判断は十分ありえますね。でも、そうは言ってもその人にしかできないことがあるから任せようという判断ができる会社だと、その人の能力が生きる気がします。 Matzさんも参加してみたいというVASILYのインターンとは? ― VASILYのインターンの大きな特徴として、インターン生の作ったものをiQONに組み入れて全世界にリリースするまでを行うということがあります。もしも学生時代のMatzさんがこんなインターンの募集を見つけたら、行ってみたいと思いますか? そりゃ行ってみたいと思いますよ!! つまらないタスクを切り出してインターン生に渡したりとか、本番では使えないコードを書かされたりとか、本当の仕事とは違う作業を行うインターンが多いと聞きます。そういうインターンは実力のあるエンジニア候補にとってはだいぶ物足りないですよね。 VASILYさんのようなインターンを企画するのって難しくて、採用側にはコストが高いと思います。ですが、そこをうまく乗り越えることができれば、そのインターンはすごい魅力的に聞こえます。私が学生だったら、参加したいと思いますよ。 学生さんも色々な人がいて、中には楽してインターンの経験を得たい人もいます。でも採用側としてはそういう人よりも、本番環境に出したいという熱意のある人の方を採りたいですよね。そういうことを考えると、魅力的な対象にフォーカスしてインターンのプログラムを設計するのはすごくいいことだと思います。多くの企業がリスクを考えて深く踏み込めていない中、そこまでやっているのはすごいことだと思います。 就活生よ、偉そうになれ! ― 最後に、現在エンジニアを目指して就職活動中の学生さんにMatzさんからアドバイスをお願いします! 組織と自分との関係を対等のものとして扱うように心の中で思ってください。 会社は私にお金を払っているけど、私は会社に時間と才能を提供している。だから、どちらかが上というものではなく、対等な関係であると思ってください。そうすると、会社が理不尽なことを言った場合に無理に我慢する必要がなくなると思います。対等な関係でないと、働く側だけが我慢することになりがちです。いわゆるブラック企業ではそういう構図になっているんじゃないかと思います。でも、どちらが偉いというわけではなく、対等という意識があれば、理不尽な要求に対してNOが言えるわけですよ。 人間の心理的にお金を『貰った』とか雇って『貰った』みたいな『貰った』という言葉を使うと立場が低くなりがちですよね。入社して『やった』とか働いて『やった』ぐらいのことを心の中で思っているといいんじゃないかなと思います(笑)やや自分の方が上になりましたが、それくらいでバランスがとれるんじゃないかなと思います(笑) ちなみに、私は新人の時にはすごく偉そうな新入社員で、すぐに「こうあるべき」みたいな正論を言うし、時間の使い方はフリーダムだし、だから、上司は扱いづらかったんじゃないのかなぁと思っています。でも長い目で見るとお互いにとってプラスだったんじゃないかなとも思います。もし私が会社よりも自分の方が下という思いを持ってしまっていたら、多分Rubyは生まれてなかったかもしれませんね(笑) 「皆さんも偉そうな就活生や新入社員になってください」というのが、僕からのメッセージです! まとめ 今回のTECH BLOGはエンジニア志望の学生に向けた、まつもとゆきひろ氏とVASILY若手社員の塩崎の対談でした。 この記事が学生の皆さんにとって将来に歩む道を決める参考になれば幸いです。 1時間程の短い対談でありましたが、とても良い発言が多く、編集するときにどこを割愛すれば良いのか頭を悩ませました。 ここには書ききれなかった事もたくさんあるので、気になる方はどこかで私に会ったときに直接聞いてみてください。 VASILYでは春季インターンの募集を行っています。 この対談記事を見て興味を持った学生さんはぜひ以下のバナーからご応募ください。
アバター
こんにちは、Webフロントエンドエンジニアの権守です。 フロントエンドエンジニアの皆さんは、リリース前の社内QAにてデザイナーにピクセルのずれを指摘されて修正したという経験があるのではないでしょうか。今回はiQONのPC・スマホサイトを構築する上で、デザインデータに忠実なCSSコーディングをどのように実現しているかを紹介します。 ツールの利用 PerfectPixel 既に利用されている方も多いかもしれませんが、デザインとのピクセルずれの確認には PerfectPixel がオススメです。 PerfectPixelは、画像を透過して重ねることでデザインとのずれを確認できるブラウザ拡張です。シンプルですが、透過率の設定や画像の拡大縮小、オフセットの設定など必要な機能をしっかりと備えており、使いやすいです。 複数のブラウザ(Chrome, Firefox, IE, Safari, Opera)で提供されているというのも嬉しいところです。 重ねるとピクセルのずれや文言違いが一目でわかるのが伝わるかと思います。 Sketch Adobe Fireworksの開発が終了して以来、VASILYでは、デザインデータはAdobe Illustrator, Adobe Photoshopを使って作成されていました。 しかし、元々Webデザインを行うことを目的に作られていないこともあり、エンジニアにとって使いづらく、実装時の障害になっていました。また、サイトの大規模化に伴いUIコンポーネント管理も問題となっていました。 そこで、VASILYでは、デザインと実装の効率化を図るために昨年夏ごろから Sketch を導入し始めました。 Sketchは、WebデザインやアプリデザインなどのUIデザインに優れたデザインツールです。 シンボル機能を使ったUIコンポーネント管理や、オブジェクト間の距離の表示、ワンクリックでの画像の書き出し、デザインプロパティのCSSプロパティへの変換など便利な機能が豊富にあります。 また、有志の方々により高機能なプラグインも多く開発されている点も魅力的です。 導入にはデザイナーの協力が不可欠ですが、導入できる方にはぜひおすすめしたいです。 コンポーネント化 PerfectPixelを使っているとデザインとの小さな乖離でも気になるようになってきます。そのような乖離を減らすためには、デザイン側と実装側の両方でコンポーネント化が必要になってきます。 デザイン側はSketchの導入によって、シンボル機能を用いてのコンポーネント化を行いました。 実装側では、以前、 本ブログでも紹介 したようにSassのMixinという形でコンポーネントを管理しています。デザイン側でもコンポーネント化が進んだことによって、どこまでがpaddingでどこからがmarginかといったことがわかるなど、より適切にコンポーネントを把握することができようになりました。それによって、デザインとの乖離が減ったのはもちろん、実装時の迷いも減りました。 コンポーネントの例を以下に示します。 brand_link_list.sass @mixin m-brand-link-list( $mright , $column : 3 , $size : medium ) $link-width : 0 @if $size == large $link-width : 312px @else $link-width : 251px ul . m-brand-link-list margin-top : -4px padding-bottom : 4px li display : inline-block background : url( $cdn-domain + "shared/link_icon.png" ) no-repeat 3px 7px vertical-align : top $pleft : 15px width : $link-width - $pleft padding : 0 0 33px $pleft margin-right : $mright & : nth-child( #{$column}n ) margin-right : 0 a color : $link-color font-size : 14px line-height : 22px & : hover text-decoration : underline padding-bottom : 1px . sub font-size : 10px line-height : 16px margin-top : 3px モックデータの管理 デザインデータ上のテキストはモックが入力されています。そのデザインに合わせてコーディングを行っていると、デザインに合わせることに夢中でコードレビュー前に実際のデータに戻し忘れてしまうということがあります。 そこで、以下のようなRailsのヘルパーを実装しました。 application_helper.rb ... def apply_mock (original, model_name, key) return unless Rails .env.development? mock = YAML .load_file( Rails .root.to_s + " /config/mock/ #{ model_name } .yml " )[key] if original.is_a?( Array ) original.clear.concat(mock) elsif original.is_a?( Hash ) original.merge!(mock) end end ... config/mock/users.yml :my_page_design : :nickname : 'ゴンノカミ' :email : 'hoge@vasily.jp' mock_sample.html.slim - apply_mock( @user , :users , :my_page_design ) section h1 = "#{ @user [ :nickname ] } さんのマイページ " p = @user [ :email ] YAMLから読み込んだデータで元のデータを表示前に上書きします。試験導入中なので、実装はシンプルなものになっていますが、十分に効果を発揮しています。コミットする際にはapply_mockをコメントアウトする運用をしてますが、もし、コメントアウトし損ねてしまったとしても開発環境でしかデータを上書きしないようにしてあります。 現状は、表示側をデザインに合わせていますが、 Craft などのプラグインを使ってSketch側でデザインをAPIに合わせるという方法も有効だと思います。 Tips 最後に忠実なCSSコーディングをする際につまづきがちなポイントについて紹介します。 line-heightについて line-heightは行の高さを指定するCSSプロパティですが、このプロパティが忠実なコーディングをする際に厄介なポイントの一つです。PhotoshopやSketchなどのデザインツール上では、テキストオブジェクトは行の高さではなく行間を設定します。設定された行間をそのままCSSのline-heightに反映すると一行目と最終行で余計なpaddingが生まれてしまいます。そのため、前後のコンポーネントとのマージンをその分減らすなどの処置が必要です。 line_height_sample.html < section > < h2 > 見出し1 </ h2 > < p > 段落1です。 </ p > </ section > < section > < h2 > 見出し2 </ h2 > < p > 段落2です。 </ p > </ section > line_height_sample.sass section margin-bottom : 60px h2 $font-size : 16px $line-height : 20px $diff : ( $line-height - $font-size ) / 2 font-size : $font-size line-height : $line-height margin : - $diff 0 ( 20px - $diff ) inline-blockのリストについて リストの要素をinline-blockを使って横並びにした際に、意図しない空白が生まれてしまうことがあります。 inline_block_sample.html < ul > < li > A </ li > < li > B </ li > < li > C </ li > </ ul > inline_block_sampe.sass li display : inline-block このようにマークアップした場合に、リスト要素の間の空白が空白文字としてブラウザ側で表示されますが、本番配信時にはHTMLファイルはminifyされた状態で配信されるので、その空白の分だけ表示がずれてしまいます。 これを防ぐには開発環境でもminifyを行うか、ulに対して font-size: 0 を指定することで解決できます。 iQONではSlimを使っているので、Railsの開発環境設定に以下のように記述を追加して、minifyを行っています。 config/environments/development.rb Iqon :: Application .configure do # ... settings Slim :: Engine .set_options pretty : false end モダンブラウザであれば display: flex を使って横並びにするのもよいと思います。 まとめ 今回は、どのようにしてデザインデータに忠実なCSSコーディングを行っているかを紹介しました。綺麗なサイトを保つためには、エンジニアとデザイナー双方の協力が不可欠です。それを踏まえた上で、ツールやテクニックを利用しお互いの負担が軽くなるようにできればと思い、日々コードを書いています。本記事が、精密なCSSコーディングができなくて困っている方の助けになれば嬉しいです。 最後に VASILYでは、細部までこだわったサービスを一緒に作っていける仲間を募集しています。少しでもご興味のある方は以下のリンク先をご確認ください。 https://www.wantedly.com/projects/61388 www.wantedly.com
アバター
こんにちは、エンジニアの荒井です。 2016年はAMP(Accelerated Mobile Pages)の正式サポートがアナウンスされ、導入した方も多いのではないでしょうか。VASILYでもAMPを導入し数ヶ月運用しています。AMPの導入に関しては、関連記事も多く存在しますが、導入したことによって得られた結果について触れている記事は少ないと感じています。そこで本記事では、弊社でのAMP導入事例と、AMPによって得られた効果を紹介したいと思います。これから導入を検討している方はAMP対応による効果の一例として、すでに導入している方は比較対象として参考にして頂ければと思います。 AMPについて 初めにAMPについて簡単に説明します。 AMPはモバイルページを高速にユーザー届けるために発足した プロジェクト です。昨年Googleの検索結果に表示されるようになり、様々なサービスで対応が進められています。対応したページは以下のように「⚡AMP」と検索結果に表示されます。 AMP動作イメージ AMPは以下の手順で高速化を実現しています。 コンテンツ提供者がAMP用のHTMLを作成 GoogleのクローラーがAMPページをキャッシュ CDNにてAMPドキュメントをすべて配信 ユーザーが検索した際、キャッシュ済みのAMPページが表示される これを実現するために、AMPでは以下の3つの要素が重要となっています。 AMP HTML AMP JS Google AMP Cache AMP HTML 拡張されたHTMLです。パフォーマンスを保証するために制約が設けられています。 記述はHTMLとほぼ同様ですが、imgタグがamp-imgタグに置き換わるなど、一部のタグが専用のタグなっています。これらはAMP HTML コンポーネントと呼ばれ、AMP実装にあたり理解することが不可欠です。また、「最上位階層のタグを <html ⚡> 」にするといった、ドキュメントが満たすべき条件があります。本記事では詳細を割愛しますが、 公式のチュートリアル に必須マークアップが詳しく紹介されています。 AMP JS パフォーマンス最適化のためのJavaScriptライブラリです。ページのレンダリング速度が低下しないように非同期のJavaScriptのみが許可されています。AMPでは作成したJavaScriptを使用することが出来ないので注意が必要です。AMP JSライブラリの読み込みはheadタグの最後に行う必要があります。 < head > ・・・ ・・・ < script async src = "https://cdn.ampproject.org/v0.js" ></ script > // 必須のAMPJS ライブラリ </ head > また、使用するAMP HTML コンポーネントによっては別途ライブラリの読み込みが必要となります。 その際、必須のAMP JS ライブラリよりも前に読み込むように注意してください。 Google AMP Cache 検証済みの有効なAMPドキュメントを配信するCDNです。 AMPでは迅速なユーザー体験を提供するため、Google AMPキャッシュから直接コンテンツ配信を行います。 AMPの導入手順 それではiQONで導入した際の流れや注意点を紹介していきます。AMPを導入する際、以下の手順で進めました。 対応するページの検討 ページの構成要素を見直し 実装と動作テスト アクセス解析 マネタイズ 対応するページの検討 既存のトラフィックデータとAMPとの親和性を考え対応ページを決定しています。今回はモバイル検索から流入するユーザーの多くが求めているアイテム詳細ページを対象としました。アイテム詳細ページはすでにモバイルページが存在しているため、まずはモバイルページをベースに着手しました。しかし、前述の通りAMP独自仕様に従う必要があるためモバイルページをそのまま流用することは出来ません。いくつか大きな制約があるため、ページを構成する要素を見直しています。 ページの構成要素を見直し すでに存在するモバイルページを流用しようとした際、数々の制約の中でも以下の制約が実装に大きな影響を及ぼしました。 作成したJavaScriptを使用できない 動的なUIはAMP HTML コンポーネントで提供されているもののみ可能 linkはcanonicalだけしか許可されていない 特にJavaScriptの制限は影響が大きく、UIやページの構成要素に大きな変更を与えました。モバイルページとAMPページの差分の一例を紹介します。 上記はモバイルページとAMPページのキャプチャです。 一見同じように見えますが、メニューやカルーセルなどで違いがあります。 AMP HTML コンポーネントで実現不可能なUIを変更したり、AMPページでは不必要と判断し機能を削るなどの調整をしていきました。LIKE機能や「もっとみる」といった独自のアニメーションを用いている箇所も修正対象となっています。 このように、提供しようとしているUIがAMPで実現不可能な場合があります。実現可能かを判断するために、AMP HTML コンポーネントを把握しなくてはいけません。 AMP HTML コンポーネント iQONで使用した主要なAMP HTMLコンポーネントを紹介します。 非常に多くのコンポーネントが用意されているため、制約はあれど、ある程度のUIは実装出来ると思います。コンポーネントについては こちら のサイトで分かりやすく紹介されています。 amp-img 画像を使用している場合にはimgタグの代わりにamp-imgタグを使用する必要があります。 画像を使用するサイトは多く存在するため、AMP対応する際は必須となるコンポーネントだと思います。 < amp- img src = "sample.jpg" alt = "hoge" height = "150" width = "150" ></ amp- img > amp-carousel カルーセルを実装するために必要なコンポーネントです。carousel、slidesといった表示タイプが設定できます。slidesの場合のみですが、autoplayもサポートされています。このコンポーネントを使用するにはAMP JSを別途読み込む必要があります。 < head > ・・・ ・・・ < script async custom-element= "amp-carousel" src = "https://cdn.ampproject.org/v0/amp-carousel-0.1.js" ></ script > < script async src = "https://cdn.ampproject.org/v0.js" ></ script > </ head > < body > < amp-carousel width = "600" height = "300" layout= "responsive" type = "slides" autoplay= true delay= "2000" > < amp- img src = "sample1.jpg" alt = "sample1" width = "600" height = "300" ></ amp- img > < amp- img src = "sample2.jpg" alt = "sample2" width = "600" height = "300" ></ amp- img > < amp- img src = "sample3.jpg" alt = "sample3" width = "600" height = "300" ></ amp- img > < amp- img src = "sample4.jpg" alt = "sample4" width = "600" height = "300" ></ amp- img > </ amp-carousel > </ body > amp-analytics 従来のWebページでアクセス解析を実装しているように、AMPページでもアクセス解析が実装できます。 注意点として、現在使っているアナリティクスベンダーが 対応しているか 調べる必要があります。VASILYでは従来からGoogle Analyticsを使用しているためGoogle Analyticsで実装を進めました。 < head > ・・・ ・・・ < script async custom-element= "amp-analytics" src = "https://cdn.ampproject.org/v0/amp-analytics-0.1.js" ></ script > < script async src = "https://cdn.ampproject.org/v0.js" ></ script > </ head > < body > < amp-analytics type = "googleanalytics" id = "iqon-amp-item-analytics" > < script type = "application/json" > { "vars" : { "account" : "UA-XXXXX-Y" } , "triggers" : { "trackPageview" : { "on" : "visible" , "request" : "pageview" // 用途を指定 } } } </ script > < amp-analytics > </ body > 現在はpageviewのみトラッキングしていますが、Google Analyticsでは以下のユーザーインタラクションがサポートされています。 type 用途 pageview ページトラッキング event イベントトラッキング social ソーシャルトラッキング amp-ad AMPでも専用のコンポーネントを使用することで広告表示が可能です。 サポートされているADネットワーク をご確認の上設定してください。以下はDoubleclickの設定例です。 < head > ・・・ ・・・ < script async custom-element= "amp-ad" src = "https://cdn.ampproject.org/v0/amp-ad-0.1.js" ></ script > < script async src = "https://cdn.ampproject.org/v0.js" ></ script > </ head > ] < body > < amp-ad width = 320 height = 250 type = "doubleclick" data -ad-client= client data -ad-slot= slot > </ body > モバイルページの修正 AMPページを用意した際には、通常のページにも少し手を加える必要があります。 該当ページでAMPが用意されているか伝えるために以下のようにlinkタグを追加します。 < link href = "https://item.iqon.jp/14379365/amp/" rel = "amphtml" /> AMP側のHTMLにはcanonicalの設定が必要です。 < link href = "https://item.iqon.jp/14379365/" rel = "canonical" /> 動作テスト AMPの実装が完了したら、適切に実装されているかテストを行います。 テスト方法がいくつかありますので、普段よく使用するテストを紹介します。 AMPテストツールを使用する https://search.google.com/search-console/amp URLを送信するだけの簡単な操作です。有効なAMPページの場合は下記のように表示されます。 「検索結果をプレビュー」を押すと、実際どのように表示されるか閲覧が可能です。 エラーの場合は改善内容が表示されるのでデバッグに役立ちます。 Chrome Developer Toolsを使用する Google ChromeのDeveloper Toolsを起動し、テストを行いたいAMPページに #development=1 を付与してアクセスを行います。するとConsole上に結果が出力されます。「AMP Validation successful.」と表示されていれば問題ありません。 AMPの効果 最後にAMPの効果について紹介します。弊社サービス内で導入した結果であり、必ずしも同じような効果が期待できるとは限りません。一例として参考程度に見ていただけばと思います。 PV AMPページのPV推移を紹介します。 11月2日にAMPの導入を行いました。グラフを見ていただくと分かる通り、AMPのPVは日に日に増えています。 Google Search Consoleの「Accelerated Mobile Pagesレポート」を確認すると、リリース当日からすでにインデックス登録されていることが確認できました。導入当初はどの程度の期間を経て検索インデックスに登録されるのか未知数でしたが、予想以上に反応が良くポジティブな結果となっています。 広告 検索インデックスの登録数が増え、AMPのPVが上がるに連れ、比較的早い段階で広告による収益も出るようになりました。元々amp-adは実装していませんでしたが、PVの増加に伴い導入しました。AMPでの広告表示は遅い印象を受けますが、CTRも高くeCPMは他のモバイルページと遜色ないパフォーマンスを出しています。 運用負荷 現在、AMPページ追加による運用負荷はほぼありません。Google Search Consoleに「Accelerated Mobile Pages」の項目が追加されているので、重大な問題があった際に対応するようにしています。 AMPのViewはモバイルページと完全に分けているため、モバイルページで要素の変更があった際に、AMPも同様に変更するという工数発生が予想されます。AMPとモバイルページが同様の構成であるべきかについてはMFIの動向も含め適宜判断が必要となりそうです。 まとめ AMP導入により現状サービスにポジティブな結果が出ていると感じます。AMPの効果が不透明で導入を見送っている方の参考になれば幸いです。 2017年もモバイルファーストインデックスなど、大きな動きがあると予想されます。 一緒に盛り上げていけるエンジニアを募集しておりますので、ご興味がある方、以下のリンクから是非ご応募ください。 参考文献 AMP Project https://www.ampproject.org/ AMP by Example https://ampbyexample.com/ Google Developers https://developers.google.com/analytics/devguides/collection/amp-analytics/?hl=ja
アバター
こんにちは、Androidエンジニアの堀江( @Horie1024 )です。VASILY DEVELOPERS BLOGは新年2回目の更新になります。ちなみに去年の更新回数は53回だったようです。 また、Androidチームのトピックスとしては、先日 ベストイノベーティブアプリ大賞 を受賞した際にいただいたトロフィーがオフィスに届きました。今年も賞をいただけるようVASILY全員で頑張ります。 company.vasily.jp はじめに Android版iQONのリリースは約1~2週間に1回行っており、リリース前には社内QAを実施しています。QAを実施するために必要な作業は定型な作業ですが、手動で行うと思いの外時間が掛かりますし、作業の抜け漏れが起きる事もあります。 そこで、タイトルにもあるようにQAの実施に必要な作業を自動化しました。一度自動化してしまえば作業に抜け漏れがなくなり、自分たちの時間も節約できます。 今回は具体的にどのように自動化したのかご紹介しようと思います。 自動化の方法 VASILYでは、以前から Heroku 上にホストした Hubot を Slack と連携させ利用しています。したがって、今回もSlackとHubotを使い自動化を行っていきます。Slackから特定の文字列を入力するとHubotが反応し、QA実施に必要な作業を自動的に実行するようにします。 この記事ではHerokuやHubotの設定は紹介しませんが、Web上に良い記事が多くありますので参考にしてみてください。 QA終了までの流れの整理 まず、今までのQA開始からQA終了までの流れを整理し書き出しました。QAは毎回以下のような流れで開始され終了します。 QAの開始・終了日時をSlackで全員に通知 GoogleスプレッドシートでQAシートを作成 Qiitaチームにリリースノートを作成 APKを作成しBetaで社内に配布 SlackでQA依頼 QA終了日時を過ぎたらSlackにQA終了報告とお礼を投稿 次にこれらをどこまで自動化するのかを考えます。 自動化する範囲 どこまで自動化するかを考え易くするために先程整理したQAの流れを QA予告 、 QA準備 、 QA開始 、 QA終了 の4つのフェーズに分類してみました。 分類する中で気づいたのは、QA準備以外のフェーズは全てSlackへの投稿という点です。これらのQA実施に関する連絡は、柔軟に対応できるようにした方が使い勝手が良さそうに思えます。 例えば、QAをお願いする時に一言添えたいかもしれませんし、QAの終了日時が伸びるかもしれません。また、QAでたくさんのフィードバックをもらえてお礼の文言を変えたい場合もあるかもしれません。 したがって、 QA準備 フェーズの自動化にフォーカスします。 コマンドの設計 SlackからHubotを使い自動化するには、Hubotに反応させるコマンドを決める必要があります。シンプルに android qa としてみました。 Slackで android qa と入力するとHubotが反応し以下の作業を実行するようにします。 GoogleスプレッドシートでQAシートを作成 Qiitaチームにリリースノートを作成 APKを作成しBetaで社内に配布 どう自動化するか? QAシート、リリースノートの作成、APK配布を自動化するにはどうすれば良いでしょうか。各作業について考えていきます。 1. GoogleスプレッドシートでQAシートを作成 QAシートは以下のフォーマットを利用しています。 Sheets API を使うことでフォーマット通りのスプレッドシートを作れますが、コードが複雑になるため予め用意したスプレッドシートをマスターにし、それを Drive API でコピーしてQAシートを作ります。 追記 マスタースプレッドシートの共有にサービスアカウントを追加する必要があります。 2. Qiitaチームにリリースノートを作成 Qiitaチームへのリリースノートの作成には Qiita API v2 を使用し、 POST /api/v2/items を使うことで投稿することができます。リリースノートのフォーマットは以下の通りで、記事のタイトルには「Android バージョン番号 リリースノート」、タグには「リリースノート」、「Android」を付けます。 # リリース日 # QA * 期限 * QAシート # 更新内容 タイトルのバージョン番号やリリース日、QAの期限はSlackを通じて対話的に入力できるようにします。また、更新内容にはGitHub APIの Repository/Releases を使用し、 GitHubのリリース機能 で記載した内容を取得するようにします。そして、QAシートの項目には、作成したQAシートのURLを記載します。 3. APKを作成しBetaで社内に配布 AndroidチームではCIに Wercker を使用しています。以下のQiitaの記事にまとめていますが、Wercker APIでビルドを実行しAPKの作成とBetaでの配布を行います。 qiita.com 記事内でも紹介していますが、Wercker APIをHubotから使用するためのAPI ClientをOSSとして公開しています。 github.com 以下のように簡単にWercker APIを使用することが可能です。 const Wercker = require( 'wercker-client' ). default ; const wercker = new Wercker( { token: 'your_token' } ); wercker.Runs .getAllRuns( { applicationId: "your_application_id" } ) .then((res) => { console.log(res); } ). catch ((err) => { console.log(err); } ); 全体の流れ Slackに android qa と入力してからの処理の流れを図にすると以下のようになります。 ① Slackに android qa と入力 ② Hubotが反応 ③ Drive APIを使いQAシートを作成 ④ Qiitaチームにリリースノートを投稿 ⑤ Wercker APIを使いビルドを開始 ⑥ WerckerがGitHubからアプリのコードを取得しAPKを作成 ⑦ BetaでAPKを配布 ⑧ 配布の完了をSlackに通知 実装 上記の流れを実装したサンプルコードです。 github.com サンプルコードでは以下の流れで処理を行っています。 Google APIクライントの初期化 android qa コマンドを定義 リリースするVersion Nameを入力 マスターとなるシートをコピーしてQAシートを作成 QAシートのタイトルをVersion Nameを含めたタイトルに更新 QAシートのアクセス権限を特定のdomainに属するアカウントのみ編集できるよう変更 GitHubのrepositoryからリリースノートを取得 リリース日を入力 QA期間を入力 リリースノートをQiitaに投稿 Wercker APIを使いビルドを開始 ローカルで実行 実行中の様子はこのようになります。 リリースノートもテンプレート通りに投稿されています。 実装する上で困った点 Google APIクライントの初期化 HubotからDrive APIを操作するには google/google-api-nodejs-client を使うのですが、認証・認可についての情報が少なく困りました。 手順としては、最初にAPIマネージャーからGoogle Drive APIを有効にします。 https://console.cloud.google.com/apis/api/drive/overview 次に コンソール にアクセスし、 サービスアカウント名 と サービスアカウントID を入力してサービスアカウント新規作成します。役割には、スプレッドシートの編集を行う必要があるため 編集者 を指定します。役割の詳細は こちら から確認できます。 キーのタイプにJSONを選択し、作成をクリックするとJSONファイルがダウンロードされます。 ダウンロードしたJSONを使用し認証・認可を行います。 こちら を参考に、以下のようにgoogleapisモジュールのJWT(JSON Web Token)メソッドを使いクライアントを作成します。 JSONファイル中に client_email と private_key というフィールドがあるので、それを以下の SERVICE_ACCOUNT_EMAIL 、 SERVICE_ACCOUNT_KEY に当てはめます。 const jwtClient = new google.auth.JWT( SERVICE_ACCOUNT_EMAIL, null , SERVICE_ACCOUNT_KEY, [ 'https://www.googleapis.com/auth/drive' ] , null ); これでDrive APIを使用できるようになります。 SERVICE_ACCOUNT_KEY をHerokuの環境変数から読み込むとクライアントの初期化に失敗する場合があるので、以下の記事にまとめています。 qiita.com Hubotとの対話的な入力 リリースするVersion Name、リリース日、QA期間はHubotと対話的に入力するようにしています。今回は、 hubot-conversationモジュール を使用しました。 VersionNameを入力させるには以下のようなコードになります。 // Slackに通知 msg.reply( "リリースするVersion Nameを入力してください。" ); // Version Nameの入力を待機 var dialog = conversation.startDialog(msg); dialog.addChoice( /([0-9]*\.[0-9]*\.[0-9]*)/ , (conversationMsg) => { // 入力された文字の取得 console.log(conversationMsg.match [ 1 ] ); } ); Drive APIでのファイルのパーミッション変更方法 ファイルのパーミッションを変更するには、 Permissions の createメソッド を使用します。 パーミッションは role と type の組み合わせで指定します。例えば、編集権限をvasilyドメインのユーザーに付与するなら以下のように指定します。 { role: "writer" , type: "domain" , domain: "vasily" } google/google-api-nodejs-client では、Request Bodyを resourceパラメータで指定 するのでファイルのアクセス権限を変更するコードは以下のようになります。 drive.permissions.create( { auth: jwtClient, fileId: sheetsId, resource: { role: "writer" , type: "domain" , domain: "vasily" }} , (err, res) => {} ); まとめ 自動化した結果、QAの準備が自動的に完了するようになりとても楽になりました。また、作業の抜け漏れも無くなりスムーズにQAを行うことができています。GitHubのリリース機能でリリース内容を書くのを忘れる事があるのが課題です。 VASILYでは一緒にサービスを開発してくれるエンジニアを募集しています。今後はiQON以外の新規サービスの開発も行う予定ですので、少しでもご興味のある方のご応募をお待ちしています。
アバター
あけましておめでとうございます。 バックエンドエンジニアの塩崎です。 今年の抱負として「テクノロジー系の同人誌を書く!」と言ったら、「アニメの女の子が出てくる漫画」のことだと勘違いされてしまいました。 いつもはiQONに関することを書いているこのTECH BLOGですが、今回の記事はiQONには全く関係のない内容です。 新年会用に低温調理器具を作った話を紹介します。 はじめに 今年のVASILYの新年会は「各地の温かいもの」を持ち寄るという企画を行いました。 しかし、僕は実家に帰らずにアキバ近辺をうろうろしていました。 アキバで温かいもの言ったら、「おでん缶」か「アニメ店長」くらいしか思いつかないため、温かいもの探しに困っていました。 そんな時に、秋月電子でいいものを見つけました。 これです。 アキバ名物(?)メタルクラッド抵抗です。 これに電流を流せばジュール熱が発生するので、お土産の要件は満たしています。 さらに、ボディがアルミ製なので、熱伝導率もバツグンです。 何を作るか この抵抗は人間が食べることができないので、抵抗を使って何か食べられるものを作る必要があります。 お題が「温かいもの」なので、加熱調理を行う調理器具を作ることにしました。 とはいえ、ただ加熱するだけですとティファールの下位互換品です。 なので、この前amazonで調べてみて目玉が飛び出るほど高かった低温調理器具を自作します。 https://www.amazon.co.jp/dp/B00XV556OQ/ 低温調理とは 低温調理とは、タンパク質が変性・凝固する温度である55°Cから68°Cを下回る温度で長時間(1時間〜)加熱することで、肉や魚全体を均一に加熱する調理方法です。 肉が硬くなってしまう温度以下で加熱するため、ぱさぱさにならず調理が可能です。 この温度を長時間維持することを手動で行うのは厳しいため、専用の調理器具が市販されています。 もしくは簡易的に魔法瓶のような断熱性の良い容器に食材を入れて余熱で火を通すということも行われています。 システム全体図 以下にシステムの全体構成図を示します。 また、以下に動作中の写真を示します。 水を入れた鍋の底にメタルクラッド抵抗を配置して鍋を温めます。 湯温は温度センサーによってリアルタイムに監視され、Micro Controller Unit(MCU)が温度を元に半導体スイッチのON-OFFをし、フィードバック制御を行います。 また、温度ログはMCUからPCにWiFiを経由して送られ、温度の時系列的な変化のデータをブラウザで確認できます。 鍋の中に入っている黄色い部品は攪拌用の水中モーターです。 ちなみに、制御基板にはトランスが搭載されていますが、これはAC電源のゼロクロス検知ができたらいいなと思ってつけたはいいが企画倒れしたものです。 無駄に皮相電力を大きくさせているだけの部品です。 ハードウェア部分 まずは、ハードウェア部分の部品選定についてお話しします。 Micro Controller Unit(MCU) まずは一番重要な部品であるMCUです。 MCUの選定には以下のことを重視しました。 CPU、メモリ、IOがワンチップに収まっている WiFiで接続可能 リアルタイム制御が可能 温度センサーとのインターフェースを備えている(SPI、I2C、ADC、etc.) 小型 安価 現在発売されているマイコンでこれらを満たすものとして、ESP-WROOM-02Cを選択しました。 わずか数百円のボードの中にWiFi機能が詰まっています。 さらに、SPI、I2C、ADC機能もそれぞれ1chずつ搭載されています。 プログラムの書き込みはArduino SDKで行うことができます。 これの他に、Arduino、Raspberry Pi、Intel Edisonなども検討しましたが、どれもESP-WROOM-02Cの小型さ、安価さには敵いませんでした。 ヒーター 100Ω 50Wのメタルクラッド抵抗4つを直並列に接続して、100Ω 200Wの抵抗にしました。 ここにAC 100Vを供給し発熱をさせるため、この抵抗での商品電力は100W(実効値)です。 メタルクラッド抵抗と鍋底との間の接着には熱伝導性の良いシリコン接着剤を使用しました。 温度センサー 温度センサーにはADT7310を使用しました。 http://akizukidenshi.com/catalog/g/gM-06708/ MCUとのインターフェースがSPIなためESP-WROOM-02Cで簡単に読み出すことができます。 また、温度校正が不要なため使用するのが非常に楽です。 この温度センサーを温水中に投入するため、ホットボンドで全体をモールドしました。 耐水性試験、耐熱性試験は行っていませんが、今の所は壊れていないので、とりあえずはこれで良しとします。 半導体スイッチ AC 100VのON OFF制御を行いために、トライアックを利用します。 今回はスナバレスタイプを使用し、また、誘導性負荷の制御も行わないため、バリスタは省略しました。 MCUからの制御信号と高電系の間はフォトトライアックで絶縁しました。 フォトトライアックを使用することで、AC 100Vが正極性の時でも負極性の時でもトライアックをONすることができます。 ソフトウェア部分 MCU側 温度センサーから温度を読んでヒーターのON OFF制御を行う部分はミッションクリティカルなため、MCU側に実装しました。 これはWiFi接続が不安定になってしまってもスタンドアローンで動作できるようにするためです。 温度制御 温度センサーからを現在の温度を読み、目標温度以上か以下かによって、ヒーターのON OFFを行います。 void control_heater() { if (temperature > target_temperature) { heater_off(); } else { heater_on(); } } この処理を1秒ごとに処理するためにTickerモジュールを利用しました。 Tickerモジュールは一定時間ごとにメインループとは別の処理を同時に行うためのモジュールで、裏ではタイマ割り込みが使われています。 温度ログ 温度ログの保存にはmilkcocoaを使用しました。 milkcocoaはIoT用のBaaSでシンプルなPubSub APIをそなえています。 milkcocoa以外のBaaSとしてFirebaseやPubNubも検討しましたが、APIのシンプルさと日本語の情報量の豊富さからmilkcocoaを選定しました。 以下のようなコードでmilkcocoaに温度ログをPublishします。 #include <ESP8266WiFi.h> #include <Milkcocoa.h> #define MILKCOCOA_APP_ID "XXXXXXXX" #define MILKCOCOA_SERVERPORT 1883 WiFiClient client; const char MQTT_SERVER[] PROGMEM = MILKCOCOA_APP_ID ".mlkcca.com" ; const char MQTT_CLIENTID[] PROGMEM = __TIME__ MILKCOCOA_APP_ID; Milkcocoa milkcocoa = Milkcocoa(&client, MQTT_SERVER, MILKCOCOA_SERVERPORT, MILKCOCOA_APP_ID, MQTT_CLIENTID); void milkcocoa_data_push() { DataElement elem = DataElement(); elem.setValue( "temperature" , temperature); elem.setValue( "temperature_target" , temperature_target); milkcocoa.push( "nabe" , &elem); } この処理もTickerに登録し、5秒ごとに温度データの送信を行います。 目標温度設定 目標温度の設定部分にもmilkcocoaを使用しました。 先ほどの温度ログの送信機能とはデータの流れが逆で、MCU側がSubscribeします。 目標温度が更新された時のコールバック関数を登録しておきます。 void control_param_changed(DataElement *elem) { float target_val = elem->getFloat( "temperature_target" ); // 小数部がない数値を送るとint型にされてしまうため if (target_val == 0.0 ) { target_val = elem->getInt( "temperature_target" ); } temperature_target = target_val; } void setup() { milkcocoa.on( "nabe_control" , "push" , control_param_changed); } HTTPサーバー MCUにHTTPサーバーを立てて、温度ログを表示するための画面のホスティングを行います。 ESP-WROOM-02CのSDKにはHTTPサーバーのサンプルがあるため、それを利用しました。 #include <ESP8266WebServer.h> ESP8266WebServer server( 80 ); const char * index_html() { return "indexの内容をここに書く" ; } void setup() { server.begin(); server.on( "/" , [](){ server.send( 200 , "text/html" , index_html()); }); } mDNSサーバー このままですと、このサーバーにアクセスするためにはIPアドレスを直に指定する必要があり面倒なので、mDNSサーバーもMCUにたてます。 nabe.local のドメイン名が自分自身に名前解決されるようにします。 #include <ESP8266mDNS.h> void setup() { MDNS.begin( "nabe" ); } PC側 温度ログのグラフ ブラウザでのグラフの表示にはHighchartsを使用しました。 簡単にリッチなグラフを書くことができ、非商用ならば無料で使用することができます。 ページのロード時にmilkcocoaのAPIからデータを取得し、グラフにプロットします。 Highcharts.setOptions( { global: { useUTC: false } } ); var milkcocoa = new MilkCocoa( 'XXXXX.mlkcca.com' ); var nabe_datastore = milkcocoa.dataStore( 'nabe' ); function initial_draw(data_set) { var temperature_nabe = data_set.map( function (d) { return [ d.datetime.getTime(), d.value.temperature ] ; } ); var temperature_target = data_set.map( function (d) { return [ d.datetime.getTime(), d.value.temperature_target ] ; } ); myChart = Highcharts.chart( 'container' , { chart: { type: 'spline' } , animation: Highcharts.svg, title: { text: '鍋温度モニター' } , xAxis: { type: 'datetime' , title: { text: 'time' } } , yAxis: { title: { text: 'temperature (°C)' } } , tooltip: { headerFormat: '<b>{series.name}</b><br>' , pointFormat: '{point.x:%H:%M:%S}: {point.y:.2f} ℃' } , legend: { layout: 'vertical' , align: 'right' , verticalAlign: 'middle' , borderWidth: 0 } , series: [ { name: '鍋温度' , data: temperature_nabe } , { name: '目標温度' , data: temperature_target } ] } ); } nabe_datastore.stream().size(300).next( function (err, data) { var data_set = data.map( function (datum) { return { datetime: new Date (datum.timestamp), value: datum.value } ; } ).filter( function (datum) { // 古すぎるデータは表示しない var diff = new Date ().getTime() - datum.datetime.getTime(); return diff < 3 * 60 * 60 * 1000; } ); initial_draw(data_set); } また、新たなデータが追加された時にはそのイベントをmilkcocoaから取得して、グラフの更新を行います。 nabe_datastore.on( 'push' , function (pushed) { var datetime = new Date (pushed.timestamp).getTime(); var temperature = pushed.value.temperature; var temperature_target = pushed.value.temperature_target; myChart.series [ 0 ] .addPoint( [ datetime, temperature ] , true , true ); myChart.series [ 1 ] .addPoint( [ datetime, temperature_target ] , true , true ); } ); また、このページをホスティングしているMCUは貧弱なため、HTTPリクエスト数はなるべく減らしたいです。 そのため、jsのファイル分離をせずに、1つのHTMLファイル内に全ての処理を書きました。 目標温度の設定 目標温度の設定は以下のコードで行いました。 milkcocoaのAPIがシンプルなおかげでとても簡潔に書くことができました。 function set_target_tamperature() { var temperature_target = parseFloat(document.nabe_controll.temperature_target.value); milkcocoa.dataStore('nabe_control').push({ temperature_target: temperature_target }); } < form name = "nabe_controll" id = "nabe_controll" action = "" > < input type = "number" step= "0.1" min= "50.0" max= "60.0" name = "tamperature_target" id = "temperature_target" value = "55.0" /> < input type = "button" value = "目標温度設定" onclick="set_target_tamperature () ;" /> </ form > 調理してみた結果 とりあえず、牛肉のステーキを作ってみます。 牛肉の両面にクレイジーソルトをまぶした後に、ジップロックに入れます。 調理中に肉からドリップが出てくるので少し多めにまぶすといいです。 ジップロックの中に空気が入ってしまうと熱の通りが悪くなってしまうため、水中に沈めながら空気抜きを行いました。 あとはこれを鍋の中に入れて加熱すればいいだけです。 加熱時間はこちらのサイトを参考にしました。 http://www.douglasbaldwin.com/sous-vide.html 肉の厚みが25mmだったため、55 °Cで2時間45分加熱しました。 加熱が終わった後の牛肉は全体的に茶色になっています。 表面の殺菌と香りづけを兼ねて、表面に焦げ目をつけます。 先ほどの低温調理で中まで十分に加熱されているため、軽い焦げ目がついたらOKです。 表面だけに焦げ目をつけるため、内側はまだ赤いままのミディアムレアな焼き加減です。 食べてみると肉の内側までとても柔らかくジューシーでした。 また、サケの香草焼きも同様に作ってみました。 サケの厚みが20mmだったため、60 °Cで60分間加熱しました。 ぱさぱさになりがちなサケを中までジューシーに調理することができました。 改善点 今回、低温調理器具を作ってみて、温度が振動する問題とmilkcocoaが不安定な問題がありました。 温度が振動する 温度が完全には一定にならず、±0.5°C程度の範囲で振動することがありました。 これは制御対象の系に位相遅れを引き起こすような成分が含まれているためです。 この程度の温度変化であれば実用上は問題ないです。 もっと作り込んで、この振動を消すのならば、PID制御を行うことが考えられます。 milkcocoaが不安定 開発中に何回かmilkcocoaが不安定になることがありました。 詳しく調べてみると、milkcocoaのサーバーが500番台のエラーを返していることがわかりました。 今回は簡単にプロトタイプを作る目的でmilkcocoaを採用しましたが、もっとしっかり作るのならばFirebaseなどのBaaSに乗り換えもありだと思います。 まとめ 今年の抱負は「テクノロジー系の同人誌を書く」なので、この鍋の完成度を上げて製作記を同人誌にするかもしれません。 VASILYでは正月休暇でもモノづくりをしてしまうほど、エンジニアリングが大好きなエンジニアを募集しています。 我こそはというからは以下のリンクからぜひご応募ください。
アバター
こんにちは、iOSエンジニアのにこらすです。 SwiftがiOSの主な開発言語になってから、多くの良いプログラミング習慣が標準になっています。 型安全な設計やコンパイル時のエラー検出が当たり前になりましたが、まだSwiftの型システムを活用せずに、Objective-C時代から残る慣習でランタイムエラーになりやすいところがあります。 今回の記事は、古くてインタフェースが良くないAPIをいかに現代のSwiftプロジェクトに取り入れるかという話です。 古いAPIを使う前に、拡張するかラッパークラスを作ることが必要になるかもしれません。 特に UIFont と NSAttributedString が良くないと思うので二つのライブラリを作りました。 この記事を読みながら付随する UIFont ライブラリと NSAttributedString のライブラリを参考してください。 ここに付随するライブラリがあります: もし気になったらスターをつけると嬉しいです! github.com github.com 例:Selector Swift 2.1 以前は Selector はまだただの文字列で、このAPIデザインには二つのデメリットがありました。 一つはコンパイラが Selector 文字列が本当に存在するメソッド名のチェックをしないため、存在しないメソッド名の場合は実行時にクラッシュしてしまいます。 もう一つの問題は、メソッド名のコード補完が効かないのでミスタイプの可能性が高くなります。 Swift 2.1までの Selector 使い方: // Swift 2.1 navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem : .Add, target : self , action : "addNewMemo" ) 幸いにもSwiftチームがこの問題を解決してくれたので、Swift 2.2でもっと良い構文になりました。 // Swift 2.2 navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem : .Add, target : self , action : #selector(addNewMemo)) Swift 2.2から Selector はただの文字列から型になりました。 #selector(methodName) の methodName が存在しないメソッド名なら、コンパイル時にエラーを検出できます。 この新しい型のおかげで Selector 系のランタイムクラッシュの可能性がなくなりました。 さらにコード補完でメソッド名が出てくるようになったので、このプログラミングインタフェースがもっと使いやすくなりました。 例:UIFont UIFont のコンストラクタは一見簡単ですが、正しいフォント名の文字列をタイプしないといけません。 現在の UIFont コンストラクタを使ってフォントオブジェクトを作ろうとしたら下記のようになります: let font = UIFont(name : "Arial-BoldItalicMT" , size : 12.0 ) ! このインタフェースはあまり良くないと思います。 さきほどの Selector と同じ問題があり、フォント名の文字列を間違っていてもコンパイル時に気付く事はできません。 UIFont は FileManager や URL と違って、実行時にiOS標準のフォントの存在が保証されています。 iOS標準のフォントは有限なので、 enum を使うのが良いと思います。 UIFont で使えるフォントを全て enum にすると、正しいフォント名を知らなくてもコード補完で使いたいフォントを探せます。 extension UIFont { /// Create a UIFont object with a `Font` enum public convenience init ?(font : Font , size : CGFloat ) { let fontIdentifier : String = font.rawValue self . init (name : fontIdentifier , size : size ) } } public enum Font : String { // Font Family: Copperplate case copperplateLight = "Copperplate-Light" case copperplate = "Copperplate" case copperplateBold = "Copperplate-Bold" . . . // Font Family: Bodoni 72 Oldstyle case bodoniSvtyTwoOSITCTTBook = "BodoniSvtyTwoOSITCTT-Book" case bodoniSvtyTwoOSITCTTBold = "BodoniSvtyTwoOSITCTT-Bold" case bodoniSvtyTwoOSITCTTBookIt = "BodoniSvtyTwoOSITCTT-BookIt" } 下記のように UIFont を宣言できます: let font = UIFont(name : .arialBoldItalicMT, size : 12.0 ) ! enum を使えば、コード補完が効くようになります。もし名前が間違っていてもコンパイル時にエラーが検出できます。 例:NSAttributedString NSAttributedString を使ったことあるなら、不愉快な経験をしたことがあるかもしれません。 NSAttributedString を作るとき、属性をDictionaryで指定しますが、キー名と対応する値の型を調べる必要があります。 例えば、 Chalkduster というフォントで、文字色を赤、文字に下線を引く NSAttributedString オブジェクトを作ろうとすると、下記のようなコードになります。 let attributes : [String: Any] = [ NSForegroundColorAttributeName : UIColor.red , NSFontAttributeName : UIFont (name : "Chalkduster" , size : 24.0 ) ! , NSUnderlineStyleAttributeName : 1 , ] let text = NSAttributedString(string : "Hello" , attributes : attributes ) attributes のDictionaryの型は [String: Any] 型なので、 NSFontAttributeName: UIColor.red と書いてしまっても、コンパイル時にエラーを検出することはできません。 属性付き文字列に値を設定するたびに、そのキーの識別子を確認する必要がありますし、キーに対する値のコード補完も効かないため、正しい値を見つけることができません。 このインターフェースは現代のAPI標準と同じではなく、不便だとみなされるべきだと私は考えています。 私たちができることは、単純な薄いラッパーにこのインタフェースをラップすることです。各ラッパーは、各属性に対して明示的に定義された型を持つすべての値を設定するメソッドを持ちます。 let attributes = Attributes { return $0 .foreground(color : .red) .font(UIFont(name : "Chalkduster" , size : 24.0 ) ! ) .underlineStyle(.styleSingle) } "Hello" .attributed(with : attributes ) ここでは、入力された値を使って各属性を設定するためのメソッドを定義しているので、どの型の値が期待されているのか正確に知ることができます。 NSUnderlineStyleAttributeName もマジックナンバーを使用しなくてもよくなりました。 また、アンダーラインスタイルが通常の下線 ( .styleSingle ) であることをはっきりと見て取ることができます。 このような拡張機能のさらなる利点として、異なる属性の単語を含む属性付き文字列を作成することが簡単にできます。 属性付き文字列のための + 演算子を定義すると、連結のための非常に簡単なインターフェースを作成できます。 たとえば、ユーザー名が白で強調表示され、テキストを目立たせるために特別なカーニングを使用して赤いテキストのベースを作成する場合、これは通常の NSAttributedString APIを使用して構築できます。 let attributes : [String: Any] = [ NSForegroundColorAttributeName : UIColor.red , NSFontAttributeName : UIFont (name : "Chalkduster" , size : 14.0 ) ! , ] let userName : String = "@trent" let attributedString = NSMutableAttributedString(string : " \(userName) has commented on your post." , attributes : attributes ) let nameAttributes : [String: Any] = [ NSForegroundColorAttributeName : UIColor.white , NSKernAttributeName : 4.0 , ] attributedString.addAttributes(nameAttributes, range : NSRange (location : 0 , length : userName.characters.count )) このコードでも正しく動作しますが、少し不自由な感じです。 上記のコードを入力している間は、共通の属性ベースを持っていることが明らかです。フォントのような既存の属性に加えて、文字列の特定の部分に追加の属性を適用したいだけです。 最初に基本となる属性を宣言し、基本属性から派生した userName のみにかかる明示的な属性を作成します。 let baseAttributes = Attributes { return $0 .foreground(color : .red) .font(UIFont(name : "Chalkduster" , size : 24.0 ) ! ) } let nameAttributes = baseAttributes.foreground(color : .white) .underlineStyle(.styleSingle) .kerning( 4.0 ) let message = " has commented on your post." .attributed(with : baseAttributes ) let userName = "@trent" .attributed(with : nameAttributes ) messageLabel.attributedText = userName + message 両方の方法で同じ結果が得られますが、後者の方がはるかに単純です。 まとめ 今回は、文字列で値を設定するメソッドの改善方法について紹介しました。 UIKitやFoundationなどの標準の仕組みであっても、使いにくいインターフェースであれば、独自のラッパーを作っても良いと思います。 今回の記事のコード例は、より簡単に操作できる安全なAPIを設計する方法の例ですが、決して唯一の方法ではありません。 私は、今後も既存のAPIを管理しやすくするため、GitHubの既存のプロジェクトなどを見ることを楽しみにしています。 VASILYではiQONを一緒に開発してくれるiOSエンジニアを募集しています。興味がある方は以下のリンクをご覧ください。 https://www.wantedly.com/projects/62340 www.wantedly.com また次回。 にこらす
アバター
はじめに こんにちは、CTOの今村です。 先日弊社のiQONが3年連続でGoogle Play「2016年ベストアプリ」に選ばれました。また、今回 ベストイノベーティブ部門の大賞 を受賞しました。 イノベーティブ部門ということなので、Androidアプリの品質だけでなく、アプリの中にある様々な機能の技術的な取り組みも評価してもらった背景があるのかなと個人的には感じています。 さて、ちょうど先日 Minami Aoyama Night #1 にて、弊社のデータまわりのアーキテクチャについてお話させていただく機会がありました。 今回は2016年12月時点での、機械学習とデータ分析を支えるAWSとGCPを利用したマルチクラウドアーキテクチャについて紹介したいと思います。 最近のデータまわりの取り組み 今年になってからVASILYは過去のテックブログでも紹介したように、データまわりの取り組みを一層強化しています。 機械学習関連の事例 ユーザーの好みに応じたレコメンドエンジン iQONにはfor Youというユーザーの好みを学習してアイテムを推薦する機能があります。 ここにも機械学習やディープラーニングが取り入れられています。 VAEとGANを活用したファッションアイテム検索システム IBIS2016 で弊社が発表した、VAEとGANを応用したファッションアイテム検索システムです。 例えば元画像のワンピースの形を保ったまま、色の違うワンピースなどを検索することができます。 http://tech.vasily.jp/entry/retrieval_using_vae_gan ディープラーニングによるファッションアイテム検出と検索 最近では似たような取り組みをしている事例もちらほら出てきていますが、精度の面ではかなりの高さを誇っています。 http://tech.vasily.jp/entry/detection_and_retrieval ディープラーニングを活用して画像から商品カテゴリを判定する http://tech.vasily.jp/entry/deepleaning_microservice 弊社の強みであるクローラーの部分でももちろんディープラーニングは活用しています。 画像からカテゴリを判定して、自動的にアイテムを分類することでメタデータからだけでは分類しづらいようなアイテムにも対応しています。 このように機械学習関連やディープラーニング関連だけでも様々なことに取り組んでおり、実際のサービスへと展開しています。 マルチクラウドの基本方針 主にAWSをサービスの メインインフラ として、GCPを データ分析/計算のインフラ として利用しています。マルチクラウドと言っていますが、実際はオンプレ環境も利用しています。 基本的な方針としては以下の通りです。 AWSでできることはAWSでやる Pythonを用いたり、Spark(DataProc)を扱ったり、大量の計算を扱う部分はGCPでやる GPUなどクラウドでの値段が高くて、検証中にリソースを常時大量消費するものはオンプレ 最終的な計算済結果はRedisに保存して、WebAPIから高速に呼び出せるようにする 各クラウド間の実装はBigQueryを中心として疎結合を意識する 例えば類似アイテム画像APIのアーキテクチャは以下のようになっています。 機械学習を支えるアーキテクチャ 類似アイテム画像APIのアーキテクチャ こちらはあるアイテムに似ているアイテムを表示する類似アイテム画像APIの例になります。 まず、データ生成には3つのステップが存在しています。そして、それぞれのステップをそれぞれ異なる基盤で行っています。 もう少し詳しくしたのが以下の図です。 まず、オンプレ環境で大量の画像の特徴量抽出を行います。ここはGPUの真価が発揮されるところであり、リソースを大量に消費する部分です。特徴量抽出を行うべき画像のリストなどをBigQueryから取得し、画像をダウンロードして、処理を行います。処理を行って得た特徴量はBigQueryに保存します。 次にGCP内のBatchにて、BigQueryに保存した特徴量を取得し、Pythonで類似度の計算を行います。計算した類似度の結果は再度BigQueryに保存します。 次にAWSにてBatch処理で、BigQueryから計算済の類似アイテムのリストを取得し、APIから参照するRedisに保存します。 あとはアプリケーション側からそのRedisを参照することによって、高速に類似アイテムを表示することができます。 上記の3ステップの中心となる存在はご覧の通り BigQuery です。このようなシステムを組む場合、リトライや冪等性を意識した仕組みにする必要性があったり、ジョブの関連性が複雑になったりしがちですが、それぞれの基盤からBigQueryを介することによって、システム全体としての結合度を下げて運用しやすくしています。 タスク制御 それぞれの基盤でバッチ処理を安全に運用するためにはワークフローマネージャーは必須といえます。弊社では、主に以下の2つの製品を利用しています。 AirFlow 弊社では AirFlow をメインのワークフローマネージャーとして使っています。運用して約1年ほどになりますが、今のところ問題なく動いています。もともとはAirbnb社が開発したものですが、3月にApache IncubatorのOSSになっています。 Luigi 最近ではSpotify社が開発した Luigi も使い始めました。Airflowは高機能すぎるため、シンプルにジョブ制御を行いたい場合に利用しています。 データ分析を支えるアーキテクチャ 次にデータ分析を支えるアーキテクチャの紹介です。 施策の数値確認やダッシュボード 弊社では、アプリ内行動ログやユーザーデータの分析などあらゆるデータの分析を常に行っています。 現在上図のようなビューが約300以上存在し、様々な施策のKPIを日々確認しています。 いくつかの重要なビューは、レンダリングされたグラフとともにSlackの全体チャンネルに日々自動的に共有されるようになっています。 ログデータまわりのアーキテクチャ アプリ内行動ログや、マスターデータ、あらゆるログデータ関しては、全て BigQuery に保存しています。 アプリ内行動ログの取得にはAndroidはCookpad社製の Puree 、iOSは独自のSDK(Yabaimo)を用いています。 ログを待ち受けるサーバー部分は自分達でサーバーを用意してfluentd経由でBigQueryに保存しています。昔はLocalyticsを使っていたのですがサポート面の遅さや料金の問題もあり、今は利用していません。 どのBIツールがいいのか? 最近は Google Data Studio 、 re:dash 、 superset 、など様々なBIツールが登場してきており、どれを採用していいか迷うことが多いかと思います。 上記の表は2016年12月時点での弊社の独自の調査に基づくものです。 Tableu Desktop + Tableau Server 弊社ではデータ分析には Tableau Desktop + Tableau Server の組み合わせを使っています。 高度な分析を必要とするデータサイエンティストやエンジニアには Tableau Desktop 、さらに彼らが作ったダッシュボードを他の職種の人たちに共有して閲覧できるように Tableau Server を利用しています。データサイエンティスト以外の人たちは、Tableau Server経由で共有されたKPIやダッシュボードを閲覧します。 最近は無料のものから有料のものまで、様々なBIツールが登場していますが、BIツールを選ぶ際に重要視していることは、 BigQueryに対応していること と ツール自体にバグがないこと です。重要指標を多く含むため、ツール自体にバグがあって数値がうまく表示されなかったりすると誤った判断をしてしまうかもしれない為、安定性を重要視しています。安定性の観点ではTableauはサポートも充実しており、弊社環境でしか起こらないようなバグなども全て対応してもらっています。 まとめ 上記をまとめたものを以下のスライドにしてありますので、ぜひ御覧ください。 現時点ではAWSやGCPもそれぞれのプラットフォームで得意なものが違うので、 うまく使いこなして、運用負荷とコストを削減していくのが今のところベストなソリューションかなと感じています。 また、今はAWSとGCPを主に利用していますが、AzureにもGPUインスタンスが登場しましたし、 今後はAzureの検証も進めていければと思います。 総括になりますが、今年はVASILYとして、弊社の持つ膨大な量のファッションのデータを使った取り組みを色々と強化することができました。 来年も引き続き、機械学習や深層学習をはじめとするAI分野に投資をしていきたいと思います。 データを使って面白いことがしてみたい、研究もしたいし、プロダクトに自分の技術を使ってユーザーに価値を届けてみたい、そんな気持ちを持っているデータサイエンティストの方々、ぜひご応募お待ちしています。
アバター
フロントエンジニアの茨木です。 皆様はCSSを書く際にコーディング規約を意識しているでしょうか。かつて、弊社にはCSSのコーディング規約が存在せず、CSSファイルの肥大化・クラス命名規則の不統一が発生していました。メンテナンスが難しくなってきた為、1年半ほど前にCSSコーディング規約を設けました。若干のルール追加を伴いながら、現在まで問題なく運用できています。本記事ではフロントエンドで運用しているCSSのコーディング規約に関して紹介します。 導入環境 本記事では以下の環境を前提にしています。 Ruby on Rails 5.0 Sass 3.4 Slim 3.0 CSSコーディング規約のコンセプト 初めにチームでヒアリングを行い、以下のようなコンセプトを決定しました。 読みやすい、書きやすいクラス名やタグ構造にする スタイルを再利用できる セレクタによるスタイルの競合を少なくする 既存の設計手法であるBEM *1 やSMACSS *2 などの検討を初めに行いました。検討の結果、最もコンセプトに近かったSMACSSを基に、チームの実情に即したルールを独自で設けることにしました。 CSSコーディング規約の概要 上記の3つのコンセプトに基づき、それぞれに対応する以下のルールを設けました。 ルール1: 読みやすく書きやすいクラス名やタグ構造にするためのルール ルール2: スタイルを再利用するための構成要素のルール ルール3: スタイル競合を少なくするためのルール 構成要素のルールはSMACSSのそれにかなり近いですが、他のルールは独自で定義したルールです。 これから、それぞれのルールについて紹介していきます。 ルール1: 読みやすく書きやすいクラス名やタグ構造にするためのルール 読みやすい、書きやすいクラス名やタグ構造にするためのルールです。タグ構造自体はHTMLの話ですが、CSSのコーディングに直接関わる部分なので規約に含めています。クラス名やタグ構造に関しては3つのルールを設けています。 ネスト構造をクラス名に含めない & クラス名はハイフン区切り 読みやすさ・書きやすさのためにネスト構造をクラス名に含めないことにしました。2単語以上のクラス名の場合は、CSSの言語仕様に則ってハイフン区切りで単語を区切ります。 class.html.slim // 良い例 section . popular-users ul li . user span . name hogehoge span . age 25 // 悪い例 section . popular-users ul . users // 以下3行はネスト構造が含まれている li . users-user span . users-user-name hogehoge span . users-user-age 25 タグやクラス名をセマンティックに タグやクラス名をセマンティックにするために、以下の3つのルールを設けています。 文章構造に適したタグを可能な限り使う HTML5で定義されたheader、footer、navといったセマンティックなタグを積極的に使うようにしています。 クラス名は、必要最小限・セマンティックに命名する クラス名は、タグで表現しきれない意味を表すために必要最小限だけ振るようにしています。特にタグとクラス名の意味が重複しないように注意しています。 divやspanを使う場合は、必ずクラス名を振る divやspanはタグ自体が意味を持たないので、必ずクラス名を振って意味を与えるようにしています。デザイン上必要なdivには .wrapper など目的が分かるクラス名を与えています。 correct_tag.html.slim // 良い例 section . popular-users // デザイン目的のタグであることをクラス名で明示している . wrapper ul li . user span . name hogehoge span . age 25 // 悪い例 . popular-users // divには必ずクラスを振らなければならない div // タグとクラス名の意味が重複している ul . list li . user // spanには必ずクラスを振らなければならない span hogehoge // 文章構造が分からないクラス名 span . small-number 25 文書構造上意味のないタグを可能な限り削減する 擬似要素などを積極的に使い、文書構造上意味のないタグを可能な限り削減しています。 HTML自体がかなり読みやすいものとなるのは勿論、タグが減少することによりスタイル競合のリスクが減少します。 擬似要素が役に立つ例として、以下の画像のような矢印付きリンクボタンを紹介します。 矢印をimgタグで定義することは勿論可能です。しかし、矢印は文書として意味を持たないので、本来はHTML文書内に無い方がセマンティックです。このような場合に、タグ内にCSSで要素を追加できる擬似要素が有用です。以下にコードを示します。 link_with_arrow.html.slim ul . links li a href= '/page1' page1 li a href= '/page2' page2 link_with_arrow.sass ul . links li list-style : none border-top : solid 1px #999 width : 200px & : last-child border-bottom : solid 1px #999 a display : block height : 50px line-height : 50px position : relative text-decoration : none // 擬似要素:afterにより右矢印のスタイルを定義 & : after content : '' display : block position : absolute top : 0 right : 10px width : 14px height : 50px background : url( //cdn.hoge.fuga/images/right_arrow.png ) no-repeat center background-size : 6px 12px ルール2: スタイルを再利用するための構成要素のルール スタイルの再利用性を高めるために、ベース・モジュール・レイアウト・ユーティリティ・ステートという5つの構成要素を定義しています。ベース・モジュール・レイアウト・ステートに関しては、SMACSSと基本の考え方は同一です。ユーティリティはモジュールと類似していますが、スタンドアローンでない点がモジュールと異なります。ユーティリティは規約策定当初モジュールに含まれていました。運用していく中でモジュールの書き方が多様になってきたので、ユーティリティを別に定義しました。 ベース 各ページ共通のスタイル、各タグの基本となるスタイルを定義します。 _base.sass header width : 100% h1 font-size : 30px input border : solid 1px #ccc モジュール 画面上で再利用されるひとまとまりの要素に適用されるスタイルです。スタンドアローンで成り立ちます。 トップレベルに .m-モジュール名 のクラスを必ず持つことを規定しています。mixinにする場合は、後述のユーティリティとの区別のため、 m- から始まる名称にします。 _mixins.sass @mixin m-items ul . m-items li . item img width : 100% . name font-size : 20px . . . レイアウト 画面上の大枠のレイアウトを指定するための要素に適用されるスタイルです。 l- で始まるクラス名で定義します。 _base.sass . l-sub float : left width : 100px . l-main margin-left : 120px ユーティリティ 頻繁に使用されるスタイルの集合です。スタンドアローンでない点がモジュールと異なります。スタンドアローンでないことを明確にするために、必ず @mixin で定義すること、一切セレクタを持たないことを規定しています。この構成要素は当初の規約にはありませんでしたが、運用過程で暗黙に使われていました。次第にモジュールと混同されるようになってきたので、ルール化しました。 _mixins.sass @mixin circle( $diameter ) width : $diameter height : $diameter border-radius : $diameter / 2 util.sass @import 'mixins' . rank-badge @include circle( 20px ) background-color : #ccc ステート ステートは状態に応じてスタイルを上書きするためのクラスです。適用元のクラスを存置したままマルチクラスで定義します。ステートのクラス名は、適用元要素のクラス名を原則含みません。 state.sass . m-like-button width : 100% // ステート & . active background-color : #ccc border : solid 1px #999 state.html.slim ul . users li . user . m-like-button . active いいね済み li . user . m-like-button いいねする ルール3: スタイル競合を少なくするためのルール 1ビュー1CSSのファイル構成 Railsではデフォルトで全CSSがapplication.cssにまとめられますが、スタイル競合のリスクが高まります。そのため、本規約ではビューごとにCSSの定義ファイルを別にしています。各ページ共通で用いるスタイルは別ファイルで定義し、 @import で読み込むようにしています。 /app/assets/stylesheets/ │ ├ shared/ │ │ │ ├ _base.sass │ ├ _mixins.sass │ └ _reset.sass │ ├ users/ │ │ │ ├ show.sass │ ├ create.sass . . . . . . show.sass @import 'reset' @import 'base' @import 'mixins' ルートでのクラス名/要素名単独のセレクタは禁止 ルートのセレクタはかなり影響範囲が大きいです。ルートでクラス名や要素名を単体で指定した場合には予期せぬ要素にスタイルを定義してしまう危険が高くなります。そのため、ルートでのクラス名/要素名単独のセレクタを禁止しています。このルールは、ベース・モジュール・レイアウト・ユーティリティには適用しません。このルールは当初暗黙のルールでしたが、今後メンバーが加わることを想定してルール化しました。 root_selector.sass // 良い例 section . popular-articles h2 font-size : 30px // 悪い例1 . popular-articles h2 font-size : 30px // 悪い例2 section h2 font-size : 30px まとめ 規約により、読みやすさ・書きやすさ・再利用性が向上し、メンテナンスがとても楽になりました。BEMのような厳格な命名規則ではありませんが、セレクタやファイル構成の規約のおかげでスタイルの競合はほとんど発生しません。途中、若干のルール追加を伴いながら1年半運用できており、当初の目的は達成されたといえます。成功の要因としては、安全性と読みやすさ/書きやすさの双方を考慮した点、コードレビューを徹底している点が挙げられます。 読者の皆様も、CSSの実装中にスタイルの競合や再利用性の問題に遭遇することはよくあると思います。そのような場合、CSSの書き方だけでなくファイル構成やタグ構造も見直すことが解決につながるかもしれません。また、規約を継続運用するためには、安全性だけでなく読みやすい・書きやすいという点も重要かもしれません。 最後に VASILYではWebの力で世の中を変えたいエンジニアを募集しています。 興味がある方は是非こちらからご応募ください。 *1 : Methodology / BEM https://en.bem.info/methodology/ *2 : Scalable and Modular Architecture for CSS https://smacss.com/
アバター
こんにちは。 モルトとシガーで生きてます。インフラエンジニアの光野(@kotatsu360)です。 先日、crontabで管理しているバッチ処理の監視にhorensoというツールを導入したのですが、 監視の品質が向上 毎分届く大量の実行結果メールから開放されQoL向上 という効果がありました。本日はその取り組みについてご紹介いたします。 ジレンマ:動作監視と大量のメール 冒頭の通り、VASILYでは定期的に実行したいバッチ処理をcrontabで管理しています。 真新しさはありませんが、実行時間の指定が簡潔かつ柔軟で未だに愛用しています。 2016年12月現在、crontabは120行ほどです。 さて、そんなcrontabによる処理で前々から課題になっていたのが、 crontabで管理しているスクリプト達(以下cronスクリプト)の動作監視です。 当時、VASILYのcronスクリプトは3箇所に結果を出力していました。 (ログファイル・スクリプト・歯車・メールの画像クレジット *1 ) 1. のログは調査用、 2. のSlack通知は異常検知、 3. のMAILTOは過去の動作記録とそれぞれ役割をもっているのですが、ここで問題になるのは 3. です。 ご存知の通り、crontabにてMAILTO要素にメールアドレスを設定しておくと、cronスクリプト実行後にメールを送信してくれます。 お手軽で便利なのですが、cronスクリプトの量が増えてくるとspamよろしくメールが届きます。 正直、邪魔です。しかし、「動いた」という記録を取る意味では大変有用なため、日々大量のメールを受け取り続けていました。 問題発生:解決手段の模索 そんな状況をしばらく容認していたのですが、ある日メールがパタリと届かなくなるという問題が発生したため、一念発起、改善に着手します。 ちなみにメールが届かなくなった原因ですが、なんということはなく、あまりに量が多いため迷惑メールフォルダに分類されていたのでした その際、手段選定の条件としたのが次の3つです。 特別な実行環境が不要 既存のスクリプトをそのまま使う 監視と本処理を独立させる 1. と 2. については、短期間で移行を完了するためです。 最初は「はやりのリッチなUIをもつジョブ管理システムを導入だ!」とも考えたのですが、 既存のスクリプトを短期間かつ安全に移行することを考えるとあまり現実的ではないと思い止めました。 3. については、cronスクリプトをこれ以上肥大化させないためです。バッチ処理のスクリプトはユニットテストもしづらく、 監視のような「運用上は必要だけれども、スクリプト自体の処理には必要ない機能」を持たせたくありません。 そして、これらの条件を満たせるものとして horenso を採用しました。 horenso horensoはSongmuさんが開発された各種コマンド用のラッパーツールで、 スクリプト実行結果の「報告」をつかさどってくれます。 Goで実装されており、各プラットフォームに対応するバイナリを置くだけで利用可能です。バイナリを配置後、以下のように実行します。 $ /path/to/horenso --reporter report.rb -- /usr/bin/ruby /path/to/batch/script.rb arg1 arg2 -- の後ろにあるのが、既存のcronスクリプトです。 このようにして実行すると、 --reporter で指定したスクリプトがcronスクリプトの実行結果をJSONで受け取ります。 { " command ": " /usr/bin/ruby /path/to/batch/script.rb arg1 arg2 ", " commandArgs ": [ " /usr/bin/ruby "," /path/to/batch/script.rb "," arg1 ", " arg2 " ] , " output ": " message \n error ", " stdout ": " message ", " stderr ": " error ", " exitCode ": " 0 ", " result ": " command exited with code: 0 ", " pid ": " 13813 ", " startAt ": " 2016-12-05T06:32:01.123456789+09:00 ", " endAt ": " 2016-12-05T06:32:11.987654321+09:00 ", " hostname ": " cron.iqon.example.com ", " systemTime ": " 0.716 ", " userTime ": " 5.812 " } 後はこのJSONをreporterのスクリプトで好きなように扱えば完了です。reporterには任意の実行権限がついたファイルを指定できます。 詳細な使い方は、SongmuさんのブログエントリやGithubのREADMEをご覧ください。 horensoというcronやコマンドラッパー用のツールを書いた | おそらくはそれさえも平凡な日々 Songmu/horenso: Command wrapper for reporting the result. It is useful for cron jobs. cron監視ver2.0 horensoを使うことによってcronスクリプトの監視方法は以下のように変わりました。 (ほうれん草の画像クレジット *2 ) crontabの中では、以下のサンプルのように呼び出します。 0 0 * * * /path/to/horenso --reporter horenso.rb -- /usr/bin/ruby /path/to/batch/script.rb args... 用意したhorenso.rbの動作は大きく2つです。 exitCodeが0以外、またはstderrに出力がある場合、Slackに通知 ログをfluentdにフォワード このログは最終的にBigQueryとS3に保存され記録されます。 これにより、ほぼspamと化していたメールを廃止することができ、かつ動作記録が確実に記録できるようになりました。BigQueryにもインサートされているため、参照も非常に容易です。 なお補足をいたしますと、horenso.rbに集約したSlack通知はあくまでも監視を目的とした通知です。 個々のcronスクリプトの仕様として必要なSlack通知は、そのスクリプトで実装することにしています。 導入初期に発生した諸々 horensoによって、劇的にモダンになったcron監視環境ですが、導入初期にはいくつか問題も発生しました。 cronスクリプト自体を直したほうが良いものもありますが、原則既存のスクリプトに手を入れず対応しています。 実行ディレクトリについて 幾つかの古いスクリプトでは内部で相対パスを使っており、 cd /path/to/dir ; ./batch/script.rb というような呼び出し方をしているものがあります。 しかし、horensoでは -- 以降で、 cd をしても意味がありません。 そこで、次のように移動した上でhorensoを呼び出すことで解決しています。 cd /path/to/dir && (horenso --reporter horenso.rb -- bin/rails runner -e production batch.rb) stderr 当初、exitCodeのみを基準に実装をしていたのですが、例外ケースがあり正しく検知できていませんでした。 VASILYのcronスクリプトはRubyですが、幾つかのスクリプトでは `` でシェルコマンドを呼び出しているものがあります。 シェルコマンドが失敗した場合、Rubyとして例外は出力されないため、exitCode=0(ただし失敗している)ということになります。 そのため、内部で失敗を検知し損ねているケースを考え、今のreporterスクリプトではstderrの有無も確認するようになっています。 BigQueryのクォータ 2つめの図の通り、horensoで受け取ったログは最終的にBigQueryとS3まで送られます。fluentdからBigQueryへはストリーミングインサートをしているのですがここには各種の制限が存在します。 割り当てのポリシー | BigQuery のドキュメント スクリプトによっては大量のログを出すものがあり、それらが一度のリクエストに重なると制限に引っかかる恐れがありました。 現在は、ログを次のように加工してからフォワードしています。 outputを削除 outputはstdoutとstderrを合わせたもの stdout/stderrを1KBで切り詰める startAt/endAtをISO 8601形式に変換 commandArgsをJSON文字列に変換 なお、後半の2つはあとで扱いやすくするだけのものなので、BigQueryの制限とは関係ありません。 切り詰める前のログはサーバ側に残っているため、どうしても全てのログを見たいケースがあればそちらを見ます。 まとめ モダンなcron監視環境についてご紹介しました。horensoを使うことで既存のcronスクリプトをそのままに、 動作記録を参照しやすい形で保存することが可能になりました。またhorensoによって、実行時間など これまで見えていなかった情報も得られるようになりました。今後はこの環境を活かしてcronスクリプト自体の改良も行いたいと考えています。 最後に VASILYではiQONを一緒に作っていく仲間を募集しています。 興味のある方は以下のリンクからご応募お願いいたします。 *1 : logfile / script icon: Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY gear icon: Icons made by Gregor Cresnar from www.flaticon.com is licensed by CC 3.0 BY mail: Icons made by Nikita Golubev from www.flaticon.com is licensed by CC 3.0 BY *2 : Icons made by Madebyoliver from www.flaticon.com is licensed by CC 3.0 BY
アバター
Solr 6でneologdが組み込まれたkuromojiを使う方法 こんにちは、VASILYバックエンドエンジニアの塩崎です。 VASILYでは商品情報の全文検索を行うためのバックエンドに、Apache Solr(以下、Solr)を利用しています。 先日、Solrのメジャーバージョンを最新の6にアップグレードしました。 それに伴ってSolrの形態素解析エンジンであるkuromojiに新語辞書であるmecab-ipadic-neologd(以下、neologd)を組み込みました。 この記事では、組み込むことのメリット及び、具体的な組み込み方を紹介します。 kuromojiにneologdを組み込むことのメリット では、まずkuromojiにneologdを組み込むことのメリットを、転置インデックスを利用した全文検索の仕組みに基づいて説明します。 転置インデックスを利用した全文検索の仕組み Solrは全文検索を行うために、転置インデックスと呼ばれるインデックスを生成します。 これは、文章中に登場する単語をキーとし、その単語が出現した文章IDの配列をバリューとする連想配列です。 Solrはこの転置インデックスを用いることで、文章全体をスキャンすることなく、全文検索を行います。 その結果として、膨大な文章に対する全文検索を高速に行うことができます。 全文検索のインデックスに形態素解析を用いるときの欠点 さて、先ほどの例のような英語(?)の文章では単語と単語の間が空白で区切られていました。 一方で日本語の文章は単語境界に空白がありません。 そのために、日本語の文章に転置インデックスを作るためには、空白文字に頼らずに文章を単語単位に分解する必要があります。 この単語分解の方法には大きく分けて2つの手法があります。 機械的にn文字ずつに単語に分解するngramと、文法的に意味のある単位で単語に分解する形態素解析です。 ngramは機械的に文章を分割するために、検索ノイズが多いという欠点があります。 (例: "京都"で検索をした時に、東京都がヒットしてしまう) そのため、iQONでは形態素解析を用いた転置インデックスの生成を行い、それを使い全文検索を行っています。 しかし、形態素解析を使った転置インデックスの作成にも欠点があります。 それは形態素解析エンジンが対応していない単語に対しては、正しくインデックスを作成できないということです。 kuromojiの標準辞書では、カバーされている単語が少ない kuromojiに標準搭載されている辞書はIPA辞書です。 この辞書は2007年を最後に更新が止まっているため、それ以降に作られた単語に対しては正しく形態素解析を行うことができないケースが多いです。 また、辞書を作る時の元データがファッション用語に乏しいため、iQONに登場するファッション用語に対応できないケースがあります。 例えば、以下のような単語は辞書に登録されていません。 ロクシタン スナイデル neologdを組み込むことで、辞書の語彙力を底上げ この問題に対応するためには、全文検索を行うアプリケーション毎に、必要な語彙を辞書に追加する必要があります。 しかし、その作業はとても地道で時間の掛かるものです。 そこで、@overlastさんのmecab-ipadic-neologdをkuromojiに組み込むことを考えました。 この辞書は、以下の公式ドキュメントに書かれているように、Webの情報から新語を追加しています。 mecab-ipadic-NEologd は、多数のWeb上の言語資源から得た新語を追加することでカスタマイズした MeCab 用のシステム辞書です。 https://github.com/neologd/mecab-ipadic-neologd/blob/master/README.ja.md また、辞書の更新ペースも非常に早く、週に2回も辞書が更新されています。 もちろん、これを行えば全ての単語に対応できるわけではありません ですがkuromojiの標準辞書だけを使うことに比べて、大幅な語彙力の向上が見込まれます。 組み込み方 ソースコードの入手 今回はgithubに公開されているlucene-solrにパッチを当ててneologdを組み込みます。 https://github.com/apache/lucene-solr なお、具体的なパッチの当て方は、moco(beta)さんや@kazuhira_rさんの以下の記事を大変参考にさせていただきました。ありがとうございます。 http://mocobeta-backup.tumblr.com/post/114318023832/neologd-kuromoji-lucene-4-10-4-branch http://d.hatena.ne.jp/Kazuhira/20150316/1426520209 これらの記事を元に、Solr6用のパッチを作成しました。 パッチは以下のGistで公開してあります。 https://gist.github.com/shio-phys/02ece250ecbc291e1ec4923ed119d182 また、このパッチを適用させたソースコードをgithubに公開してあります。 https://github.com/vasilyjp/lucene-solr また、ビルド済みのjarファイルもgithubに公開してありますので、とりあえず試したい方はご利用ください。 2016/12/02時点での最近のneologdを組み込んでいます。 Solr 6.2.0用 Solr 6.2.1用 Solr 6.3.0用 これらのファイルはneologdのバージョンアップには追従していないので、バージョンアップが頻繁なneologdの特性を活かすためには、その時点での最新のneologdを利用することをお勧めします。 ビルド 必要な環境 まずは、以下のドキュメントを参考に、neologdのビルドに必要なパッケージをインストールしましょう。 https://github.com/neologd/mecab-ipadic-neologd#getting-started その後に、kuromojiをビルドするためのパッケージをインストールしましょう。 Ubuntu 16.04.01であれば、以下のパッケージが必要です。 $ sudo apt-get install default-jdk ant subversion jarの生成 パッチ適用済みのリポジトリを取得し、使用しているSolrのバージョンに合わせたブランチをチェックアウトします。 2016/12/02現在、以下のブランチを用意しています。 使用しているSolrのバージョンに合わせたブランチをチェックアウトしてください。 kuromoji_neologd_6.2.0 kuromoji_neologd_6.2.1 kuromoji_neologd_6.3.0 $ git clone https://github.com/vasilyjp/lucene-solr.git $ cd lucene-solr $ git checkout <ブランチ名> build.propertiesに書かれているneologdのバージョンを最新のものに合わせます。 以下のディレクトリに mecab-user-dict-seed.*.csv.xz というファイルがあるので、このファイル名のタイムスタンプ部分をneologd.versionに指定します。 https://github.com/neologd/mecab-ipadic-neologd/tree/master/seed $ vim lucene/build.properties あとは、以下のコマンドを順番に実行することで、kuromojiのjarが生成されます。 $ ant ivy-bootstrap $ ant compile $ cd lucene/analysis/kuromoji $ ant clone-neologd $ ant build-dict $ ant jar-core 以下のディレクトリにjarが生成されます。 lucene/build/analysis/kuromoji/ jarのファイル名は以下のようなものになります。 実際にはSolrのバージョン名の部分とneologdのバージョン名の部分が変わり得ます。 lucene-analyzers-kuromoji-ipadic-neologd-6.3.0-SNAPSHOT-20161128.jar Solrに読み込ませる 上記の手順によってつくられたjarファイルを以下のパスに配置することで、Solrに読み込ませることができます。 server/solr-webapp/webapp/WEB-INF/lib クラス名を変えていないため、もともとあったjarを上書きする必要があります。 読み込まれたことの確認 さて、このjarが正しく読み込まれたことをsolrのweb consoleで確認してみましょう。 solrのweb consoleにはtokenizeの結果をその場で確認する機能があるので、それを使って確認します。 Core Selectorから適当なcoreを選択した後に、Analysisを選択します。 テキストボックス内に文章を打ち込んで、Analysis Valuesボタンを押すと、その文章の解析結果が表示されます。 以下の例では「ロクシタン」という固有名詞が1つの単語として認識されていることが分かります。 neologdを組み込んだことによる問題と、その対処 ブランド名の部分一致検索ができなくなった neologdを組み込むことで辞書の語彙が大幅に増えましたが、増えすぎることによる問題も発生してしまいました。 複数語からなるブランド名を部分一致検索できなくなってしまいました。 例えば JIMMY CHOO というブランド名が1つの単語としてtokenizeされるために、 JIMMY という検索クエリでヒットしなくなってしまいました。 この問題に対しては、ngramでtokenizeしたフィールドに対しても同時にクエリを投げることで解決しました。 ngramでtokenizeしたフィールドを併用することによって、このような検索漏れを無くすことができました。 まとめ VASILYではiQONを一緒に作っていく仲間を募集しています。 興味のある方は以下のリンクからご応募お願いいたします。
アバター