Posted at
HaskellDay 4

RTBの入札プロトコルを実装する

More than 3 years have passed since last update.

(Japan) Haskell Advent Calendar 2014 の4日目のエントリです。簡単なRTBのBidderを実装します。入札戦略ではなく、プロトコル周りに焦点を当てます。


RTB(Real-time bidding)

RTBはオンライン広告の配信枠をリアルタイムに売買する仕組みです。視聴者が広告枠のあるWEBページを開いた瞬間にオークションが開始され、数百ミリ秒以内に落札者に指定された広告をWEBページに表示させるというものです。オークションを開催する側をSSP(Supply side platforms)、落札する側をDSP(Demand side platforms)と呼びます。詳細は wikipedia の説明をご覧ください。

今回は、オークションに参加するbidderを実装します。つまりDSP側のシステムとなります。既存のHaskellによるbidderの実装としては hopenRTB というものがgithubに上がっていますが、2014年12月現在、中身はほぼ空っぽです。


OpenRTB

RTBには標準化されたプロトコルが存在しており、OpenRTBと呼ばれています。まずはこのプロトコルを実装していきましょう。リファンレス実装 もありますので、参考にするといいと思います。

OpenRTBの仕様によると、オークションが発生するとbidderに対して入札を促すHTTPリクエストが送られてきます。HTTPリクエストには、その枠があるWEBページの情報や、表示可能な広告の種類、ページを見ている視聴者のブラウザの情報などが含まれており、bidderはその情報を元にいくらでその枠に対して入札するかを判断します。そしてbidderは、入札金額や表示する広告をHTTPレスポンスに含めて返却します。もちろん、入札せずに見送ることもできます。

最高値をつけることができると落札成功であり、広告を表示することができます。bidderには、落札できた場合に再度HTTPリクエストが送られて来て、これによってオークションの結果を知ることができます。この仕組みを Win Notice と呼びますが、このエントリでは実装しません。


OpenRTBのbidderの実装

bidder と言うと難しそうに聞こえますが、プロトコルを読んでわかる通り、要はWEBアプリケーションを実装すればいいことがわかります。Snapを利用してもいいのですが、通信とHTTPプロトコルについて隠蔽できれば十分なので、今回は素のWAI(Web Application Interface)アプリケーションとして作ります。

以下のようにしました。

