おつかれさま、MYM 〜僕とMYMのフロントエンド戦争〜

  • このエントリーをはてなブックマークに追加
Yahoo! JAPAN Tech Advent Calendar 2018の25日目の記事です。一覧はこちら

メリークリスマス。ついにアドベントカレンダーも最終日です。トリを担当する事になりました、Node.js言語サポートチームの伊藤(@koh110)です。

2日目のMYMの記事は読んでいただけたでしょうか。自分がどうしても聴きたくて栗山さんにねだりにねだって書いてもらいました。

ありがとう、MYM 安らかに眠れ

2日目の記事では主に振り返りや数字、インシデント、チャットシステムのバックエンドなど全体の仕組みについて詳細に語ってもらいました。
そこで本記事では、途中から開発に加わった視点からチャットシステムの面白かったところを、フロントエンドやユーザー体験寄りの視点からまとめたいと思います。

MYMは2011年に社内ハッカソンのHackDayで生み出されました。フロントエンドを当時のJavaScript黒帯、バックエンドは現Node.js黒帯の2人がメインとなって開発されました。もちろん他に関わったエンジニア・デザイナーも優秀な人ばかりです。新卒のひよっこにとっては楽しくも大変な日々でしたが、ここから学んだものは数え切れません。

蛇足ではありますが、エンドロール後のおまけと思って少しお付き合いいただければ幸いです。

僕とMYMのフロントエンド戦争

僕とフロントエンド戦争の始まり

MYMは2011年にリリースされました。僕が初めてMYMに触れた2013年当時は、社内でもbackbone.jsやAngularJSなどフロントエンドフレームワークを使うことが当たり前になってきたところでした。

MYMのフロントエンドのコードは今も当時の構成を受け継いでいるのですが、BabelやTypeScriptといったトランスコンパイル処理がなく、ほぼピュアなJavaScriptで書かれています。ライブラリもレンダリングに関わる部分に薄くjQueryの依存があるだけで、大半の機能がフルスクラッチで実装されています。

また、完全なSPA実装になっていてサーバーサイドのコードは一切含まれません。こういった構成が成り立つのは社内のPCはスペックがほぼ固定化できることに加えて、外部サービスで必要とされるSEOなどの要件がないというのも大きいです。

MYMにあるもう一つの面白い方針は、なるべく機能をフロントエンドによせるというものです。そうすることで要望がきた際の対応速度をあげる狙いがあります。バックエンドのリリースはサーバーの再起動など、かなりセンシティブになる要素が多いですが、SPAアプリはただ静的ファイルを配置するのみなので、ある程度大胆なリリースが可能です。

MYMでは要望やバグへの応答速度はかなり気にされていて、速いものであれば報告から数分で修正・リリースされます。ユーザーから送られてきたプルリクエストもPJメンバー個人の裁量でマージ・即時リリースがされます。

この方針は前の記事でも語られましたが、「なんでも聞きやすくコミュニケーションが取れる」という空気を作ることが重要視されているためです。もともとHackDayで開発された時も、当時ヤフーメッセンジャーで少数の仲間うちでされていた気軽なコミュニケーションをもっと全社的にできるようにしたかった、という理由があったようです。そういった起源から「気軽に投げ込んだら爆速で実装される」という雰囲気が今でも続いています。

求められた機能をいかに最小のコストかつ最速でリリースするかを常に考えることが、MYMとの戦いのキモになっていきます。

jQueryと和解せよ

世の中はすでにフレームワークの時代なのに、ピュアなJavaScriptと薄いjQueryというコストのかかる開発スタイルをとっているのか疑問でした。

昨今、jQueryというとスパゲティコードやテスタブルでないなど負のイメージに捉えられることが多いです。自分もそういったイメージを持っていたのも事実ですし、実際そういう風になりやすい面もあるでしょう。MYMの開発に加わった時、そんな激闘を求められるのではないかと不安に思いました。

