読者です 読者をやめる 読者になる 読者になる

アニメイトラボ開発者ブログ

株式会社アニメイトラボの開発者ブログです


アニメイトラボ開発者ブログ

developer.animatelab.com


フロントエンドがサーバー負荷を抑えるためにできること

はじめまして、アニメイトラボのフロントエンドエンジニア id:koharusugiura です。

フロントエンドエンジニアとして働いていると、いかにサーバーサイドに負担を掛けずに処理を行うかについて考えることも多いと思います。

そこで今回は、サーバーに画像の転送を行う前にクライアント側で画像加工をする話について書きます。

この記事は animateLAB Advent Calendar 2015 15 日目の記事です。

qiita.com

JavaScript で画像処理を行う

ウェブアプリケーションで画像ファイルの加工が要件にある場合、サーバー側で画像加工を処理するケースが大半だと思います。

しかし、データ通信のことを考えると、最適な考え方とは言えない気がしています。

近年、日本のインターネット回線の速度は大きく向上しているとはいえ、モバイルデータ回線はまだまだ速度的に完璧とは言えません。

また AWS などの PaaS、SaaS におけるデータ通信にかかる料金も決して無視できるようなものではありません。ですので、サーバーへの転送は極力最小限にとどめるべきです。

サーバー側での画像ファイルの加工を行わず、クライアント側で加工しようとする場合はHTML5から採用された canvas 要素 を使うのが最も手軽です。

例えばウェブブラウザー上に表示されたウェブページで、ドラッグ & ドロップされた画像ファイルにコピーライト表記を付与するという処理を行う場合には、下記のようなスクリプトを書くことになるでしょう。

/**
 * @licence MIT
 */

/**
 * data URI から Blob を生成
 * @param {string} sourceDataUri
 * @return {Blob}
 */
function dataUriToBlob(sourceDataUri) {
  var _ref = sourceDataUri.match('data:([^;]+);base64,(.+)') || [];
  var type = _ref[1];
  var data = _ref[2];
  if (typeof type === 'undefined' || typeof data === 'undefined') {
    throw new TypeError('Invalid data URI.');
  }
  var bytes = atob(data);
  var sourceLength = bytes.length;
  var buffer = new Uint8Array(sourceLength);
  var i;
  for (i = 0; i < sourceLength; ++i) {
    buffer[i] = bytes.charCodeAt(i);
  }
  return new Blob([buffer], { type: type });
}

/**
 * 画像ファイルに文字列を乗せる
 * @param {Blob} sourceBlob
 * @param {string} text
 * @return {Promise}
 */
function fillText(sourceBlob, text) {
  var image = new Image();
  var canvasElement = document.createElement('canvas');
  var canvasContext = canvasElement.getContext('2d');
  image.crossOrigin = 'anonymous';
  return new Promise(function(resolve, reject) {
    image.addEventListener('load', function() {
      var resultBlob;
      var resultDataUri;
      URL.revokeObjectURL(this.src);
      try {
        canvasElement.width = this.width;
        canvasElement.height = this.height;
        canvasContext.drawImage(this, 0, 0);
        canvasContext.font = '25px Arial';
        canvasContext.fillStyle = 'white';
        canvasContext.fillText(text, 5, this.height - 5);
        resultDataUri = canvasElement.toDataURL(sourceBlob.type);
        resultBlob = dataUriToBlob(resultDataUri);
        resolve(resultBlob);
      } catch (error) {
        reject(error);
      }
    });
    image.addEventListener('error', reject);
    image.src = URL.createObjectURL(sourceBlob);
  });
}

document.addEventListener('drop', function(event) {
  event.preventDefault();
  var dataTransfer = event.dataTransfer;
  var file = (dataTransfer.files || [])[0];
  fillText(file, '\u00a9 animateLAB, Inc.').then(function(blob) {
    var body = new FormData();
    body.append('imagedata', blob);
    return fetch('/upload.cgi', {
      method: 'post',
      body: body
    }).then(function(response) {
      if (response.status === 200) {
        return response.text();
      } else {
        throw new Error([response.status, response.statusText].join(': '));
      }
    }).then(function(uri) {
      open(uri, '_blank');
    });
  }).catch(function(error) {
    alert(error.message);
  });
  return false;
});

