オウチーノ開発者ブログ

「株式会社オウチーノ」の社員によるブログです。

DTPデザイナーからwebデザイナーにジョブチェンジした話

オウチーノ デザインチームの奈良です。

私は現在webデザイナー・UI/UXデザイナーとしてオウチーノの不動産サービスに携わっているのですが、元々は弊社が発行する不動産フリーペーパーの制作を行うDTP(グラフィック・エディトリアル)デザイナーでした。

冊子型フリーペーパーの他にもペラのチラシやダイレクトメールに加え、不動産店舗のディスプレイ用ののぼりやステッカーなどもデザインしていました。

花火大会で無料配布するうちわ型広告なんてものもありましたね。

紙媒体を主戦場としていた私ですが、使い慣れたミリメートルの物差しをピクセル定規に持ち変えてwebデザイナーへジョブチェンジするには多くのハードルがありました。そんな悪戦苦闘の一部を綴りたいと思います。

DTPとの基本的な違い

f:id:kenichi_nara:20181227184332j:plain
投函ツールはポスト内で目立つかが重要。紙質とクリエイティブの相性も気にしていました。

さあwebデザインをするぞといってもできることは限られます。

幸いオウチーノには優秀なフロントエンドエンジニアがおり、デザイナーはディレクターの作成したワイヤーフレームにしたがってまずは見た目のデザインをすればよかったため、愚かな私は軽い気持ちで何の知識も入れずにDTPデザインのやり方で制作に取りかかりました。

DTPデザインで使用するアプリケーションといえばQuarkXPress(今や懐かしの)やIllustratorやPhotoshop、InDesignといったレイアウトソフトが有名どころかと思います。

私は普段Illustratorでレイアウト、Photoshopでフォトレタッチを行っていたため、迷いなくIllustratorでwebページのデザイン作業を進めました。作業が一通り完了しフロントエンドエンジニアへデザインデータをお渡しする段になったとき、事件は起きました。

フロントエンドエンジニア「これはコンテンツ幅は何ピクセルなんでしょう」

私「ん??」

フロントエンドエンジニア「え???」

ディスプレイで表現されることから考えれば当たり前にわかることなのですが、そもそもwebページの描画がピクセル単位でされていることを認識していませんでした。

そしてその当時Illustratorではピクセルへのスナップが不完全で、プロパティで数値を指定してもオブジェクトのエッジがわずかに滲んでしまう現象がまま見られるなど、web制作に適しているとはいえなかった(現在では機能向上に伴いIllustratorでwebデザインをされている方もそれほど珍しくないようです)のもよろしくありませんでした。

とはいえPhotoshopでのレイアウト経験もなく相当の時間を食ってしまうため、Illustratorでデザインしたものをpsdデータに変換してPhotoshopで整形する…というようなことを行いその場は凌ぎましたが、それ以外にもカラーモードや画像の解像度、画像とテキストの扱いの別など基本的な部分での勝手の違いに大きな戸惑いを感じ、そもそもDTPとは別モノであることを思い知らされたのでした。

webデザインの壁

f:id:kenichi_nara:20181226112640j:plain

苦い経験を胸に恐る恐るwebデザインの扉を開けてみると、そこには得体の知れない暗号の海が広がっていました。

html、css、JavaScriptです。

これがなんなのか、どんな役割を持ったものたちなのかを理解し、使いこなせなければ話になりません。

その辺のスキル習得の方法はいろいろあろうかと思いますが、私は何冊かの入門書を読んだくらいでほとんどOJTに近い方法で身につけていきました。 html・css・JavaScriptはDTPデザイナーからの転向の際にぶつかる大きな壁だと思うのですが、最近ではフロントエンドエンジニアと分業であることも珍しくないためゴリッゴリに極めなくともなんとかなったりします。

ちなみにオウチーノではエンジニアがコーディングを担当してくれるケースが多く、デザイナーはよりデザインに特化して動ける環境が整っています。 私の拙いコーディングスキルでどうにかなっているのは、まさしくこのチーム制作環境のおかげといえるでしょう。ありがたや。

そしてDTP歴が長ければ長いほど感じるのが、デザインそのものの壁です。

特にフォント・カーニング・トラッキングなどテキストまわりの不自由さは、DTPデザイナーからするとやりたい表現が実現できず不満が募ります。さらにhtml+cssでの表現の仕方やユーザーエージェントによる見え方の違いなどを考慮する上で制約が多く、非常に窮屈に感じることでしょう。

けれどwebデザインの魅力はなんといってもギミック。マウスの動きに応じてボタンが光ったり画面がスクロールしたり、Googleのマテリアルデザインをはじめぬるぬる動くサイトもよく見ますよね。

これまで2次元のデザインをしていた私は、自分でつくった画面が実際に操作できることに興奮を覚えました。 そしてそれはまた新しいデザインの扉だったのです。

マインドを切り替える

f:id:kenichi_nara:20181226112653j:plain

チラシなどの場合、伝えたい要素に優先順位をつけて要素の大小や色のコントラストの強弱、紙面における割合などを勘案しアウトプット(レイアウト)をしていきます。このあたりの定石はwebデザインにも応用ができると思いますが問題はこの先。

紙媒体が一方的に伝えたいことを投げかけるのに対し、webではユーザーがなんらかのレスポンスをすることが重要なのでレスポンスしやすいデザインが求められるわけですが、そのプロセスで私は今まで使ったことのない頭の使い方をすることになりました。

  • ユーザーがwebサイトを利用するタイミングはいつか
  • ユーザーはどういう目的を持って訪れているのか
  • その目的を達成するために、どのような情報や仕組みが必要か

などなど。

どのように目を留めさせるか、どのように購買意欲を刺激するか、という言わば「攻める」とか「仕掛ける」といったスタンスでデザインをしてきた私ですが、「受け止める」「寄り添う」という真逆の気持ちに切り替える必要がありました。