しかし、実際に開発を進めてみるとViewとロジックが分離され、多くのコードが疎結合になっていたため予想してたほどつらいものではありませんでした。jQuery製のrenderメソッドに依存するプロパティーを注入する形なのですが、ES5の時代にReactのような実装をするとこうなるんだろうなという印象です。

たとえば発言のテンプレートは下記のような形で利用します。またDOMの情報に触れるコードを書かなければならない場合、HTML5の仕様である data-* 属性を通してアクセスすることが徹底されています。逆に言うと data-* に追加されているということはコードから利用される可能性がある、と判断することが可能です。このおかげかMYMでは「DOMを変更したらコードがうごかなくなった」という問題に遭遇したことはほぼありません。

const commentHtml = render.comment({
  _id: 'comment-id',
  type: 'plain',
  date: '2018/12/25 14:04:18',
  user: 'koh110',
  message: 'おはよー',
  userIcon: 'https://iconurl.com/koh110'
});
<li id="{{:_id}}" data-id="{{:_id}}" data-type="{{:type}}" data-date="{{:date}}" data-user="{{:user}}" class="comment-wrap">
  <article>
    <a class="comment-icon"><img src="{{userIcon}}" class="comment-icon-img"></a>
    <div class="comment-text">{{:message}}</div>
    <a class="publish-time">{{:date}}</a>
  </article>
</li>

jQueryを使うことが悪なのではなく、フレームワークはロジックを疎結合にするために利用するものであるということを学び、嫌いになりかけていたjQueryと和解することができました。

今自分がMYM NEXTを作るとしたらフレームワークを利用します。しかし、MYMはピュアなJavaScriptでDOMと通信するという仕様であったからこそDOMをハックし、好きにカスタマイズできる楽しさからエンジニアに受けたという側面もあったと思います。

過去に一度React化するという動きもあったのですが、当時はどうしてもパフォーマンスがでない部分があったため適応を諦めました。今は当時と状況が変わってきたのでまた挑戦してみたいですね。

増え続ける部屋との戦い

MYMでは会話するチャンネルを部屋と呼んでいます。部屋には公開/非公開/ロックの3段階の公開設定があり、この公開設定は途中で行き来できます。

入室 部屋検索
公開 自由
非公開 自由 x
ロック 招待 x

部屋への入室は招待かURL欄に部屋名を直接入力で可能です。もし入力した部屋が存在しなかった場合、新規に部屋が生成されます。

非公開設定はいらないのではないか、とよく言われるのですが「入室を拒んでいるわけではないが検索を汚したいわけではない」という日本人的なニーズを満たしていて僕は気に入っています。先の記事で数字が載っていた通りMYM上には部屋が全部で15万ほど存在しますが、この公開設定のおかげかこれだけの部屋数になっていても検索性をあまり損なっていません。

ヘビーに利用するユーザーであれば数百を超える部屋に入室するのは当たり前で、2000をオーバーすることも珍しくはありません。例えば僕は1000部屋以上入室しています。

> document.querySelectorAll('.list-myroom li').length
1109

ぱっとこの数字だけ聞くと、まともに処理できる量を超えているでしょう。しかし普段そこまでの不便を感じません。

部屋は「全て・未読・部屋・1on1」という4つのフィルターをかけることが可能です。僕は「未読」を一番よく利用します。このフィルターをかけると、未読のついた部屋のみが一覧で表示されます。

部屋

1on1はSlackで言うDMにあたり、1対1の会話のみがリストされます。MYMでは新規登録されたユーザーはIdenticonという仕組みで一意なデフォルトアイコンが設定されます。この仕組のおかげでアイコンが個別に設定されていなくても、なんとなく一覧から絵だけで誰かを判別できるようになっていきます。

また部屋ごとにもアイコンが設定できる点が視認性をあげています。人間の画像認識力というのは大したもので、部屋にアイコンがついていると名前を見なくても一覧の中から自分の中で重要度のランキングがつけられるようになります。

部屋の中でも最優先で確認する部屋はお気に入りとして数件登録されていますが、それ以外の部屋もチェックの優先度が高い部屋(例えば利用している社内プラットフォームのアナウンス部屋など)がいくつか存在します。部屋名で検索した場合、検索するまで未読がついているかどうかはわかりません。ざらっとスクロールするだけで視認できるのは思っている以上にとても快適です。