function cancelEvent(event) {
  event.preventDefault();
  return false;
}
document.addEventListener('dragenter', cancelEvent);
document.addEventListener('dragleave', cancelEvent);
document.addEventListener('dragover', cancelEvent);

JavaScript で data URI を Blob にする

上記スクリプトの肝は dataUriToBlob 関数です。

JavaScript で HTTP リクエストを扱う API である XMLHttpRequestfetch で画像ファイルの転送を行う際には、 Blob オブジェクト、Blob オブジェクトを継承したインターフェースの File オブジェクトであることを要求します。

これらのオブジェクトは JavaScript でバイナリーファイルを意味するものです。

canvas 要素は CanvasHTMLElement.prototype.toBlob メソッドを使うことによって Blob オブジェクトを得られるのですが、CanvasHTMLElement.prototype.toBlob メソッドはこの記事を執筆時点 (2015 年 12 月 15 日) では Firefox 以外のウェブブラウザーでは正式サポートされていません。

Google Chrome 47 (2015 年 12 月 15 日時点の stable) 以降では、実験的なサポートがされていますが、「chrome://flags から有効にする必要のあるもの」となっており、多くの環境で使えるものとは言い難いものとなっています。

そのため上記スクリプトでは、CanvasHTMLElement.prototype.toDataURL メソッドが返す data URI を、Blob オブジェクトに変換させています。

この記事では、本筋と異なるため詳しい説明を省きますが data URI は汎用的に任意のデータを HTTP リソースとして扱えるようにする仕様です。

データは文字列しか受けつけませんが、Base64 の形式でエンコードし、文字列の形にするとバイナリーも data URI にできます。dataUriToBlob 関数では Base64 のデコードに window.atob メソッドを使用しています。

window.atob メソッドは、UTF-8 の文字列を扱えないといった一部制限がありますが、画像ファイルをエンコードした Base64 文字列のデコードをする範囲では問題がありません。

しかし、window.atob が返すデコード済みの値は string となります。そのままでは Blob オブジェクトにしても画像ファイルとしては正しく認識されません。

そのため、dataUriToBlob 関数では window.atob メソッドが返した値を String.prototype.charCodeAt を使い、一文字づつコードに置き変えています。

この処理はループを用いて行っているため、画像ファイルが長大化すると多くの処理時間を要すようになってしまいます。想定される画像ファイルが大きいのであれば Web Workers を使うことを考えても良いでしょう。

また、モバイル端末で動作するものを含め、ここ数年の内に提供が開始されたウェブブラウザーであれば縦 1080px、横 1920pxの PNG 画像程度であれば遅くとも数百ミリ秒の内に処理を完了させられます。

Web Workers を使い、並列処理をする意義は一部の例外を除けばないと思われます。

Blob オブジェクトの作成は Blob インターフェースの第一引数に前段で作成したコード群を渡し、第二引数でオプションという形で MIME Type の指定をするだけです。これで JavaScript の扱うことのできるバイナリーファイルの用意ができました。

Firefox では window.atob メソッドが遅い?

まだ検証が済んでいないのですが、Firefox では window.atob メソッドが非常に遅いと感じています。

簡単に検証できるスクリプトを jsPerf に用意しました。Create a blob from a base64 encoded string. を筆者の環境 (メモリー 32GB、64bit Windows 10) で確認しています。

Firefox 42 を確認すると、以下のように window.atob メソッドを使わずに Base64 のデコードを JavaScript で行ったほうが四倍弱高速という結果になりました。

f:id:koharusugiura:20151214095600p:plain

この記事では環境に応じて条件分岐を行うような処理は省きたかったため、HTMLCanvasElement.prototype.toDataURL メソッドのみを使用しています。

ですが、実際の運用に乗せる場合は Firefox での速度も気にしなくてはなりません。ですので HTMLCanvasElement.prototype.toBlob メソッドが存在しない環境でのみ、Blob オブジェクトを data URI から得るようにするべきでしょう。