「体験をデザインする」ということ

では「受け止める」「寄り添う」デザインとはなんなのか?

「UX」という言葉が普及して久しいかと思います。

ざっくり言うと「ユーザーがサービスに触れて得られる体験のこと(検索すればもっと詳しいサイトが山ほど出てきます)」ということですが、ではそれをデザインする、つまり

ユーザーがサービスに触れて得られる体験をデザインする

というと、ものすごくフワッとしませんか?
少なくとも私の脳内では音もなく舞いました。

ともすればどこかの企業のキャッチフレーズにも聞こえてくる「体験をデザインする」という概念。私はこれをオウチーノの物件検索サービスの設計を通して学ぶことができました。

例えば新しいwebサービスをつくるブレストをしたときに、

  • 「お気に入り機能が欲しい」
  • 「地図は必須」

のような具体的な機能って出てきがちですよね。 しかし「体験」はそういった様々な機能を使った後の話なので、

  • 「気になった物件を後から見返したい」(→ だからお気に入り機能が欲しい)
  • 「物件の位置関係を把握したい」(→だから地図は必須)

のように機能の前に「欲求」が先にあるわけです。

ここからはあくまで私が理解しやすい形での解釈なのですが、

UXデザインというのはユーザーの、コレほしい、アレしたい、という「欲求」の動きを予見したうえで、しかるべきタイミングで解消する方法を提供する言わば...

我儘お嬢様に対しての執事的なアプローチ...!

陳腐な例えですが、体験をデザインする「体験」がない私にとってはざっくりと飲み込みやすいものでした。

これが腹落ちすると

  • ユーザーってどんな人を指すの?
  • ユーザーの欲求を集めよう
  • ユーザーの欲求を繋げて動きを見てみよう

といったペルソナだとかユーザーインタビューだとかカスタマージャーニーマップなどの必要性も必然的に理解でき、実践していくことでUXデザインの概要を知ることができたのでした。

f:id:kenichi_nara:20181228103655j:plain
開発当初の画面。一番左は実装に至らなかったボツ案です(デザイン供養)。

まとめ

同じデザイナーなので重なる部分も多いと思われがちですし、実際お互いがお互いの仕事をまったくできないかと言われるとそうでもなかったりもするんですが、私の体感だとDTPデザイナーとweb(UI/UX)デザイナーはまったく別の職業だと感じています。

個人的には苦労の多かったジェブチェンジ体験ではありましたが、不動産ポータルサービスを展開する上で新たな視座が得られたことは財産になりました。

これからwebデザインを始めようとしているDTPデザイナーの方や、現在webデザイナーだけどDTPもやってみたいよという方の何かの参考になれば幸いです。

join datatypeとnested datatypeの比較

こんにちは、オウチーノの山本です。この記事はくふうカンパニー Advent Calendar 2018の17日目として書いています。

はじめに

以前の記事 ( 漸進的なシステムリプレイス(STTMeetup システムリプレイスNight))内の資料でも触れているのですが、オウチーノでは物件の検索にElasticsearchを、Elasticsearchへの登録には、Embulkを使用しています。

このElasticsearchに登録している物件の情報ですが、階層構造を持つ必要があるので、現在は Nested datatype を使用しています。

# Embulkのconfigから一部抜粋
estate:
  _all:
    enabled: false
  dynamic: false
  properties:
    estate_id:
      type: keyword
  traffics:
    type: nested
    properties:
      traffic_id:
        type: keyword

nested datatypeを使う場合、estate(物件の基本情報)とtraffics(最寄駅などの交通機関の情報)は、1つのドキュメント上に存在する必要があるのですが、RDBのテーブル上は別々ですので、

  1. RDBの物件テーブルからestateを登録
  2. 交通情報テーブルを元にtrafficsを追記

のように、2回Embulkを実行して作成しています。

しかも追加+更新なので、並列で実行する事ができません

いやいや、それは普通だろう、と思われる方もおられるかも知れませんが、Elasticsearchには join datatypeというものが存在するので、別々に登録したドキュメント間で階層構造を作ることが出来ます。

ただ、公式ドキュメントに以下のような記述があります。

The join field shouldn’t be used like joins in a relation database. In Elasticsearch the key to good performance is to de-normalize your data into documents. Each join field, has_child or has_parent query adds a significant tax to your query performance.

このように釘を刺されていたので、今まで使っていませんでした。
それでも、最近、並列で実行したいという思いが高まってきたので、試しにやってみる事にしました。

前提

今回、検証を行なった環境は以下の通りです。

  • Embulk
    • v0.9.6
    • 実行はローカル(MacBook Pro 13-inch 2016 Core i7 3.3GHz 16GB High Sierra)
    • input-plugin
      • embulk-input-mysql
    • output-plugin
      • embulk-output-elasticsearch_using_url
  • Elasticsearch
    • AWS Elasticsearch service 6.3

Embulkでjoin datatypeを登録

まず最初に、少なくとも以下の条件をクリアするプラグインが必要だったのですが、見つかりませんでした。

  1. 項目の値をJsonでパースする必要がある
    • {"traffics": "{\"name\": \"traffic\", \"parent\": 1}"} こうではなく
    • {"traffics": {"name": "traffic", "parent": 1}} こうしたい
  2. メタ項目である_routing を指定する必要がある

1.embulk-output-elasticsearch_using_urlで解決できるのですが、2.がどうしても解決できなかったので、とりあえず修正しました

後は、階層構造で記述していた定義を平坦化して、join datatypeの項目を追加すれば、登録できるようになります。

  • 平坦化してjoin datatypeを追加
# Embulkのconfigから一部抜粋
estate:
  _all:
    enabled: false
  dynamic: false
  properties:
    estate_id:
      type: keyword
    traffic_id:
      type: keyword
    traffics:
      type: join
      relations:
        estate: traffic
  • 親ドキュメントはtrafficsestateを指定