自分宛ての通知がある場合は未読の表示が赤から黄色に変わり、自分がチェックするべき未読かどうかの判断がつきやすくなっています。

部屋アイコンは一見システム負荷を高めてしまう機能ですが、画像にはかなり強力なキャッシュ戦略がなされているため、思っている以上にシステムに対する負荷は少なく、1000件以上の部屋に入室しても即座に表示されます。ServiceWorkerによるキャッシュも一時期導入しましたが、社内の環境ではそこまで高速化に寄与しなかったため削除されました。

MYMでは多くの機能で、画像や色によって視認性をあげるという考えが現れています。

フロントエンド激戦地区

ここからは特徴的な技術の話についてまとめていきたいと思います。

検索(WebWorkerとの戦い)

MYMのフロントエンドコードには「富豪的プログラミングを行うこと」という少し変わった開発方針がありました。最初は富豪的に最速で作成し、どうしてもパフォーマンスが出ない時に初めて最適化するというものです。これは先に述べたように、PCのスペックやユーザーをしぼることができる社内システムだからこそとれる手法です。

その一例としてユーザーの検索があります。これは全ユーザーデータの配列からフロントエンドのコードでフィルターを行う仕組みです。

重み付けや形態素解析などの機能はありませんが、限定的な用途だとかなり実用的です。社内システムでは人の名前やアカウント、メールアドレスなどクエリのパターンが限られてきます。そういった用途に限った場合、ネットワークを経由することなくオンメモリで高速に動作するというメリットが最大限に発揮されます。

検索

このユーザーデータはおよそ1万人のデータがあり5MBほどのデータ量です。普通のサービスであれば5MBもあるデータをダウンロードさせるなんて言語道断でしょう。社内なので割り切って実装しています。

もちろんパフォーマンスが完全に無視されているわけではなく、WebWorkerを利用して別スレッドで実行されます。
下記のようなコードで検索を利用します。WebWorkerでは直接DOM要素を直接いじれないため、onmessageで受け取った結果を描画します。

const userSearch = new Worker('src/search.js');

userSearch.postMessage({
  type: 'init',
  data: users
});

userSearch.postMessage({
  type: 'search',
  q: query
});

userSearch.onmessage = function(res) {
  renderUserList(res.results.users.slice(0, USERLIST_MAX));
};

search.js は汎用的な検索ができるライブラリとして作られています。さらにこの検索はローマ字で検索してもひらがなにヒットしたり、果ては漢字の表記揺れ(濱 → 浜, 辺 → 邉邊部)もヒットします。

簡単にコアとなる仕組みを解説すると下記のような流れになります。

  • 初期化時に配列を文字列化する
  • 検索時にクエリを正規表現化する
  • 文字列に正規表現化されたクエリをあて、ヒットした結果をブラウザーにpostMessageする

下記はかなり省略した疑似コードですが、これでもかなり複雑で読み解くのにすごく苦労します。実際のコードはこれに加えページング処理や高速化のためのロジックがあります。

const delimiter = '\0';
const search = {
  // 生データ
  raw: []
  // 生データを文字列化したもの
  txts: [],
  // txtsの長さを保持する配列
  idx: [],
  // 正規表現をあてる対象の文字列
  txt: '',
};

const receiver = {
  init: (req) => {
    // searchオブジェクトを初期化します
    for (let i = 0; i < req.data.length; i++) {
      const elem = req.data[i]
      const values = Object.values(elem).join('');
      const length = i !== data.length - 1 ? values.length + delimiter.length : values.length
      search.raw.push(elem);
      search.idx.push(i >= 1 ? search.idx[i - 1] + length : length);
      search.txts.push(values);
    }
    search.txt = search.txts.join(delimiter);
  },
  search: (req) => {
    const regexp = query2regexp(req.q);
    const results = [];

    while (regexp.test(search.txt) && resulsts.length < req.limit) {
      let i = 0;

      while (search.idx[i] <= regexp.lastIndex) i++;

      // lastIndexを次の要素の長さにまでずらすことで、2回同じ要素にひっかかるのを防ぐ
      regexp.lastIndex = search.idx[i];
      results.push(search.raw[i]);
    }

    req.results = results;
    self.postMessage(req);
  }
};

