概要
前回の記事で概要を述べた通り、僕たちはUAE大学電気工学のシニア・プロジェクトで、Roadomaticというシステムを構築している。このシステムの目的は、ロケーションベースの道路交通情報を、リアルタイムでドライバーに配信できるようにすることだ。
Roadomaticは、2つの主要構成要素からなる。Android OSを対象としたクライアント・アプリケーションと、僕自身が実装した、Nodeで書かれたサーバだ。
もともと、僕たちの最優先事項は効率性だった。例えば、このシステムの通信プロトコルはUDP上のJSONで構成されているのだが、これは符号化と復号化のしやすさや、やりとりごとに使用される約200バイトのデータを考慮してのことだ。
この記事では、主にバックエンドについて話すつもりだ。特に、僕がNodeとGoで書いたサーバの、機能的には全く同じ実装2つを、比較していきたいと思う。
サーバ運用
バックエンドは2つの主要部からなる。
- UDPサーバ
- データストレージ用のMongoDBデータベース
サーバ運用の一般的な流れは次の通りだ。
- ソケットがUDPデータグラムを受信する
- データグラムの中身がJSONで復号化され、座標図形が保存される
- データベースにクエリが送信され、受信した座標が表す道路を見つける
- レスポンスオブジェクトが作成されたら、JSONで符号化される
- レスポンスのバイトを含んだデータグラムが回線を通じて送り返される
ご覧のとおり、現状、このシステムのサーバサイド処理は最小限に抑えられている。だが先々の変更で、より多くの処理をサーバに任せることになるかもしれない。
では、この処理はNodeとGoそれぞれでどのように実装されたのか?そして、この2つの実装は、互いに比較してどのくらい難しかったか?
ラウンド 1:リクエスト処理
僕たちが最初、データをJSONで送信することを選んだ理由の一つに、僕たちがかつてバックエンドで構築していた技術スタックがある。NodeとMongoDBだ。これらは両方とも、JSON(もしくは技術にウルサイ人に言わせるとBSON)に依存するところが大きい。だから案の定、データの取り扱いやデータベースへの問い合わせをNodeで行うのは非常に容易だったのだ。
しかしながら、Goで同じことを成し遂げるのは、はるかに簡単だった。Goが比較的、低級言語であることを考えると、「複雑な」オペレーションの多くがGoで簡単になるというのは、僕にとって嬉しい驚きだった。
UDPサーバ自体については、NodeもGoも標準ライブラリがあって、プロセスを大いに単純化してくれた。
UDP ソケット 始める前に、データグラム受信時におけるポートのリッスンと実行コードに関して、2つの言語を比較してみたいと思う。
// Socket setup
var socket = dgram.createSocket('udp4');
socket.bind(config.server_port, config.server_host);
socket.on('listening', () => {
console.log('Listening on %s:%d...', config.server_host, config.server_port);
});
socket.on('message', (req, remote) => {
console.log('Message #%d received!', (++c));
processRequest(req, remote, socket);
});
socket.on('error', (error) => {
console.log(error.stack);
socket.close();
});
// Request handler
var processRequest = (req, remote, socket) => {
// ...
}
Goでやった方法を見てみよう。
func main() {
// Socket setup
addr := net.UDPAddr{
Port: PORT,
IP: net.ParseIP(HOST),
}
conn, err := net.ListenUDP("udp", &addr)
if (err != nil) {
panic(err)
}
defer conn.Close()
for {
// Create a buffer for each request
buf := make([]byte, 1024)
// Read bytes into buffer (blocking)
b, addr, err := conn.ReadFromUDP(buf)
if (err != nil) {
panic(err)
}
// Spawn a goroutine for each request
go udpHandler(buf, b, n, conn, addr)
}
}
func udpHandler(buf []byte, b int, ...) {
// ...
}
リクエストの検証 次に、リクエストの妥当性チェックがどのように実装されているかを見てみよう。 両方とも、上で示した2つのコードの続きだ。 Nodeでは以下が、受信リクエストBufferが妥当かどうかをチェックするコードだ。
var processRequest = (...) => {
try {
parsed = JSON.parse(req);
// Check for correct params
if (!parsed.lat || !parsed.lng) {
throw Error();
}
} catch (e) {
console.log(e.stack);
valid = false;
}
if (valid) {
// Continue processing
}
}
Goでは、Bufferの代わりに**[]byte**をチェックしているが、考え方は同じだ。
type Request struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
func udpHandler(...) {
// Slice buffer depending on read bytes, trim spaces
clean := bytes.TrimSpace(buf[:b])
// Parse received JSON
r := Request{}
err := json.Unmarshal(clean, &r)
if err != nil {
fmt.Println(err)
return
}
// Continue processing
}
結果:引き分け 行数は同じくらいで、両バージョンとも、NodeやGoにそれほど詳しくない人にも分かりやすいと思う。
ラウンド 2:データベースへの問い合わせ
今回は、JSONをネイティブサポートしていることや、非同期プログラミング形式という点で、Nodeのほうが優れていると予想できそうだ。
まず、コードでやることを簡単に説明しよう。データベースに検索クエリを2つ作成する必要がある。
1つ目は、MongoDBの地理空間機能とGeoJSONを使って、示された座標のある道路区分を決定すること。
2つ目のクエリでは、各segmentとともに保存されているroad_idフィールドを使って、道路の詳しい情報、厳密には、通りの名前を抽出する。
Nodeの実装ではmongodbパッケージを使っており、一方Goバージョンはmgoに頼っている。
var findRoad = (parsed, db, callback) => {
const resp = newResponse();
// GeoJSON coordinate representation
const loc = {
type: 'Point',
coordinates: [parsed.lng, parsed.lat]
};
var segments = db.collection(config.segments);
// Perform query on SEGMENTS collection
// Find the road segment that contains Point `loc`
const q = {
shape: {
$geoIntersects: {
$geometry: loc
}
}
};
segments.findOne(..., (err, segment) => {
if (err) {
// Collection read error
console.log(err);
resp.online = 0;
callback(resp);
} else if (!segment) {
// No segment match!
callback(resp);
} else {
// Segment match!
var roads = db.collection(config.roads);
// Find road name using road_id
roads.findOne(..., (err, road) => {
if (err || !road) {
// Read error, return what we have[code]
console.log(err);
resp.name = "";
} else {
resp.found = 1;
resp.speed = segment.speed;
resp.name = road.name;
}
callback(resp);
});
}
});
};
う~ん、asyincがクリーンにしてくれるとはあまり思えない。
同じことをGoでどのようにやったか見てみよう。
func findRoad(req *Request) Response {
r := Response{Online: 1}
lat, lng := req.Lat, req.Lng
loc := bson.M{
"type": "Point",
"coordinates": []float64{lng, lat},
}
// Query database, retrieve road_id
session, err := mgo.Dial(fmt.Sprintf("mongodb://%v:%d", MONGO_HOST, MONGO_PORT))
if err != nil {
r.Online = 0
fmt.Println(err)
return r
}
defer session.Close()
segments := session.DB(MONGO_DBNAME).C(MONGO_SEGMENTS)
s := Segment{}
filter := bson.M{"_id": 0, "speed": 1, "road_id": 1}
query := bson.M{
"shape": bson.M{
"$geoIntersects": bson.M{
"$geometry": loc,
},
},
}
err = segments.Find(query).Select(filter).One(&s)
if err != nil {
fmt.Println(err)
return r
}
roads := session.DB(MONGO_DBNAME).C(MONGO_ROADS)
road := Road{}
err = roads.Find(bson.M{"_id": s.RoadId}).One(&road)
if err != nil {
r.Online = 0
fmt.Println(err)
return r
}
r.Found = 1
r.Speed = s.Speed
r.Road = road.Name
return r
}
結果: Go****の勝ち 僕はGoに熟達しているわけではないけれど、上のような例は、Goを使ったエラーチェックの素晴らしさを物語っていると思う。少なくとも僕にとっては、Goバージョンのほうがはるかにクリーンに見える。
もちろん、Nodeバージョンでもう少しエレガントなコードを書けたのでは?と思う方は、遠慮なく下にコメントを残していってほしい。というかいっそのこと、Githubで僕たちにプルリクエストを送って!
ラウンド 3:パフォーマンス
これは、テスト結果をまとめた表だ。応答時間にはクライアント(ここにある)からサーバ(ここにある)までのRTT(ラウンドトリップタイム)が含まれていることにご留意いただきたい。
メモリ使用量 | 応答時間 | コンパイル | サイズ | 依存関係 | |
---|---|---|---|---|---|
Node | 25 MB | 150 ms | 未 | ― | mongodb |
Go | 1.5 MB | 150 ms | 済 | 6 MB | なし |
結果: Go****の勝ち
今回は明らかに、Goの圧勝だ。
Goの実装でのメモリ使用量には、ただただ驚くばかりだ。制限のあるサーバでバックエンドの実行ができる。僕たちの場合、512MBのコンピュータでNodeバージョンを実行しているが、メモリ使用はほぼ限界ギリギリだ。MongoDBに必要なメモリを考えると、できることなら数MBを節約したいところだ。
結論
要するに、サーバベースのアプリケーションを書くなら、NodeもGoも素晴らしい選択だ。しかし、僕たちの特殊な使用事例においては、Goがほぼあらゆる点においてとにかく優れていた。
僕たちは現在、まだNode実装をテストに使用しているが、最終的にRoadomaticを製品展開していくとなれば、Goバージョンに換えていくことになりそうだ。
いずれにせよ、NodeからGoにシステムを移植したことは個人的に素晴らしい経験だった。僕はいつもGoを学びたいと思っていたし、今回のこの経験は、とにかくやってみるには最高の口実になった。
原文:http://assil.me/2015/11/07/roadomatic-node-vs-go.html (2016-1-15) ※元記事の筆者には直接翻訳の許可を頂いて、翻訳・公開しております。