# estate のconfigから一部抜粋
filters:
  - type: column
    add_columns:
      - {name: traffics, type: string, default: "estate"}

out:
  type: elasticsearch_using_url
  bulk_actions: 2000
  request_timeout: 300
  id_format: "%d"
  id_keys:
    - "estate_id"
  routing_format: "%d"
  routing_keys:
    - "estate_id"
  • 子ドキュメントはtrafficstrafficと、親ドキュメントのIDを設定。
# traffic のconfigから一部抜粋
filters:
  - type: column
    add_columns:
      - {name: traffics, type: string, default: ""}
  - type: ruby_proc
    requires:
      - json
    columns:
      - name: traffics
        proc: |
          ->(col, record) do
            { name: "traffic", parent: "#{record['estate_id']}" }.to_json
          end
        type: string

out:
  type: elasticsearch_using_url
  bulk_actions: 2000
  request_timeout: 300
  id_format: "%d-%d"
  id_keys:
    - "estate_id"
    - "traffic_id"
  routing_format: "%d"
  routing_keys:
    - "estate_id"
  array_columns:
    - {name: traffics, parse_json: true, error_skip: true}

実際に、物件を12万件ほど登録してみたところ、結果は以下の通りでした。

nested join
実行時間(estate + traffic) 2:05 + 1:52 2:02 + 2:24
データサイズ 102.2MB 85.6MB

join datatypeの場合、nested datatypeに比べて、交通情報の登録に時間がかかっていますが、並列で動かせるので問題はなさそうです。 データサイズが小さくなるのは意外でした。

検索速度の検証

登録はできたので次は検索を実行します。
まずはnested datatypeのインデックスに対して、駅徒歩10分の物件を検索してみます。

GET nested_estate/_search
{
  "query": {
    "nested": {
      "path": "traffics",
      "query": {
        "range": {
          "traffics.walk_time_station": {
            "lte": 10
          }
        }
      }
    }
  }
}

平均6ms程で結果が返ってきます。