self.addEventListener('message', (event) => {
  const req = event.data;
  receiver[req.type] && receiver[req.type](req);
});

ここでキモとなる関数が query2regexp です。この正規表現がローマ字検索や全角/半角、漢字の表記揺れなどを吸収してくれます。

この関数を通すと yahoo という文字列が /(?:[yYyY][aAaA]|[やヤ])(?:[hHhH][oOoO]|[ほホ])(?:[oOoO]|[おオ])/g という正規表現に変換されます。yahoo という文字列がローマ字よみのひらがなに変換されているのが見て取れると思います。

これは query2regexp 関数内部に用意されたローマ字変換用のマッピングテーブルで変換されています。ひらがな、カタカナの変換や漢字の表記揺れも同様にマッピングテーブルを用意して正規表現化されています。

名前を検索しようと思って検索欄に入力したら英字入力だった、みたいなシーンはよくあると思うのですが、そんな時にもこの検索はうまく引っ掛けてきてくれます。一見力業にみえますが、スマートな解決策よりスピーディーに課題を解決できることが優先されているモデルケースだと思います。

const romaji = {
  'a':'あ','i':'い','yi':'い','u':'う','wu':'う','whu':'う','e':'え','o':'お','la':'ぁ',
  'xa':'ぁ','li':'ぃ','xi':'ぃ','lyi':'ぃ','xyi':'ぃ','lu':'ぅ','xu':'ぅ','le':'ぇ',
  ...
};

const synonym: {
  'あ':'ア','い':'イ','う':'ウ','え':'エ','お':'オ',
  ...
  '崎':'﨑','﨑':'崎','高':'髙','髙':'高','柳':'栁','栁':'柳',
  '斉':'斎齋齊','斎':'斉齋齊','齋':'斉斎齊','齊':'斉斎齋',
  ...
};

サジェスト(複雑な文字列処理とDOMの戦い)

サジェスト

チャットシステムでの入力補完機能はかなり利便性を左右します。例えばユーザーにメンションを送りたいシーンや、スタンプの名前、特殊な記法(部屋への招待やアンケート)などを完全に覚えることは難しいでしょう。

サジェスト

サジェストはどこからがサジェストの候補文字列の開始地点なのかを判断するのがかなり難しいです。

例えばスタンプをサジェストする場合を考えます。MYMのスタンプは :stamp_ で始まる文字列を画像に置き換えるという仕組みです。

入力値の最初がスタンプではじまっていればサジェストの展開は単純で、テキストエリア全てを :stamp_yj_arigato: に置き換えれば済む話です。

問題はスタンプの前に一言そえた場合です。

サジェスト

この場合、カーソルは :stamp_ari の後ろにありますが、テキストエリア全てを置き換えてしまうと「ありがとうございます!」という入力までスタンプの記法で上書きされてしまいます。
そこで、この場合は :stamp_ari だけをカットして置き換えるような展開処理が必要です。

MYMではカーソルの位置から手前に戻していき、アルファベットや記号以外が出てきたらそこがサジェストの開始地点であり開始地点から対象の文字列をカットして置き換える、という仕様にしてこれを実現しています。下記のようなイメージです。

let start = textarea.selectionStart;
while (start > 0 && /[A-Za-z0-9_\+\-]/.test(inputText[start - 1])) {
  start--;
}

また、文章を書いている途中で前に戻って書く場合もあり得るので、終了地点も開始地点と同様に最後までカットしてしまうとおかしなことになってしまいます。

サジェストはDOMや文字列処理と密接に関係していて改修も難易度が高いのですが、チャットシステムならではの「ユーザが何をどう入力するかを予想する」という部分が使いやすさに直結して面白くもあります。