この度、上記の検証を行う際に binary-base64 という window.atob メソッドを使わずに Base64 デコードを行うライブラリーを作成してみました。機会があれば、ぜひご活用ください。こちらのライブラリーは window.atob メソッドとは違い、UTF-8 の文字列も扱え、またウェブブラウザー以外の JavaScript 実行環境でも動きます。

JavaScript でバイナリーファイルをサーバーに転送する

XMLHttpRequestfetch API でバイナリーファイルをサーバーに転送する方法はいくつかありますが、上記スクリプトでは FormData インターフェースを使っています。

FormData インターフェースは XMLHttpRequest で、XMLHttpRequest.prototype.send メソッドの引数に渡し、fetch API でも第二引数にオプションという形で与えるオブジェクトに body という名前のキーで渡すだけで、サーバーに Content-Type: multipart/form-data という形式のリクエストを送ることができます。

これは下記のような HTML のフォームから送信されるリクエストと同等のものとなっています。これは、多くのウェブアプリケーションフレームワークが対応している形式のリクエストです。

FormData インターフェースを使うことで、クライアント側の処理もサーバー側の処理も簡素に済ませられます。

<form action="upload.cgi" enctype="multipart/form-data" method="post">
  <input name="imagedata" type="file">
  <button type="submit">submit</button>
</form>

転送状況を表示させる

実際の運用では画像ファイルの転送のように時間がかかる処理を行う場合、ユーザーに進行状況が見えるようになっていると親切です。

上記のスクリプトでは、XMLHttpRequest ではなく fetch API を使っていますが、クライアント側からの転送状況をユーザーに見せる場合には XMLHttpRequest を使う必要があります。

XMLHttpRequest オブジェクトは upload という XMLHttpRequestUpload オブジェクトの属性を持ちます。この属性の progress イベントを監視することにより、転送の進行状況を得られます。

得られた値は適宜処理して progress 要素を用いてユーザーの目に見えるようにすると良いでしょう。

var progressBar = document.getElementById('upload-progress');
var request = new XMLHttpRequest();
var body = new FormData();
body.append('imagedata', blob);
request.addEventListener('load', function(event) {
  // ...
});
request.upload.addEventListener('progress', function(event) {
  var percent = event.loaded / event.total;
  progressBar.value = percent;
});
request.send(body);

最後に

上記のスクリプトでは固定された値を画像に当てはめるだけでしたが、実際には文字の色や大きさ、表示位置を変えたりする処理も必要になります。またユーザーによって入力された任意の文字列を当てはめるような処理に変えたいという要件もありえます。そうすると、どのような加工がされるのかプレビューの表示は必須でしょう。

サーバー側で画像加工を処理すると、変更の確認をするにはサーバーへの転送が必要となります。通信速度によっては、プレビュー表示に時間掛かることにつながります。これは、ユーザーの離脱にもつながりかねません。

クライアント側で画像加工を処理する大きなメリットは、ほぼリアルタイムでのプレビューの表示ができる点にあります。プレビュー表示に時間がかかるということはありません。また、画像ファイルの転送も最後の一度だけで済むので、無用な転送量に悩まされることもなくなります。

ですが、クライアント側に処理を偏重させすぎると、ユーザー端末に不必要な負荷をかけてしまうことになります。そのため、「クライアント側で処理するべき」か、「サーバー側で処理するべき」かを見極めることが重要となります。

上記のように書いてしまうと少々大げさですが、多くの場合はクライアント側で転送を省略することにより、高速に処理を完了させることができるため、恩恵のほうが大きいと感じています。

ぜひ、ご検討とご活用ください。


さて、現在アニメイトラボにフロントエンドエンジニアは筆者一人しかいません。

JavaScript による処理の最適化や高速化にご興味がお有りのかたはぜひご応募ください。

recruit.animatelab.com

明日は @sugicyan ……ではなく @sugiyan による「Play Framework の Message を View に渡すやり方」です。お楽しみに。