ニッチすぎる!?知られざる広告JavaScriptの世界

こんにちは。雨宮(@rail44)です。
普段はヨーヨーやポケモンに興じるかたわら、株式会社fluctで広告配信システムの開発を担当しています。

fluctは広告業界ではSSP(Supply-Side Platform)と呼ばれる立ち位置で、インターネットメディアの収益の最大化にフォーカスした事業を行っています。
私たちのシステムを使うと、広告によるマネタイズが面倒な運用無しに出来る。といったイメージです。

この記事では、自分が直近で担当をしている広告の配信スクリプトと、普段注目されづらいその裏側について書いていきたいと思います!

広告タグの構造

さて、webページに広告を表示したい場合、アプリケーションはHTMLで記述されているため、広告もHTMLタグの形でお渡しすることになります。
(※fluctではモバイルアプリや動画プレイヤーへの広告配信も行っており、それらの場合はHTMLではない納品形態になっています)

シンプルな物で一例を示すと以下のようなHTMLタグになっています。

<!-- A: 表示領域 -->
<div class="hoge"></div>

<!-- B: 広告配信スクリプト -->
<script type="text/javascript" src="https://pdn.adingo.jp/p.js" async></script>

<!-- C: 広告表示コマンド -->
<script type="text/javascript">
  var fluctAdScript = fluctAdScript || {};
  fluctAdScript.cmd = fluctAdScript.cmd || [];
  fluctAdScript.cmd.push(function (cmd) {
    cmd.loadByGroup("hoge-group")
    cmd.display(".hoge", "hoge-unit");
  });
</script>

タグの構造をざっくり分類すると、
A: 表示領域となるdiv要素
B: 外部のJavaScriptを読み込むためのscript要素
C: IDを用いて広告の読み込みや表示を指示するscript要素
といった作りになっています。

ここで、途中で読み込んでいる、B が広告配信スクリプトと呼んでいるJavaScriptとなります。
サーバーへの広告リクエストの発行、広告のレンダリングやスタイリング、各種イベントのトラッキングなどが責務となっています。

さて、このJavaScriptをガシガシ開発していくわけなんですが、広告以外ではなかなか遭遇しないような制約が数多くあるので紹介していこうと思います。

広告ならではの制約

動作するブラウザが限定できない

一般的なwebサービスではターゲットとなるユーザーから、動作環境を想定した上で開発が行われます。
たとえば、スマホユーザーが主体であればiOSのSafariとAndroidのChromeをサポート範囲とすれば大部分をカバーすることができます。

しかし広告システムとなると、様々なwebメディアへと導入されることになるので、広告配信スクリプトはサポート範囲として最大公約数を目指すことになります。

実際、現在のfluctでは、数バージョン前のInternet ExplorerやSafari、つまりPromiseやArray.prototype.forEachが無い世界でも動作するような作りになっています。

Polyfillは(素直には)使えない

動作するブラウザを広くするためには、Polyfillを用いて未実装な機能を補完するという解決策が多く用いられます。
今日ではbabelからcore-jsを使う方法が一般的でしょうか。 [参考]

ところが、これも広告配信スクリプトでは工夫をして導入する必要があります。
なぜなら、広告を表示したいwebアプリケーションへ既にPolyfillが導入されていた場合に、衝突してしまう危険性があるためです。

エラー処理を緻密に行いたい

Polyfillが使えないのと同じような理由で、ランタイムエラーの検知をするのにwindow.onerrorのような手法は使えません。
自分達のシステムとは関連しない、メディア側や導入されている別の広告プロダクトのエラーも検知してしまうためです。

そのために、エラーが発生してもそれをthrowするのではなく、ScalaでいうEitherやRustでいうResultのような構造を用いることが優先されます。
また、想定外のランタイムエラーを検出するためにスクリプトの全体をtry-catch句でラップすることになりますが、window.setTimeoutのような、一旦ブラウザのランタイムへコンテキストが戻ってからコールバックされるような物はcatchができないことに注意する必要があります。

try {
    window.setTimeout(() => {
        throw new Error("hoge"); // Unable to catch!!
    }, 0);
} catch (e) {
    console.log(e);
}

ですが、すでに述べた動作させたいブラウザやPolyfillの制約によりPromiseは使えません!

広告の表示は出来るだけ速くしたい

速くしたい、というのは通常のアプリケーションでもあたり前な話なのですが、広告についてはより顕著です。
多くの広告がインプレッション(表示回数)やクリックされた回数を元に利益の計算がなされるため、表示が遅くユーザーの目に留らないようでは無意味となってしまいます。

メディアの動作とは互いに影響し合わないようにしたい

広告の表示が遅れてしまっても、メディア自体の表示が遅れないように。もしランタイムエラーを起こしてもメディアが動作しなくなることは避けなければなりません。
また、メディア側で用いているフレームワークによって広告表示が出来なかったり、といった事を避ける必要があります。
しかも、ここでもフレームワークの種類やバージョンに始まり、メディアの作りは千差万別です。

以上から、スクリプトの全体的な動作を非同期的に行う必要があります。
先ほどの広告タグの作りにも、その一端が現れているのが分かるでしょうか。

とはいえ、書き味はモダンにしたい