アップローダー(Canvasとの戦い)

Paster はお手軽なファイルアップローダーです。

テキストエリアにファイルをドラッグアンドドロップすると一意のURLが発行される仕組みです。画像やテキストファイル、Excelファイルなど多種多様なバイナリデータをアップロードできます。

アップロード

また画像ファイルは、ドラッグアンドドロップの他クリップボードからペーストが可能な他、アップロード直前に加工が可能です。仕事中ちょっとした画像加工をして画像共有しなければならないシーンは意外とありますが、都度画像加工ソフトを立ち上げずにチャットに投げて加工できるのはコンテキストスイッチが少なくて好きです。

pasteとdropイベントに引っ掛けて、FileReaderから読みだしたcontentTypeが画像だった場合のみCanvasにわたして画像加工ができます。

document.addEventListener('drop', onpaste);
document.addEventListener('paste', onpaste);

function onpaste(event) {
  const files = getFiles(event);
  if (files.length > 1) {
    event.preventDefault();
    files.forEach((f) => {
      openFile(f, true);
    });
  } else if (files.length === 1) {
    event.preventDefault();
    openFile(files[0]);
  }
}

function openFile(file, passthrough = false) {
  const reader = new FileReader();
  if (!passthrough && file.type.indexOf('image/') === 0) {
    img = new Image();
    // canvasに読み込んだ画像を貼り付ける処理
    img.onload = enterClipper;
    reader.onload = () => {
      img.src = reader.result;
    };
  } else {
    reader.onload = () => {
      upload(reader.result, file.name);
    };
  }
}

Pasterの画像処理は長方形の範囲選択が可能で、選択した範囲にトリミング・強調・モザイク・集中線の4つの加工ができます。モザイク処理はコードもそこそこ短くて、はじめてCanvasを扱う題材として楽しいと思います。

画像加工

function mosaic(rect, src, dst) {
  const size = 15;
  const right = rect.dx + rect.dw;
  const bottom = rect.dy + rect.dh;
  for(let x = rect.dx; x < right; x += size) {
    const w = Math.min(right - x, size);
    for(let y = rect.dy; y < bottom; y += size) {
      const h = Math.min(bottom - y, size);
      const rgba = [0, 0, 0, 0];

      const data = dst.ctx.getImageData(x, y, w, h).data;
      const len = data.length;
      for(let px = 0; px < len; px++) {
        rgba[px % 4] += data[px];
      }
      const pxCount = len / 4;
      const rgb = [
        Math.floor(rgba[0] / pxCount),
        Math.floor(rgba[1] / pxCount),
        Math.floor(rgba[2] / pxCount)
      ];
      src.ctx.fillStyle = 'rgb(' + rgb.join(',') + ')';
      src.ctx.fillRect(x, y, w, h);
    }
  }
  selectRect(0, 0, dst.canvas.width, dst.canvas.height);
}

集中線は有志が作った機能です。最初は要望としてあがったものでしたが要望したのがエンジニアだったので、このへんを修正すれば機能追加できるのでぜひプルリクください、と返したところ空いた時間を使って実装してくれました。こういった部署を飛び越えてみんなで開発していける空気がMYMのいいところでもあります。

サムネイラー(ヘッドレスブラウザとの戦い)

チャットしかりSNSしかり、URLを貼ったら展開されるのは当たり前になってきました。MYMでもURLを投げると画像として返却するサムネイラーと呼ばれる機能があります。

もともとこの機能はPhantomJSを利用して作成されていましたが開発の終了を受けて、2017年にpuppeteerで全面的にリプレースしました。

サムネイラーの実装はとてもシンプルです。リクエストがきたらpuppeteerを立ち上げて、加工してからスクリーンショットを撮るだけです。

puppeteerを実サービスに組み込む場合に必ず気をつけなければならない点が、下記コード中のscreenshotエラー時に受けてpuppeteerをcloseする部分です。puppeteerはタイムアウトやその他理由により、よくエラーを返します。その際closeを忘れると閉じそこなったChromeのプロセスが増殖していき、最終的にプロセスを食い尽くされます。あわや事故というところでしたが、実運用して初めて気づいた部分でした。