{
  "took": 6,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 30616,
    "max_score": 1,
    "hits": [
      {

次にjoin datatypeのインデックスに対して、同じ検索をしてみます。

GET join_estate/_search
{
  "query": {
    "has_child": {
      "type": "traffic",
      "query": {
        "range": {
          "walk_time_station": {
            "lte": 10
          }
        }
      }
    }
  }
}

平均13ms程。確かにnested datatypeに比べたら遅いです。倍くらいの時間がかかっています。

{
  "took": 13,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 30616,

色々検索条件を変更してみましたが、nested datatype は1桁msで返ってくるのですが、join datatypeはひどい時だと50msほどかかったりしました。
しかし、Embulkが並列実行できるのは結構なメリットなので、これくらい目を瞑るかと思っていた所、ある問題に気づきました。

返却値の違い

nested datatype は1つのドキュメントに全てのデータが入っているので意識していなかったのですが、join datatypeestatetrafficが別のドキュメントなので、返ってくる値にtrafficが入っていない事に気づきました。

    "hits": [
      {
        "_index": "nested_estate",
        "_type": "estate",
        "_id": "1",
        "_score": 1,
        "_source": {
          "estate_id": 1,
          "traffics": [     <---- この部分が返ってこない
            {
              "walk_time_station": 16,
              "traffic_id": 1
            },
            {
              "walk_time_station": 5,
              "traffic_id": 2
            }
          ]

これはいけない、という事でtrafficの値も返ってくるようにオプションをつけたのですが、

GET join_estate/_search
{
  "query": {
    "has_child": {
      "type": "traffic",
      "query": {
        "range": {
          "walk_time_station": {
            "lte": 10
          }
        }
      },
      "inner_hits": {}  <-- inner_hits option
    }
  }
}

検索で対象となったものしか返ってきません。

    "hits": [
      {
        "_index": "join_estate",
        "_type": "estate",
        "_id": "1",
        "_score": 1,
        "_source": {
          "estate_id": 1,
        "inner_hits": {
          "traffic": {
            "hits": {
              "total": 1, <-- 1件しか返ってこない
              "max_score": 1,
              "hits": [
                {
                  "_source": {
                    "estate_id": 1,
                    "walk_time_station": 5,
                    "traffic_id": 2,
                }
              ]

inner_hits オプションを別の所につけたらいいような気がして、試しにmatch_allを追加してみた所、

GET join_estate/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "has_child": {
            "type": "traffic",
            "query": {
              "range": {
                "walk_time_station": {
                  "lte": 10
                }
              }
            }
          }
        },
        {
          "has_child": {
            "type": "traffic",
            "query": {
              "match_all": {}  <-- match_all を追加
            },
            "inner_hits": {}
          }
        }
      ]
    }
  }
}

返ってくるようにはなったのですが、平均で20msほどかかるようになってしまいました。

{
  "took": 20,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 30616,
    "max_score": 2,

まとめ

nested datatypeに比べ、最終的に3倍以上の時間がかかるようになってしまったので、確かにjoin datatypeは遅いです。
並列で実行したいと思っていましたが、今回はちょっと見送る事にしました。

ただ、検索速度の遅延が問題にならず、データ投入の時間を短縮したい場合には非常に有効な手段だと思いますので、今後そういう機会があれば、再チャレンジしたいと思います。

Web画面を印刷するための基本知識とハック

どうも。オウチーノの田淵です。この記事はくふうカンパニーアドベントカレンダーの8日目として書いています。

2018年10月に、住宅・不動産専門サイトを運営する弊社と、結婚式場紹介口コミサイトを運営するみんなのウェディングが経営統合し、共同持株会社「くふうカンパニー」が設立しました。くふうカンパニーは現在、結婚関連事業、不動産関連事業の他、金融関連事業、デザイン・テクノロジー事業、投資・起業家支援事業を行っています。

はじめに

私は現在、不動産営業支援ツール「くらすマッチ」を開発しています。
くらすマッチは不動産会社・不動産店舗が簡単に物件周辺の暮らしに関する情報を調べることができ、住宅を探しているユーザーニーズに合わせて提案できるツールです。

Webだけでも完結するサービスなのですが、物件情報と合わせて周辺情報の資料をお客様にお渡ししたいというユーザー(不動産営業)ニーズが高いため以下のような印刷機能を開発しました。

  • ブラウザでプレビューできる
  • ブラウザの印刷機能を使って資料を作成できる

開発時にいざ印刷してみると各要素のスタイルが満足に反映されませんでした😓
印刷のスタイルにはブラウザとは一味違う指定が必要だったので、ここで少しまとめてみます。

前提

ターゲットにしたブラウザ

Chrome、IE11、Edge、Firefox
(ツールの特性上、ユーザー数が多いブラウザをターゲットに開発しています)

用紙サイズ

A4 縦

まずは印刷のための基本

  • メディアタイプを知る
    all すべての機器(ブラウザ、印刷 etc...)
    print 印刷プレビュー
    screen ブラウザ
    他にもありますが割愛。
    メディアタイプ(MDN)

  • CSSファイル内では@mediaを使いブラウザと印刷で適用されるスタイルの条件を振り分ける

@media screen { ブラウザだけに適用する }  
@media print { 印刷プレビューだけに適用する }  
@media print, screen { ブラウザ・印刷プレビューどっちにも適用する }  

@media 規則の例(MDN)

  • CSSの読み込み
<link rel="stylesheet" media="all" href="hoge.css" />

メディアクエリにmedia="all" が指定されるようにします。
(HTMLで省略した場合の既定値はallですがHamlなどを使っている場合はデフォがscreenだったりするので要注意)
メディアクエリのついた条件付きのリソース読み込み(MDN)

A4サイズのページを作る

  • ページサイズの指定
    印刷するページのスタイルには@pageで指定します。
    今回はA4の縦ページにしました。
@page {  
  size: A4 portrait;  
  margin: [ページの余白を指定];  
}

@page(MDN)

  • 改ページの指定
    レイアウトされたページごとに印刷したいので指定の位置で改ページするようにします。ページブレイクのCSSプロパティは3種類あるので駆使して理想の位置で改ページさせます。
@media print {
  .pageBreaker {
    page-break-before: always;
  }
}

page-break-before(MDN)
page-break-after(MDN)
page-break-inside(MDN)

ブラウザ仕様が異なる

上述の印刷用のメディアクエリやプロパティを使うことで印刷時にもブラウザと遜色なくレイアウトでき、スタイルも適用することができます。
ですがご想像の通り、ブラウザによって印刷時のスタイルの仕様は異なります。
Web画面として表示するときの仕様ともまた異なります。

一番困るのが、印刷時に背景色が表現されないこと。
ブラウザによって対応方法が違います。

Chrome

背景色を表示するには-webkit-print-color-adjust: exact;を指定する必要があります。
また表示したい背景色には!importantも必要です。

body {
  -webkit-print-color-adjust: exact;
  background: #F4F4F4 !important;
}

IE11

背景色を表示するにはユーザーがブラウザの印刷プレビュー上のページ設定で背景の色とイメージを印刷するのチェックボックスをONにしないと表示されません😓

Edge

Edgeは背景色が表示されません。
(」゚O゚)」< Edgeは背景色が表示されません。
方法を探し回りましたが、↓↓の回答に行き当たりました。マイクロソフトサポートの公式回答のようです。
Windows10 EdgeでWeb上の画面を印刷すると背景の色や罫線が印刷できません。

まことに恐れ入りますが、Microsoft Edge には Internet Explorer のように背景を印刷する設定箇所がありません。 そのため、背景を印刷される場合は IE をご利用頂くようお願いいたします。

現在でも設定できそうな箇所は見当たりませんでした😭

Firefox

背景色を表示するには表示したい背景色に!importantが必要です。

background: #F4F4F4 !important;

また、ユーザーがブラウザの印刷設定でアピアランス > 背景色をプリントのチェックボックスをONにしないと背景色は表示されません😓

Edgeの背景色問題をどうしたか

背景色background-colorが表示されないEdgeでもborderや画像は表示されるようです。
background-imageを指定してbackground-colorの代替とする手法がWeb検索するとヒットしましたが、カラーごとの画像を用意するのは変更に弱い印象を受けました。

当時は開発中のためデザイン的にも容易に変更できるほうが望ましかったのです。
幸いレイアウトも単純だったためborder-colorを利用して背景色を表現することにしました。

一例としてタイトル行に背景色をつける例。

HTMLとCSSはこんな感じ。

<div class="titleBackground"></div>
<h3>
    <span>No</span>
    <span>コンビニ</span>
    <span>距離</span>
</h3>
@media print {
  .titleBackground {
    border-bottom: 20px solid #272736 !important;
    margin-bottom: 10px;
  }

  h3 {
    height: 20px;
    font-size: 8px;
    margin-top: -30px;
  }
}

背景色の代わりにborder-bottomの線幅を欲しいサイズに指定しています。
その下に用意したタイトルになるテキストをネガティブマージンでborderに重ねてタイトル行の背景色を表現しました。

Googleマップからマーカーがはみ出る問題

サービスの特性上、周辺情報を表示するためにマップは不可欠です。当然、紙面にもマップが印刷されます。
今はGoogle mapを利用しています。
スーパーやコンビニ、公園、保育園などの周辺施設の位置をカスタムマーカーで表現しています。

地図の縮尺によってはレイアウト内に収まらないマーカーが出てきます。当然、Googleマップではレイアウト外になったマーカーは表示されない動作になります。

これがIE11では動きが異なります。
レイアウト外になったマーカーがマップ外に表示されてしまいます。
印刷時にもマップ外の領域に突然マーカーが表示されている状態になるので困ります。

Google mapはデフォルトで下記のようなマークアップがされています。
いろいろ試したところ、class="gm-style"のdiv要素にoverflow: hiddenを指定してあげることでこの問題は解消できます。(しかも他のブラウザでの悪影響は発生しません)

<div class="map" style="position: relative; overflow: hidden;">
  <div style="height: 100%; width: 100%; position: absolute; top: 0px; left: 0px; background-color: rgb(229, 227, 223);">
    <div class="gm-style" style="position: absolute; z-index: 0; left: 0px; top: 0px; height: 100%; width: 100%; padding: 0px; border-width: 0px; margin: 0px;">
      <div tabindex="0" style="position: absolute; z-index: 0; left: 0px; top: 0px; height: 100%; width: 100%; padding: 0px; border-width: 0px; margin: 0px; cursor: url(&quot;https://maps.gstatic.com/mapfiles/openhand_8_8.cur&quot;), default; touch-action: pan-x pan-y;">
      <div style="z-index: 1; position: absolute; left: 50%; top: 50%; width: 100%; transform: translate(0px, 0px);">
      <div style="position: absolute; left: 0px; top: 0px; z-index: 100; width: 100%;">
      <div style="position: absolute; left: 0px; top: 0px; z-index: 0;">
<以下割愛>
.gm-style {
  overflow: hidden;
}

まとめ

今後も細かい調整や各OS、各ブラウザへの対応をブラッシュアップしていきたいと思います。

Web画面をPDF化せずに印刷するにはブラウザの印刷機能を使うしかなく、JavaScriptからも制御できない部分が非常に多いです。
ページごとのヘッダー・フッター表示などユーザーの印刷設定による部分が大きいので、技術的にできることは少ないがユーザーがストレスなく印刷できるための操作説明などをうまく見せることも必要かと感じました。

普段、Web画面と戦っているエンジニア・デザイナーが画面を印刷したいと言われたときに困らないよう、基本的なところをまとめてみました。

明日(9日)は@ken1flanさんの「ニャーQL勉強会(エンジニアじゃないひとたちとやったSQL勉強会)」です。

Rails+ReactなSPAサイトでSEOをしようとしてぶつかった壁

Rejectcon 2018 レポート記事

こんにちは。オウチーノ吉川です。

去る9/29にRejectconで「Rails+ReactなSPAサイトでのSEO」について登壇してきました。 SPAサイトでSEOを行う際にぶつかった壁と、それをどのように乗り越えていったかの経緯をご紹介しました。

ちょっと内容詰め込みすぎて途中途中端折ってしまったのと、発表後にもいろいろ質問などをいただいていたのであわせて補足しながらご紹介します。

発表資料はこちらです。

なぜ経緯を紹介したのか?

昨今のフロントエンド技術の進化速度は非常に速く、どんどん新しい技術が登場しています。 記事などでどういった技術が登場しているのかは抑えているが、実際に全てちゃんと触ってはいないという方も多いのではないでしょうか。

SSRをするためにはreact-railsやhypernovaがある、ということを知っているだけでは、いざ使うとなったときにどちらを導入すべきか判断できません。 Dynamic Importsについても、利用ケースを知らなければなぜこんな面倒なことをするのか、という印象を持つかもしれません。

今回の対応を進めるにあたって、対応を進めるに連れて昔読んだ記事にたどり着いて、最初からヒント読んでたのに!となったことが何度もありました。 そのため背景をお伝えすることが重要だと考えました。

React-HeadによるSSR

時間がなくて端折ったところです。

React-HeadはSSR対応head管理ツールです。コンポーネントとしてmetaタグなどを設定しておけば、head部分に適切にタグを挿入してくれます。 と、CSRの場合はそれで良いのですが、SSRの場合は全体のレイアウトを別途持っているはずで、その適切な場所に入れ込む必要があります。

React-Headのアプローチは、HeadProvider にArrayを渡しておけば、コンポーネントとしてセットされたmetaタグなどを集めてArrayに入れておいてくれます。そのArrayのみを別途renderToString して差し込んでやるというアプローチです。 これ自体もなかなか複雑なアプローチですが、あくまでNode.jsを想定したアプローチで、hypernovaの場合はそのmetaタグコンポーネントを保持しているのがhypernova、テンプレートはRails側にあるためプロセスをまたいで渡す必要が出てきます。 そこでhypernovaがレンダリングしたルートコンポーネント部分のhtmlにカスタムタグとしてくっつけてRailsに返し、Rails側で文字列操作で適切な場所に入れ替えるという力技で対応しています。

結局SSRはSEOのために必要だったの?

比較検証ができていないので断言はできませんが、SEO上は必須ではないと考えています。 オウチーノの事例ではSSRを導入してもインデックス状況は改善せず、パフォーマンス改善によってインデックス状況が改善しました。 またSSRしないと駄目だというならそもそも全くインデックスされないはずです。 現状もパフォーマンス改善のためにSSRを活用していますが、これはSSR導入済みだったためそれを活用しただけで、必須だったわけではありません。

SSRにすることでのメリットもありますが、メンテナンスコストは高めです。 一番のコストはSSRが壊れないようにすることです。Universal JSでない実装をしてしまって壊すことは日常的にありえますが、 SSRに失敗していてもCSRになるだけなので手元で開発していても気づかない場合があります。 現状はE2Eテストを使ってリリース前になるべく気づけるようにしてはいますが完璧というわけではありません。 またオウチーノの場合はhypernovaを利用しているため、hypernovaサーバーを別途運用する必要があります。 Docker化していてECSで運用しているためすごく手間というわけではありませんが・・・

RailsでSSRするのに疲弊しているように見えるけれど、Railsにのせる必要はあるの?

技術的な側面から言えば必要ではありません。エコシステムとの親和性を考えると、サーバー側はRailsではなくNode.jsがやりやすそうではあります。

一方でオウチーノは現在「全体をモノリスにする」という方針で進めています。 このあたりの背景については以前の記事をご参照ください。 https://developers.o-uccino.com/entry/2018/08/30/152838

今後チームが成熟するにつれてSPAは独立させることも検討しています。

sagaやReact-Headの部分は、あらかじめRails側でpropsを作って渡す形にすればシンプルになるのでは?

最初からSSRを想定した設計にするならそういったアプローチも可能だと思います。 SSRを想定しておらず、routingもすべてReact側にやらせている設計だったのと、SSRにすることで問題が解決するかどうかわからない状況下で 大きく設計を変更すべきでないと判断しこういった対応になりました。

これからそういった設計に変えることもできるのですが、どちらかというとSSRしなくて良い状態にできればベターと考えています。

E2Eテストはどうやってやっている?

変更がマージされた後まずRSpecやJestなどによる各種テストを行っています。 RSpecのfeature specとしてCapybara + Headless Chrome によるテストを行っています。 ただしこの段階ではhypernovaサーバーは使っておらずCSRによるテストです。

テストが通った後asset compileやdocker buildを経てstagingに自動デプロイされます。 stagingへのデプロイ後にE2Eテストをstagingに対して実行しています。 こちらはCapybara + PhantomJSを利用しています。

メジャーシナリオに対して期待した操作ができることに加え、SSRが実行された上でのhtmlが返却されていることや、意図通りのメタタグが出力されていることなどをテストしています。 このE2Eテストが通ったdocker imageに対してタグを付与しており、productionへはこのタグが付与されているものしかデプロイできないようになっています。

なおここでPhantomJSを利用しているのは苦肉の策です・・・ 以前Chromeでは問題ないのにSearch Console上ページがレンダリングされないというエラーが発生したことがあり、 PhantomJSだけがそのエラーを再現できたためこういった構成になっています。 どうやらGoogleのクローラーが利用しているChromeは最新というわけではないらしく、最新のHeadless Chromeを利用していると気づけないケースでした。 ただし現在PhantomJSの開発は停止しているため、Headless Chromeに戻すべきかどうかは検討中です。

このあたりのビルドパイプラインまわりはまた改めて記事にできればと思います。

最後に

いかがだったでしょうか。今後もオウチーノではユーザーにより使いやすいサービスを提供するために技術的なチャレンジを進めていきたいと考えています。 オウチーノでは一緒に働くメンバーを募集中です

GrafanaとPrometheusとTwilioを使ってサーバー監視システムを構築する

オウチーノのSREチームの尾形です。

今回はオウチーノのサーバー監視の仕組みについてご紹介したいと思います。

経営方針が変わる前のオウチーノのサーバー監視は全て外部会社にお願いしていまして、その時は個別の依頼ベースで監視対象を追加・削除してもらっていました。自分達で管理していない状態なので、監視項目に漏れがあったり柔軟な設定が出来ない、Opsへの認識が薄くなるなどの問題がありました。そこで自分達のシステムは自分達で面倒を見ようということで去年から監視をする仕組みを構築し始めました。外部会社ではZabbixを利用していたので、その設定をそのまま頂くことも出来たのですが秘伝のタレを頂いても管理が出来ないということでゼロから構築することを決めました。
SREチーム以外にも見てもらえるグラフを表示出来るツールということでGrafana+Prometheusで構築しました。オウチーノではAWSを利用しているため、AWS CloudWatchで取得できる情報はAWS CloudWatchに任せたりしています。

Grafana + Prometheusの構築方法については今回割愛します。

監視の仕組み

GrafanaにDashboardを表示し、オウチーノドメインのGoogleアカウントを持っている人なら誰でも見ることの出来る状態にしています

CloudWatchMetrics

こんな感じにDashboardを用意し、開発者に気軽に見てもらえるようにしています。もちろん開発者がDashboardやグラフを作ることも可能です。
各グラフにアラートの閾値を設定し、閾値を超えたものに関してグラフ付きでSlackに通知します。

SlackNotification

PrometheusではEC2上に乗っているMySQLのメトリクスやヘルスチェック、EC2のCPUやメモリなど様々な値を収集しています。
現在はまだ出来ていませんが、コンテナ単位のメトリクス取得もする予定です。

Prometheusのデータ収集は各種Exporterを利用しています。監視をOFFにしたい時もあるので、その場合はEC2のTagで制御しています。
prometheus.ymlに以下のような設定値を追加し、PrometheusNodeExporterタグがenabledのものだけを収集するようにしています。

  - job_name: ec2-node-exporter
    ec2_sd_configs:
      - region: ap-northeast-1
        refresh_interval: 60s
        port: 9100
    relabel_configs:
      - source_labels: [__meta_ec2_tag_PrometheusNodeExporter]
        regex: (enabled)
        action: keep
      - source_labels: [__meta_ec2_tag_Name]
        target_label: name

サーバーの追加

Itamaeを利用してサーバーを構築しています。
(ItamaeはChefを簡潔に記載出来るように作られたOSSです。)
共通化したレシピを用意してあるので、各サーバーのレシピを書く際に単純にincludeすればexporterが入ります。

e.g.

include_recipe "../../cookbooks/prometheus-ec2-exporter/default.rb" # この1行を追加するだけ
include_recipe "../../cookbooks/timezone/default.rb"
include_recipe "../../cookbooks/hostname/default.rb"

execute "apt-get update"

...

構築後expoterが起動すると設定に則りメトリクスが収集されます。共通化されたレシピを利用している場合は既にGrafana上にグラフがあるので、特別な設定をしなくてもグラフ上に構築したサーバーが表示され、アラート監視の管理下に入ります。
ItamaeのレシピはGit管理下にあるため、GitHubのPR時のレビューで監視の漏れがないかをチェック出来、1度レシピを作ればSREチームでなくとも簡単にサーバーを構築・監視下に置くことが可能です。

オンコールの仕組み

Slack通知だけでは夜間・休日に気づくことが難しくなります。なのでSlack通知の他に電話通知もしています。
電話発信にはTwilioという音声通話をAPIでコントロールできるサービスを利用し実現しています。
大まかな流れは以下のようになります。

  • Googleカレンダーに予め第1窓口、第2窓口の人を週替わりで登録しておく
  • Prometheusがアラートを検知
  • Googleカレンダーに登録されている第1窓口の人に電話
    • 電話を受けた第1窓口の人はチャットボットに対応しますコマンドを投げる
  • 約10分第1窓口の人から応答がなければ第2窓口の人に電話

ここからは実現方法を簡単に説明していきたいと思います。

Googleカレンダーに予め第1窓口、第2窓口の人を週替わりで登録しておく

Google Spread Sheetsにマスターを登録して、日々スクリプトでカレンダーを更新しています。
ここではオンコールだけでなく、トイラーも登録しています。トイラーについては前回のブログのCTOのスライドをご参照ください。

OncallMasterSheets OncallCalendar

スケジュールを更新するスクリプトはGoogle Apps Scriptを利用していて、Google Apps Scriptのスケジューラ機能で日々Google Spread Sheetsのマスターベースに最新のカレンダーに更新します。順番を変更したい場合はGoogle Spread Sheetsをいじってもらえれば次の日には次週以降の情報が更新されます。
(スクリプトの内容については目を瞑って頂けると٩( ᐛ )و

var spreadSheetUrl = 'https://docs.google.com/spreadsheets/xxxx';
var calendarId = 'XXXX'

var columnDefinitions = { 'オンコール': 1, 'トイラー': 2 }

function users(target) {
  var column = columnDefinitions[target];
  var spreadSheet = SpreadsheetApp.openByUrl(this.spreadSheetUrl);
  var sheets = spreadSheet.getSheets();
  var sheet = spreadSheet.getSheetByName('シート1')
  
  var row = 2; // 1行目は列タイトルなので2行目から
  
  var targetUsers = [];
  while(true) {
    var user = sheet.getRange(row, column).getValue();
    if(user == "") {
      break
    }
    targetUsers.push(user);
    row++;
  }
  return targetUsers;
}

function previousMonday() {
  var today = new Date();
  return new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay() + 1);
}

function calenderEvents(seachQuery) {
  var startTime = previousMonday(); // 月曜始まり
  var endTime = new Date(startTime.getFullYear(), startTime.getMonth() + 4, startTime.getDate());
  return CalendarApp.getCalendarById(calendarId).getEvents(startTime, endTime, {search: seachQuery});
}

function removeAfterNextWeekEvents(target) {
  var events = calenderEvents(target);
  if(events.lentgh == 0) {
    return;
  }
  events.slice(1, events.length).forEach(function(element) {
    element.deleteEvent();
  });
}

function addCalender(target) {
  var oncaller = users(target);
  var primaries = oncaller.slice();
  var secondaries = oncaller.slice();
  var findIndex = 0;
  var endTime = previousMonday(); // 月曜始まり
  
  var events = calenderEvents(target);
  if(events.length != 0) {
    var lastEvent = events.pop();
    findIndex = oncaller.indexOf(lastEvent.getTitle().match(/Primary: (.*), Secondary: (.*)/)[2]);
    endTime = lastEvent.getEndTime();
  }
  
  // rotate
  for(var i = 0; i < oncaller.length; i++) {
    if(i < findIndex) {
      primaries.push(primaries.shift());
    }
    if(i < findIndex + 1) {
      secondaries.push(secondaries.shift());
    }
  }
  var calendar = CalendarApp.getCalendarById(calendarId);
  
  for(var i = 0; i < primaries.length; i++) {
    calendar.createEvent('【' + target +  '】 Primary: ' + primaries[i] + ', Secondary: ' + secondaries[i],
                    new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate() + 7 * i),
      new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate() + 7 * (i + 1)));
  }
}

function upsertOncaller() {
  removeAfterNextWeekEvents('オンコール');
  addCalender('オンコール');
}

function upsertToiler() {
  removeAfterNextWeekEvents('トイラー');
  addCalender('トイラー');
}

function upsert() {
  upsertOncaller();
  upsertToiler();
}

Prometheusがアラートを検知

Prometheusにアラートを設定して、アラート発生時のアクションにWebhookを登録することで電話通知サービス(Sinatra製サービス)をキックします。
これらの設定はGrafanaのGUIから設定しています。

http://docs.grafana.org/alerting/notifications/ NewNotificationChannel

http://docs.grafana.org/alerting/rules/#notifications SetAlertNotification

Googleカレンダーに登録されている第1窓口の人に電話

キックされた電話通知サービスがGoogleカレンダーから窓口の人の名前を取得しに行き、名前から電話番号を引いてTwilioのAPIをコールします。
電話発信をした後、第1窓口の人が未対応という状態を保存します。

Googleカレンダーの取得

GoogleカレンダーをAPIで取得するにはOAuth2の認証が必要なので、電話通知サービスのデプロイ時に手動で認可しています。
(ここが自動化のネックになっているのでどうにかしたい・・・ナイスな案をお持ちの方は是非教えてください!)
アラートが全く鳴らない幸せな世界が続いた場合にリフレッシュトークンが切れるので何かしらのリカバリー手段が必要なのですが、幸い(?)アラートは少なくとも週1で発生しているのでまだ問題に直面していません _:(´ཀ`」 ∠):...

電話の発信

TwilioのAPIをコールしています。

TwilioのAPIは公式ドキュメントが充実しているので、そちらを参照して頂くのが良いと思います。
SDKのサポートしている言語の種類も豊富で困ることは少ないと思います(201809時点ではGoが非サポートです)。

公式の電話発信のRubyサンプルは以下のようになっています。

# Get twilio-ruby from twilio.com/docs/ruby/install
require 'twilio-ruby'

# Get your Account SID and Auth Token from twilio.com/console
account_sid = 'ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
auth_token = 'your_auth_token'

# Initialize Twilio Client
@client = Twilio::REST::Client.new(account_sid, auth_token)

@call = @client.calls.create(
  url: 'http://demo.twilio.com/docs/voice.xml',
  to: '+14155551212',
  from: '+15017122661'
)

puts @call.sid

オウチーノでは電話でアラートに気づけばいいよねということでサンプルボイスをそのまま流していますが、自前の音声ファイルを流したりテキストを読ませるということもTwilioではサポートしています。電話に出るのが楽しくなるようなアイデアを募集中です!

状態の保存

サービスのサーバのローカルファイルに状態を記載しています。
DBなどを利用するのがいいのでしょうが、手抜きしました v^^v

電話を受けた第1窓口はチャットボットに対応しますコマンドを投げる

チャットボット経由で未対応状態を解消します。

e.g. @ruboty ack

これはチャットボットがack(acknowledge)コマンドを受けたら電話通知サービスに対して状態削除APIをコールしています。
これは発信したユーザーを特に見ていないので、アラートに気づいて対応できる人がいたら代わりに投げてくれたりします。

ちなみにオウチーノではチャットボットにrubotyを利用しています。

約10分第1窓口の人から応答がなければ第2窓口の人に電話

cronで定期的に状態を確認し、アラートが発生してから10分間応答がない場合に第2窓口の人に電話します。
電話をかける方法は第1窓口の人に電話をした時と同じです。

今後の課題

途中にも書きましたがGoogleのAPIを呼ぶ周りをもう少し整理したいのと、現状アラートアクションのトリガーとなっているPrometheusと電話発信をするサービスの冗長性が皆無なので、どちらかが死ぬと悲劇が起きるため、何かしら対策を打ちたいと考えています。

最後に

モニタリングにオーナーシップを持つようになった結果、トラブルの予兆を見つけて事前に対策したり、リソースが余っている場合にはスケールダウンしてコスト削減したりといったことが柔軟にできるようになりました。
とはいえ手動な部分が多く残っているので、これからも改善を続けていきたいと思います。

オウチーノでは一緒に働くメンバーを募集中です

漸進的なシステムリプレイス(STTMeetup システムリプレイスNight)

オウチーノの吉川です。この度オウチーノでも開発者ブログをはじめます。 これからオウチーノの技術的な取り組みやサービス開発の裏側についてご紹介していきます。 どうぞよろしくお願いします。

システムリプレイスNight

先日STTMeetup#7 システムリプレイスNightが開催されました。 STTMeetupはスタートトゥデイテクノロジーズさんが定期的に開催しているミートアップで、今回はシステムリプレイスがテーマです。 スタートトゥデイテクノロジーズさんもちょうどZOZOTOWNのシステムリプレイスに取り組んでいるということで、 各社のシステムリプレイスに関する取り組みについて私と弊社山本がご紹介しました。

オウチーノのリニューアル

オウチーノでは昨年2017年に経営体制が変わり、この1年で開発体制も大きく変化しています。 システムのリニューアルや新サービスのローンチも着々と進んでおり、今月にはオウチーノのポータルサイトの中核である新築・中古サイトもリニューアルされました。

オウチーノはもともと新築、中古、賃貸サイトなどをそれぞれ独立したサービスとしてローンチし、あとから統合してきた経緯があります。 またもともと外注中心の体制だったため、担当業者によって技術スタックが異なっており、ColdFusion+OracleなものあればCakePHP+MySQLなものやStruts2もあったりとばらばらでした。それが今やReact+Railsのアプリケーションに集約されつつあります。

と、言葉にするのは簡単ですがもちろん容易なことではありません。システムのリプレイスはともすれば泥沼化しがちで、頓挫することもままあります。 そうならないようにどのような工夫をしてきたかについてSTTMeetupでお話しました。

分かれたシステムをていねいにモノリスに集約する

私からはリプレイスを進めるにあたっての方針策定やアーキテクチャ、組織的なとりくみについてご紹介しました。 タイトルだけ見るとMicroservicesつらいからモノリスにしたとか、分断されたモノリスの話を思い浮かべるかもしれませんが、そういった話ではありません。 ちなみにもとの状態を一言でいうなら分断されたモノリスが乱立した状態でした。

例えばPC版はColdFusionでスマートフォン版がPHPで同じOracleを見ていたり、バッチが何系統も別にあって、あるバッチが別のバッチの結果ファイルを監視してドミノ倒しで処理していくようなイメージです。

それ自体も良い状態ではないのですが、あれもこれも解決しようとしはじめるのは泥沼化への近道です。 オウチーノの場合はシステムの分断以上に開発者が分断されてしまっていたことが問題と考え、それを解決する手段としてモノリスを選択しました。

リプレイスではなくリノベーションという選択について

私からは新モノリスアプリケーションを中心お話しましたが、それを実現するための裏側として新システムと旧システムの間の構成について山本が発表しました。 新システムと旧システムの間をうまくつなげることで新システム側が開発しやすくなるだけではなく、新旧同じツールでデプロイやログ解析ができるようになりました。完全置き換えしなくてもちょっとした工夫でディレクターの待ちとエンジニアの作業を減らすことができ、ひいてはリプレイスがより進みやすくなります。 ちなみにタイトルはなんとかして不動産用語を使いたかったようです。

当日の様子

オフィスがものすごくおしゃれでケータリングも豪華でした。

f:id:adorechic:20180828194258j:plainf:id:adorechic:20180828195625j:plainf:id:adorechic:20180828201425j:plain

スタートトゥデイテクノロジーズのみなさんにこの場を借りて感謝いたします。 どうもありがとうございました。

最後に

これからのオウチーノを一緒に作ってくれるメンバーを募集中です。 少しでもご興味を持っていただけたならぜひご連絡ください。