var Ad = function (hoge) {
    this.hoge = hoge;
};

Ad.prototype.load = function () {
    ...
};

なんて2020年に書きたくないんじゃ!!(突然の発狂)
言語オタクとしてはプロトタイプベースの言語はとても可愛いゲがあって良いんですが、チームでメンテナンスをしていくことを考えると辛すぎます。

非同期エラー処理をするにしても、

function setTimeoutWithCatch(cb, errCb, msec) {
    window.setTimeout(function () {
        try {
            cb(e);
        } catch (e) {
            errCb(e);
        }
    }, msec);
}

とか作りたくないんじゃ!

どう戦うか

これまでの制約をまとめると、広告配信スクリプトの要件はこんな感じになってしまいます。

Promiseが無く、Polyfillも使えない環境下で非同期に動作する、読み込み/実行が高速なJavaScript
(日々の開発の負担は減らしたい)

:sob:

この要件への立ち向い方は色々ありそうですが、現在のところ、

  • 最低限のPromise風のsyntaxが使えるオブジェクトを手製
  • そのPromiseLikeなオブジェクトを使ったモジュールローダーを手製
  • 手製のモジュールローダーを使うようなJavaScriptの成果物となるよう、バンドラであるRollupを使ったビルドシステムを整える

などといった取り組みを行っています。

Promise風のオブジェクト

今回のケースでPromiseが使えるようになると何が嬉しいかというと、コールバック関数をラップする、といった辛さのあるエラーハンドリングの回避が出来る点が最も顕著でした。
そこで、それなりの大きさがあるfull-featuredな代替実装を使うのではなく、素朴な .then/.catchを実現できることのみにフォーカスした、小さなPromise風オブジェクトを実装しPonyfill的に運用しています。

Ponyfill

import { PromiseLike } from "./promise"

function delay(msec: number): PromiseLike<void> {
  return new PromiseLike((resolve) => {
    setTimeout(() => resolve(), msec);
  });
}

のように、Promise風オブジェクトを使う部分で明示的にimport文を書くことで、モジュール内のスコープでのみその恩恵を受けることができるようになります。
また、このPromiseLikeには .catch がなされていない例外のスローが起きた時のハンドリングを実装しているので、想定外のランタイムエラーをログ収集基盤で検知することも実現できました。

(なお、TypeScript自体に PromiseLike<T> 型が存在していて、import無しだとそちらと見做されてしまう問題には後から気付き、対応予定というのはあります……)

モジュールローダー

AMD形式のモジュールを動的に読み込むモジュールローダーも自前で実装しています。
モジュールローダーへ求められる要件としては

  • Internet Explorerで動作する
  • 用いるPromiseの実装を上記の物に切り替えることのできる
  • グローバルではなく名前空間を持たせてモジュールの定義や展開ができる
    • これもPolyfillと同様、Webメディアのモジュールローダーとの衝突を避けるため

といった物で、このようなモジュールローダーは自分で書かなければ存在しませんでした。
AMDの仕様へ準拠しつつ、広告商材の配信頻度からimportする可能性の高いモジュールはエントリポイントとなる単一のJavaScriptへバンドルし、そうでない物は外部モジュールとしてビルドする、といった私たちのビジネスの構造に特化した実装をしています。

ビルドシステム

ここまで見てきた通り、広告スクリプトは通常のWebアプリケーションの構成とは大きく異なっています。様々なWebページからロードされ、その機能が利用されるといった特徴から、アプリケーションというよりライブラリに近い特性を持っていると言えます。
そこで、Webアプリケーションのバンドラとしてデファクトスタンダードと言えるWebpackではなく、npmモジュールのビルドでよく用いられているRollupを採用しています。

また、モジュールの分割の制御や独自のモジュールローダーの採用の必要から、RollupのCLIを直接使うのではなく、Rollupをライブラリとして利用するビルドスクリプトを記述してビルドを行っています。
言及が遅れましたが、TypeScript、ESLint、Terserといったモダンなツールチェインが使えるような環境も実現できました。

まとめ

ニッチな話題が続きましたがいかがでしたでしょうか。

class構文、const/let、Promise、async/awaitなど近年のJavaScriptの進化は目覚ましく、類を見ない先進的な構文を持った言語になりつつあります。
その書き味や開発のしやすさを維持しつつ、上記のような制約を満たすためのビルドシステムやモジュールシステムを作り込むのは、大変ですがエンジニアとして非常に魅力的な課題です。
また、Web上のトラフィックで少なくない割合を占める、広告の表示で高速化や省サイズを実現することはインターネットそのものに寄与することのできる取り組みでもあります。

今回の記事では割愛していますが、CIとLambda@Edgeを組み合わせた検証環境などのトピックもあるので、別の機会があればまだまだ話が出来そうです!

株式会社fluctでは、こんなニッチなJavaScriptで広告をよりよくする仲間を募集しています!

株式会社fluct SSP開発本部 シニアソフトウェアエンジニア | 株式会社VOYAGE GROUP
https://hrmos.co/pages/voyagegroup/jobs/fl-e04
https://hrmos.co/pages/voyagegroup/jobs/fl-e09
株式会社fluct エクスペリエンスデザインセンター フロントエンドエンジニア | 株式会社VOYAGE GROUP