const puppeteer = require('puppeteer');

const regexps = new Map([
  ['realtime', /^https?:\/\/search\.yahoo\.co\.jp\/realtime\/search/],
  ...
]);

module.exports = async (url, options) => {
  if (!Array.from(regexps.values()).some((r) => r.test(url))) {
    return;
  }

  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox']
  });

  try {
    await screenshot(browser, url, options.output);
  } catch (e) {
    throw e;
  } finally {
    await browser.close();
  }
};

サムネイラーではリアルタイム検索などヤフーのいくつかのサービスが展開可能ですが、チャット上で見る場合にはPC版よりスマートフォン版の方が見やすいのでChromeのユーザーエージェントをiPhoneにしています。evaluateの中で必要な要素以外をremoveChildで消去し、screenshotでPNG画像化します。

const devices = require('puppeteer/DeviceDescriptors');
const iphone = devices['iPhone 6 Plus'];

async function screenshot(browser, url, output) {
  const page = await browser.newPage();

  await page.goto(url, {
    waitUntil: ['domcontentloaded', 'networkidle0']
  });

  // リアルタイム検索の場合
  await page.emulate(iphone);
  await page.evaluate(() => {
    // 要素を削除する
    const remove = (selector) => {
      Array.prototype.forEach.call(document.querySelectorAll(selector), (node) => {
        node.parentNode.removeChild(node);
      });
    };

    remove([
      // ヘッダ系
      '#Sap, #Sa',
      // 検索結果
      '#contents #TSz',
      // 表示は5件まで
      '#TSu section:nth-of-type(6) ~ section',
      // その他消す要素
      ...
    ].join(','));
  });

  await page.screenshot({ path: `${output}.png`, type: 'png', fullPage: true });
}

Linux上でpuppeteerを動かすためにはlibX~~など追加で必要なパッケージがいろいろあります。また、サーバーでpuppeteerを動かした時にfontが足りず豆腐になる問題がよく起きるので、fontのインストールも忘れないようにしましょう。個人的にはGoogleが提供している noto font が奇麗でおすすめです。

戦のおわりに

入社して初めてMYMを触った時、ヤフーの社内システムってこんなに作り込まれているのかと驚きました。

そして、当時こんなに作り込まれているのになぜ外に売らないのか、という疑問をぶつけたことがあります。その時に返ってきた答えは「売ってしまうと、どうしても大きな課題や多数のユーザーが持つ問題解決にしか目を向けられなくなるから、あえて社内に最適化するんだ」というものでした。ヤフーに新卒で入るからにはサービスをやりたい、そう思って入社したのに社内システム担当になりもやもやしていた自分にとっては目からウロコの考えでした。

MYMには背景が雪模様に変わるクリスマスモードというものが仕込まれています。初めてそれを見た時とてもわくわくしたのを覚えています。社内のシステムであり毎日使うことを強制されるからこそ、少しでもストレスなく使ってほしいという人の顔が見えたような気がしたからです。「エンジニアだからといって技術だけが解決策ではない」という考え方もMYMを通して学びました。

このシステムを通じて受け取った多くのものをまた次の人たちにプレゼントしていけたらいいなぁと思います。

話は変わりますが、MYMの初代開発者は現在六本木でハッカー兼バーテンダーをしています。先日MYMのクローズを報告にいったところ、MYMのイメージカクテルを作ってくれました。

カクテル

「ショットグラスを落とすタイプのカクテル(○○・ボム)とソニック(音速)でヤフーの"爆速"を表現し、レッドベア(エナジー系リキュール)を落とすことで"みんなのいいね(エナジー)が溜まったら赤くなる"というMYMの代表的な機能を表現しました。」

ハッカーとしてもバーテンダーとしても完成度が高くて驚いてしまいました。

このカクテルを味わいながら、MYMと一緒にラストクリスマスを祝いたいと思います。メリークリスマス!


2016年Best Author受賞者

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加