作って学ぶ動画広告配信システム

この記事は[CyberAgent PTA Advent Calendar 2021](https://adventar.org/calendars/6450)の18日目の記事です。

## はじめに

今年8月にABEMAにjoinし、広告配信システムの開発に携わらせてもらっています。

ABEMAはの広告配信システムは、[パーソナライズド配信](https://ad.abematv.co.jp/news/906/)や[プログラムマッチ](https://ad.abematv.co.jp/news/1007/)など、多機能で柔軟な広告配信を実現しており、システム自体の規模も巨大です。
一方で、個人的に動画広告の配信については全く知識がなかったため、joinした当初は、全体像を掴むのにかなり苦しみました。

そこで、今回は動画広告配信システムのエッセンスだけ切り取り、実装してみることで、システムの全体像を掴むことを目標とします。
今後、動画広告配信に入門する人が全体像を掴むのに役立つ記事になればと思います。

#### おことわり

- 本記事の全コードはこちら: https://github.com/dai65527/video-ad-sample
- 動画素材はこちらからお借りしています https://pixabay.com/ja/videos/
- localで動作確認する際、Chrome、Edgeだと正常に動画広告が流れません
    - セキュリティ仕様上、使用するIMASDKのjsからlocalhostへリクエストできないらしい
    - Safariなどのブラウザでご確認ください

## 構成

今回実装する動画広告配信システムの機能は以下です。

- 動画サイト(フロントエンド)配信
- 広告配信
- 広告視聴ログのトラッキング

ABEMAはマイクロサービス構成をとっているので、上記の機能を別々のマイクロサービスが担いますが、今回は簡易的なサーバのため、以下のように同一サーバでURLによって機能を振り分けることにします。

![スクリーンショット 2021-12-17 15.14.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677011/0a48ae25-a3e9-5af3-2e4f-f51e75d40aff.png)

また、言語について、サーバサイドはABEMAで主要な開発言語であるGo言語、フロントエンドはhtml+素のJavaScriptでお届けします。

## 動画サイト(フロントエンド)配信

まず、静的ファイル配信サーバをGoで実装し、html/css/mp4を配信するサーバを実装します。

```go:main.go
func main() {
	mux := http.NewServeMux()
	mux.Handle("/", http.FileServer(http.Dir("public")))

	err := http.ListenAndServe(":8080", logger.HttpLogger(mux))
	log.Printf("server error: %v", err)
	os.Exit(1)
}
```

なお、`logger.HttpLogger`は、リクエストログを残すためのハンドラです。

```go:logger/http.go
func HttpLogger(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Println("[http request]", "method", r.Method, "url", r.URL.String())
		h.ServeHTTP(w, r)
	})
}
```

これで、`public/`以下のディレクトリのファイルを配信できるようになりましたので、フロントエンド用のhtml、css、jsを作成します。

```html:public/index.html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>vast client</title>
    <link rel="stylesheet" href="style.css">
</head>

<body>
    <div id="page-content">
        <div id="video-container">
            <video id="video-element">
                <source src="movie.mp4">
            </video>
        </div>
        <button id="play-button">Play</button>
    </div>
    <script src="app.js"></script>
</body>

</html>
```

```css:public/style.css
#page-content {
  position: relative;
  max-width: 640px;
  margin: 10px auto;
}

#video-container {
  position: relative;
  padding-bottom: 56.25%;
}

#video-element {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}
```

```js:public/app.js
var videoElement;

window.addEventListener("load", function () {
  videoElement = document.getElementById("video-element");
  var playButton = document.getElementById("play-button");
  playButton.addEventListener("click", function (event) {
    videoElement.play();
  });
});
```

あとは`public/video.mp4`にmp4動画ファイルをおき、`go run main.go`すれば、localhost:8080で動画が見ることができます。

![スクリーンショット 2021-12-17 12.01.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677011/cfc44628-4f27-b272-e0df-b1eaa55039b0.png)

ねこ可愛い。

## 広告配信サーバ

ここからが本番です。上で作った動画サイトに広告を載せていきます。
今回は、再生ボタンを押して本編の動画が再生される前に、同じウィンドウで広告動画が流れるようにします（インストリーム広告といわれるやつです）

### VAST

動画広告を配信する際は、VASTというテンプレートに沿って広告情報を配信するのが一般的です。

VASTとは、Video Ad Serving Templateの略で、IAB Tech Labが策定した、広告配信サーバと動画プレイヤー間の情報のやり取りの仕組みです。広告クリエイティブのURLや、再生時間、ログ送信先などを含んだXMLファイルを動画プレイヤーで読み込むことで、動画広告を配信することができます。

というわけで、今回実装する動画広告配信サーバもVASTを配信することで、動画広告の配信を実現します。

以下のように、リクエストを受け広告VASTのxmlを返すサーバを作成します。
なお、Go言語におけるVASTのxml定義には、https://github.com/rs/vast を使いました。

```adserver/adserver.go
package adserver

import (
	"encoding/xml"
	"fmt"
	"math/rand"
	"net/http"
	"strconv"
	"time"

	"github.com/rs/vast"
)

// AdServer 広告配信(VAST配信)サーバ
type AdServer struct{}

const TrkBaseURL = "http://localhost:8080"
const CreativeBaseURL = "http://localhost:8080"

// ServerHTTP is the interface for the HTTP server
func (s *AdServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// cors対応
	w.Header().Set("Access-Control-Allow-Origin", "http://imasdk.googleapis.com")
	w.Header().Set("Access-Control-Allow-Credentials", "true")

	// userIDの取得
	userID := r.URL.Query().Get("userID")

	// VASTを生成して返す
	w.Header().Set("Content-Type", "text/xml")
	w.Write(createVAST(userID))
}

// trackingURL 広告視聴ログ発火用のURLを生成
func trackingURL(adID, userID, event string) string {
	return fmt.Sprintf("%s/tracking?adID=%s&userID=%s&event=%s", TrkBaseURL, adID, userID, event)
}

// createVAST VASTのXMLを生成
func createVAST(userID string) []byte {
	// ユーザごとに最適な広告を選択（したい）
	adID := strconv.Itoa(rand.Int() % 10000)

	v := vast.VAST{
		Version: "3.0",
		Ads: []vast.Ad{
			{
				ID:       adID,
				Sequence: 1,

				// 広告表示に必要な情報
				InLine: &vast.InLine{
					// 配信サーバの名前
					AdSystem: &vast.AdSystem{
						Name: "sample vast server",
					},

					// 広告の名前
					AdTitle: vast.CDATAString{
						CDATA: "sample ad",
					},

					// 広告の表示ログ発火URL
					Impressions: []vast.Impression{
						{URI: trackingURL(adID, userID, "imp")},
					},

					// 広告クリエイティブの情報
					Creatives: []vast.Creative{
						{
							// Linearは動画内に挿入される動画広告
							Linear: &vast.Linear{
								// 動画広告の時間
								Duration: vast.Duration(time.Second * 10),

								// 動画広告の表示ログ発火URL
								// start/midpoint/completeでそれぞれ発火する
								TrackingEvents: []vast.Tracking{
									{Event: "start", URI: trackingURL(adID, userID, "start")},
									{Event: "midpoint", URI: trackingURL(adID, userID, "midpoint")},
									{Event: "complete", URI: trackingURL(adID, userID, "complete")},
								},

								// 動画広告クリエイティブののURL
								MediaFiles: []vast.MediaFile{
									{
										Delivery: "progressive",
										Type:     "video/mp4",
										URI:      CreativeBaseURL + "/ad/creative.mp4",
									},
								},
							},
						},
					},
				},
			},
		},
	}

	// XMLに変換
	b, _ := xml.MarshalIndent(v, "", "  ")
	return b
}
```

上のコードで指定している主な情報は、

- 動画広告の再生時間（10秒）
- 広告クリエイティブのURL（`http://localhost:8080/ad/creative.mp4`）
- ログ発火のURL（`http://localhost:8080/tracking`）

になります。（他にも、スキップの設定やインストリーム以外の形式の広告を配信することなどもできます。VASTの詳細な仕様はこちらで確認できます: https://www.iab.com/guidelines/vast/ ）

また、クエリパラメタから、userIDをとり、ユーザに対して最適な広告を選択（している風にIDを発行）して、tracking用のクエリパラメタに含めています。

あとは、これのサーバに対するルーティング設定をmain.goに追加し、広告クリエイティブ（動画ファイル）を`public/ad/creative.mp4`に追加すれば、動画配信サーバの完成です。

```go:main.go
func main() {
	mux := http.NewServeMux()
	mux.Handle("/", http.FileServer(http.Dir("public")))
	mux.Handle("/ad/vast", &adserver.AdServer{})          // 追加

	err := http.ListenAndServe(":8080", logger.HttpLogger(mux))
	log.Printf("server error: %v", err)
	os.Exit(1)
}
```

curlでリクエストすると、VASTのxmlが返ってくるのが確認できます。

```console
$ curl 'http://localhost:8080/ad/vast?userID=42'
<VAST version="3.0">
  <Ad id="3551" sequence="1">
    <InLine>
      <AdSystem><![CDATA[sample vast server]]></AdSystem>
      <AdTitle><![CDATA[sample ad]]></AdTitle>
      <Impression><![CDATA[http://localhost:8080/tracking?adID=3551&userID=42&event=imp]]></Impression>
      <Creatives>
        <Creative>
          <Linear>
            <Duration>00:00:15</Duration>
            <TrackingEvents>
              <Tracking event="start"><![CDATA[http://localhost:8080/tracking?adID=3551&userID=42&event=start]]></Tracking>
              <Tracking event="midpoint"><![CDATA[http://localhost:8080/tracking?adID=3551&userID=42&event=midpoint]]></Tracking>
              <Tracking event="complete"><![CDATA[http://localhost:8080/tracking?adID=3551&userID=42&event=complete]]></Tracking>
            </TrackingEvents>
            <MediaFiles>
              <MediaFile delivery="progressive" type="video/mp4" width="0" height="0"><![CDATA[http://localhost:8080/ad/creative.mp4]]></MediaFile>
            </MediaFiles>
          </Linear>
        </Creative>
      </Creatives>
      <Description></Description>
      <Survey></Survey>
    </InLine>
  </Ad>
</VAST>
```

## 視聴ログのトラッキングサーバ

上記のVAST読み取られ、広告動画がスタートすると、VAST内の`Impression`タグや、`TrackingEvents`タグの記述内容に応じて以下のログが`userID`、`adID`、`event`のクエリパラメタと共に`http://localhost:8080/tracking`に向けて発火されます。

- impression: 広告表示時
- start: 広告再生開始時
- midpoint: 広告再生が半分の時間を経過したら
- complete: 広告再生完了時

（VASTの使用上、他にも1/4が再生されたタイミングやミュート時など様々なログを送信することができます。任意のタイミングでログを飛ばすこともできます。）

このログがすなわち配信実績となるので、しっかり記録・保存しておく必要があります。

とはいえ、実装としては単純です。

```go:trackingserver/trackingserver.go
type TrackingServer struct{}

// ServeHTTP 広告視聴ログを記録
func (s *TrackingServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// クエリパラメタを取得
	adID := r.URL.Query().Get("adID")
	userID := r.URL.Query().Get("userID")
	event := r.URL.Query().Get("event")

	// 広告視聴ログを記録
	log.Println("[ad watch log]", "ad", adID, "user", userID, "event", event)
}
```

```go:main.go
func main() {
	mux := http.NewServeMux()
	mux.Handle("/", http.FileServer(http.Dir("public")))
	mux.Handle("/ad/vast", &adserver.AdServer{})
	mux.Handle("/tracking", &trackingserver.TrackingServer{}) // 追加

	err := http.ListenAndServe(":8080", logger.HttpLogger(mux))
	log.Printf("server error: %v", err)
	os.Exit(1)
}
```

クエリパラメタから、adID、userID、eventを取得して、ログに出力します。
なお、今回はサーバのログに出すのみですが、実際には、BigQuery等に保存して、配信レポートの作成や分析に用いることになります。

## フロントエンド側の対応

広告配信サーバ、トラッキングサーバは完成しましたが、フロントエンド側の対応が必要です。

VASTで配信される動画広告の再生にはVASTに対応したクライアント・動画プレイヤーが必要です。

VASTのクライアントとして、Googleが出している、IMASDKを用います。（https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/architecture ）

まず、htmlに、広告用のdivタグと、IMASDKを読み取るscriptタグを追加します。

```html:public/index.html
<body>
    <div id="page-content">
        <div id="video-container">
            <video id="video-element">
                <source src="movie.mp4">
            </video>
            <!-- 追加 -->
            <div id="ad-container"></div>
        </div>
        <button id="play-button">Play</button>
    </div>
    <!-- 追加 -->
    <script src="https://imasdk.googleapis.com/js/sdkloader/ima3.js"></script>
    <script src="app.js"></script>
</body>
```

```css:public/style.css
// 追加
#ad-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
```

`div#ad-container`で`video`要素を覆い、その上でIMASDKを用いて広告を配信します。

app.jsにIMASDKを使って広告をリクエスト、再生する処理を記述します。（処理の内容についてはコード中にコメントしていますが、IMASDKのドキュメントの焼き直しなので、ドキュメントを読むのが良いと思います。https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side ）

```public/app.js
var videoElement;
var adDisplayContainer;
var adsLoaded = false;
var adContainer;
var adsLoader;
var adsManager;
var userID = Math.floor(Math.random() * 10000); // ユーザID（今はランダム）

window.addEventListener("load", function () {
  videoElement = document.getElementById("video-element");

  // IMASDKの初期化処理
  initializeIMA();

  var playButton = document.getElementById("play-button");
  playButton.addEventListener("click", function (event) {
    videoElement.play();
  });

  videoElement.addEventListener("play", function (event) {
    if (adsLoaded) {
      return;
    }
    adsLoaded = true;

    event.preventDefault();

    videoElement.load();
    adDisplayContainer.initialize();

    // adManagerをスタートして広告を再生する
    try {
      adsManager.init(
        videoElement.clientWidth,
        videoElement.clientHeight,
        google.ima.ViewMode.NORMAL
      );
      adsManager.start();
    } catch (adError) {
      videoElement.play();
    }
  });
});

function initializeIMA() {
  // 動画画面をオーバーレイして広告を表示するためのコンテナ
  adContainer = document.getElementById("ad-container");

  // クリックで一時再生・停止
  // adContainerがvideo要素を覆ってしまうため、adContainerのクリックで一時停止・再生する
  adContainer.addEventListener("click", function (event) {
    if (videoElement.paused) {
      videoElement.play();
    } else {
      videoElement.pause();
    }
  });

  // adsLoaderを作成
  // 広告を取得して表示するオブジェクト
  adDisplayContainer = new google.ima.AdDisplayContainer(
    adContainer,
    videoElement
  );
  adsLoader = new google.ima.AdsLoader(adDisplayContainer);

  // 広告ロードしたらADS_MANAGER_LOADED eventからAdManagerを作成
  adsLoader.addEventListener(
    google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
    function (event) {
      adsManager = event.getAdsManager(videoElement);

      // エラーハンドリング
      adsManager.addEventListener(
        google.ima.AdErrorEvent.Type.AD_ERROR,
        onAdError
      );

      // CONTENT_PAUSE_REQUESTEDで再生を一時停止
      // 裏にいる動画プレイヤーから発火されるevent
      adsManager.addEventListener(
        google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED,
        function (event) {
          videoElement.pause();
        }
      );

      // CONTENT_PAUSE_REQUESTEDで再生を一時停止
      // 裏にいる動画プレイヤーから発火されるevent
      adsManager.addEventListener(
        google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED,
        function (event) {
          videoElement.play();
        }
      );
      adsManager.addEventListener(
        google.ima.AdEvent.Type.LOADED,
        function (event) {
          var ad = event.getAd();
          if (!ad.isLinear()) {
            videoElement.play();
          }
        }
      );
    },
    false
  );

  // ロードエラー時のハンドリング
  adsLoader.addEventListener(
    google.ima.AdErrorEvent.Type.AD_ERROR,
    onAdError,
    false
  );

  videoElement.addEventListener("ended", function () {
    adsLoader.contentComplete();
  });

  var adsRequest = new google.ima.AdsRequest();
  adsRequest.adTagUrl = "http://localhost:8080/ad/vast?userID=" + userID;
  adsRequest.linearAdSlotWidth = videoElement.clientWidth;
  adsRequest.linearAdSlotHeight = videoElement.clientHeight;

  // 広告を取得する
  adsLoader.requestAds(adsRequest);
}

function onAdError(adErrorEvent) {
  console.error("Ad Error: " + adErrorEvent.getError());
  if (adsManager) {
    adsManager.destroy();
  }
}

// 動画コンテナのresizeに追従する
window.addEventListener("resize", function (event) {
  adsManager.resize(
    videoElement.clientWidth,
    videoElement.clientHeight,
    google.ima.ViewMode.NORMAL
  );
});
```

## 動作確認

改めて、localhost:8080にアクセスします。

playボタンを押すと、10秒の広告動画が流れ、

![スクリーンショット 2021-12-17 14.53.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677011/0ce03ee4-649a-98b3-d4fd-9e12cf5796e2.png)

広告が終了が本編が再生されます。

![スクリーンショット 2021-12-17 14.41.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677011/9e0e291a-3b55-af53-32e9-773847006098.png)

ねこちゃん可愛い。

ログもちゃんと残っています。（広告動画とログを見比べると各イベントのログが適切なタイミングで発火されているのがわかると思います）

```
2021/12/17 12:53:44 [http request] method GET url /tracking?adID=5821&userID=2585&event=imp
2021/12/17 12:53:44 [ad watch log] ad 5821 user 2585 event imp
2021/12/17 12:53:44 [http request] method GET url /tracking?adID=5821&userID=2585&event=start
2021/12/17 12:53:44 [ad watch log] ad 5821 user 2585 event start
2021/12/17 12:53:49 [http request] method GET url /tracking?adID=5821&userID=2585&event=midpoint
2021/12/17 12:53:49 [ad watch log] ad 5821 user 2585 event midpoint
2021/12/17 12:53:54 [http request] method GET url /tracking?adID=5821&userID=2585&event=complete
2021/12/17 12:53:54 [ad watch log] ad 5821 user 2585 event complete
```

## まとめ

今回は最小限の機能を持った動画広告配信システムを実装しました。なんとなく動画広告配信の流れは分かっていただけたでしょうか。

今回実装した他にも、他にも複数の広告を段積みしたり、インタラクティブな広告を配信したり、インストリーム以外の広告を配信したりと、できることはまだまだたくさんあります。

また、今回は触れなかった、広告プレイリスト（VMAP）や、パーソナライズドのアルゴリズム、アドネットワークとの接続など、動画広告配信には様々な技術的トピックスがありますので、それについてもいつかまとめたいですね。