{-# LANGUAGE LambdaCase #-}

data BidRequest
data BidResponse
type Bidder = BidRequest -> IO BidResponse
decodeRequest :: WAI.Request -> IO (Either String BidRequest)
encodeResponse :: BidResponse -> IO WAI.Response

bidderApp :: Bidder -> WAI.Application
bidderApp bidder httpreq cont = do
bidreq <- decodeRequest httpreq >>= \case
Right r -> return r
Left l -> error $ "[TODO] implement error handling: " ++ l
bidres <- bidder bidreq
httpres <- encodeResponse bidres
cont httpres

HTTPリクエストは decodeRequest によってパースされ、 Bidder はパースされたデータを元に意思決定をします。結果は encodeResponse によってHTTPレスポンスに変換され、入札が完了します。 \caseLambdaCase 拡張で、モナド値に case を直接 bind したい時に便利です。


JSONデータのパース

OpenRTBは通常JSONでやりとりされるので、JSONをパースする必要があります。パースにはAesonを使うとよいでしょう。

以下のように JSON に対応する Haskell のデータ構造を作り、 Data.Aeson.TH で JSON のエンコード・デコードを行うための FromJSONToJSON のインスタンスを導出させます。入札リクエストの中で、バナー広告用の広告枠に対するリクエストのうち最低限のフィールドだけを定義すると以下のようになります。

{-# LANGUAGE TemplateHaskell #-}

data Banner = Banner {
bannerW :: Int
, bannerH :: Int
} deriving (Show, Eq)
$(AESON.deriveJSON AESON.defaultOptions {
AESON.fieldLabelModifier = map toLower . drop 6
} ''Banner)

data Imp = Imp {
impId :: TX.Text
, impBanner :: Maybe Banner
} deriving (Show, Eq)
$(AESON.deriveJSON AESON.defaultOptions {
AESON.fieldLabelModifier = map toLower . drop 3
} ''Imp)

data BidRequest = BidRequest {
reqId :: TX.Text
, reqImp :: [Imp]
} deriving (Show, Eq)
$(AESON.deriveJSON AESON.defaultOptions {
AESON.fieldLabelModifier = map toLower . drop 3
} ''Request)

入札レスポンスは以下の通りです。

data Bid = Bid {

bidId :: TX.Text
, bidImpID :: TX.Text
, bidPrice :: Double
}
$(AESON.deriveJSON AESON.defaultOptions {
AESON.fieldLabelModifier = map toLower . drop 3
} ''Bid)

data SeatBid = SeatBid {
seatbidBid :: [Bid]
}
$(AESON.deriveJSON AESON.defaultOptions {
AESON.fieldLabelModifier = map toLower . drop 7
} ''SeatBid)

data BidResponse = BidResponse {
resId :: TX.Text
, resSeatbid :: [SeatBid]
}
$(AESON.deriveJSON AESON.defaultOptions {
AESON.fieldLabelModifier = map toLower . drop 3
} ''Response)

作った型を元に入札リクエストのパーサを書きます。 loopAllBodyaccum を正格にしてなかったり怪しい匂いがムンムンしますが、とりあえず動く実装にはしています。

{-# LANGUAGE OverloadedStrings #-}

decodeRequest :: WAI.Request -> IO (Either String BidRequest)
decodeRequest req = do
jsonstr <- loadAllBody
return $ AESON.eitherDecode (LBS.fromStrict jsonstr)
where
loadAllBody = loop ""
where
loop accum = do
body <- WAI.requestBody req
if BS.null body
then return accum
else loop (BS.append accum body)

最後に、返却するHTTPレスポンスを作成します。AESONのおかげで楽にJSONが作れますね。

encodeResponse :: BidResponse -> IO WAI.Response

encodeResponse bidres = return $ WAI.responseLBS NHT.status200 headers body
where
body = AESON.encode bidres
headers = [("Content-Type", "application/json")
, ("X-OpenRTB-Version", "2.2")]


DoubleClick Ad Exchange のプロトコルの実装

Googleが提供する DoubleClick Ad Exchange は、 OpenRTB で売買を行っていません。これを OpenRTB に変換する openrtb-doubleclick という Java のライブラリも提供されていますが、ここでは Haskell で実装しましょう。

ドキュメントを読むと、 Ad Exchange は JSON ではなく Protocol Buffers でやりとりを行うことがわかります。 Haskell だと protobuf-haskell という一連のツール群が Hackage に登録されていましたので、これを利用することにします。

protobuf-haskellは hprotoc, protocol-buffers, protocol-buffers-descriptor の三つのツールから成ります。まず、 hprotoc というコンパイラを使って Haskell のソースファイルを生成します。Googleのダウンロードページより、 realtime-bidding.proto を入手します。そして、以下のコマンドを叩くことで Protocol Buffers のエンコード・デコードに必要な Haskell のモジュール群を生成します。

$ hprotoc --haskell_out=src/ --prefix=Web.RTBBidder.Protocol realtime-bidding.proto

生成されたソースコードは、 protocol-buffers モジュールが提供する関数から使うことができます。OpenRTBの時と同様に decodeRequest を定義します。

import Text.ProtocolBuffers.Header (defaultValue)

import Text.ProtocolBuffers.WireMessage (messageGet, messagePut)
import qualified Web.RTBBidder.Protocol.Adx.BidRequest as ADXRQ
import qualified Web.RTBBidder.Protocol.Adx.BidRequest.AdSlot as ADXRQSL
import qualified Web.RTBBidder.Protocol.Adx.BidResponse as ADXRS
import qualified Web.RTBBidder.Protocol.Adx.BidResponse.Ad as ADXRSAD
import qualified Web.RTBBidder.Protocol.Adx.BidResponse.Ad.AdSlot as ADXRSSL

decodeRequest :: WAI.Request -> IO (Either String BidRequest)
decodeRequest req = do
protobufstr <- loadAllBody
let adxreq = parseAdxreq . LBS.fromStrict $ protobufstr
reqid = ADXRQ.id adxreq
adslot = flip SEQ.index 0 $ ADXRQ.adslot adxreq
slotid = ADXRQSL.id adslot
width = flip SEQ.index 0 $ ADXRQSL.width adslot
height = flip SEQ.index 0 $ ADXRQSL.height adslot
banner = Banner (fromIntegral width) (fromIntegral height)
imp = Imp (TX.pack . show $ slotid) (Just banner)
rtbreq = Request (TX.pack . show $ reqid) [imp]
return $ Right rtbreq
where
parseAdxreq input = case messageGet input of
Right (r, _) -> r :: ADXRQ.BidRequest
Left e -> error $ "[TODO] handle errors :" ++ show e

Protocol Buffers をパースし、OpenRTBと同じ形式の BidRequest に変換します。 encodeResponse も同様に実装します。長くなるので省略しますが、 messagePut を使うことで簡単に実装できます。

ここまででRTBのための2つのコネクタを実装したわけですが、一般的に Bidder には複雑なロジックが含まれているため、接続先によって別のロジックの実装が必要になると不便です。以下のように型を整理して、接続先が変わっても同じロジックを使えるようにするのがいいでしょう。

type Bidder = BidRequest -> IO BidResponse

data RTBProtocol = RTBProtocol {
rtbDecodeReq :: WAI.Request -> IO (Either String BidRequest)
, rtbEncodeRes :: BidResponse -> IO WAI.Response
}

bidderApp :: RTBProtocol -> Bidder -> WAI.Application


Network.Wai.Test を使ったテスト

ここまで実装したコードはWAIアプリケーションなので、wai-extraパッケージで提供される Network.Wai.Test を使ってテストを書くことができます。例えば、JSON形式の入札リクエストを投げて、きちんとHTTPレスポンスが返ってくるか確認するテストは以下のようになります。

testBidder :: BidRequest -> IO BidResponse

testBidder bidreq = return $ BidResponse (reqId bidreq) [seatbid]
where
imp = head . reqImp $ bidreq
bid = Bid "TODO_MAKING_UNIQUE_ID" (impId imp) 100.0
seatbid = SeatBid [bid]

main :: IO ()
main = do
jsonstr <- readFile "test/asset/openrtb22.json"
runSession (test jsonstr) (bidderApp ORTB22.protocol testBidder)
where
test jsonstr = do
res <- srequest (SRequest defaultRequest (pack jsonstr))
assertStatus 200 res
assertBodyContains "\"80ce30c53c16e6ede735f123ef6e32361bfc7b22\"" res


今後の展望

ここまでの実装は github に上げていますが、入札リクエストのフィールドをほとんど何もパースしていないので使い物にはならないでしょう。まずはここをきちんと実装する必要があります。

また、実際にDSP業者として RTB に参入するためには、まだまだ必要な機能がたくさんあります。 C++で実装された RTBkit では、HTTPリクエストへのフィルタリングや乱数による配信量のコントロール、広告予算の管理、クリックやコンバージョンなど入札後に発生するイベントの追跡、それらのデータのロギングなど、様々な機能を提供しています。さらに、構成として ZooKeeperØMQRedis を採用するなど、単なるフレームワークを超えたゴツい構成になっています。ここまでリッチな機能は提供しないにしても、distributed-processを利用した分散環境も視野に入れる必要は出てくるでしょう。

さらに、bidderは数万〜数百万のQPSで全リクエストを数十ミリ秒でレスポンスを返すことが求められるため、パフォーマンスは絶対要件となります。実際に運用しながらのパフォーマンスチューニングは必須となるでしょう。


まとめ

このエントリではbidderをHaskellで実装することで、RTBの世界に簡単に触れました。このエントリでちょっとでもアドテクに興味を持ってくれた方は、こちらの求人情報をちらっと覗いてみてはいかがでしょうか。 Haskell書かない会社 ですけどね!