ProcessingでHTTP通信を可視化する

  • 26
    Like
  • 0
    Comment
More than 1 year has passed since last update.

はじめに

Processingで遊んでいるとCarnivoreというTCP/UDPのデータをキャプチャするためのライブラリを見つけたので、これを使ってHTTP通信を可視化してみることにしました。

可視化の方針

HTTPプロトコルをやり取りするための1TCPコネクションを1つの円とし、その円の直径を該当コネクションにおけるデータの送受信量に比例するように描画します。
円の描画位置, 色はランダムに決定します。
例えばブラウザで適当なWebページを開くとhtmlやらcss, js等のファイルのダウンロードが行われますから、そういったシーンでは大小様々で色とりどりな円が描画されて結構面白く見えるんじゃないかなぁという思惑です。

それに加えて、あるWebページのデータを取得する際のHTTPプロトコルのバージョンがHTTP/1.1である場合とHTTP/2である場合の差異も見てみたいと思います。
HTTP/2ではストリームの多重化とやらが行われていて1本のTCPコネクションで諸々のデータを送受信するそうなので、そうなると小さめの円が大量に描画されるのではなく、1つのドデカイ円がばーんと描画されるんじゃないでしょうか。
ちょっとHTTP/2は理解がぼんやりなんですが、そういうのも見れればいいなぁと思います。

使用する環境について

OS, Processing, Carnivore, ブラウザのバージョンは以下の通りです。

  • Mac OS X El Capitan 10.11.2
  • Processing - 3.0.1
  • Carnivore - 7
  • Chrome - 47.0.2526.106

Carnivoreのインストール

CarnivoreのインストールはProcessingのIDEの"スケッチ"メニューからライブラリのインポートを通して行うことができるので、ドキュメントを見つつちゃちゃっと済ませます。
フィルタから"Carnivore"で検索すれば見つかるかと思います。

Installation Instructions

試す前に

Carnivoreのページのサンプルには、使用前にWindowsならwinpcapのインストール、Macの場合はsudo chmod 777 /dev/bpf*を実行してプロミスキャスモードを有効にするようにして、作業が終わったらリブートしなさいよとの文言があります。忘れず確認しましょう。

実装要点

CarnivoreはTCPセグメントの一部をキャプチャしてくれるだけなので、1コネクションあたりどれくらいのデータが送受信されたかは自分で積算しておく必要があります。
ドキュメントによると、TCPセグメントのうち取得できそうな情報は送信元, 送信先のIPアドレスとポート、送信されるデータ部分くらいです。
なので今回は送信元, 送信先のIPアドレスとポートが一致しているパケット群を1コネクションとみなし、送受信されたデータ量を積算します。(データが再送されて被ったりとか、そもそも被ったデータがCarnivoreを通して取得されるのかとか色々思うところはありますが...)

以下のような簡単なメソッドで、パケットから一意なIDを求めるようにしておきます。

public String createConnectionId() {
  String sender = packet.senderSocket(); // IPアドレスとポートをコロンで結合した書式
  String receiver = packet.receiverSocket();

  // 辞書順で小さい方の文字列が先頭に来るようにして、送受信どちらでも一意なIDが返るようにしておく
  return sender.compareTo(receiver) < 0 ? sender + "-" + receiver : receiver + "-" + sender;
}

切断に関しても、TCPのコントロールフラグが取得できないため適当に処理します。
今回は最後にデータが送受信されてから数秒程度経過したコネクション(円)を画面上から消すようにします。

要点としてはこれくらいで、あとは画面に円を綺麗に出したりとか、細かい部分の実装になるため説明は割愛します。

コード一式

というわけで今回の可視化に必要なコードは以下になります。

http_visualize

import java.util.*;
import org.rsg.carnivore.*;
import org.rsg.lib.Log;

CarnivoreP5 carnivore;
HashMap<String, TcpNode> nodes = new HashMap<String, TcpNode>();

void setup() {
  size(1200, 900);
  smooth();

  Log.setDebug(true);
  carnivore = new CarnivoreP5(this); 
  carnivore.setShouldSkipUDP(true); // UDPは無視
}

void draw() {
  background(255);
  drawPacketPerFrame();
  updateNodes();
}

synchronized void drawPacketPerFrame() {
  fill(70);
  textSize(25);
  text("Connections: " + nodes.size(), 10, 30);
}

// パケット受信ハンドラ
synchronized void packetEvent(CarnivorePacket p){
  Packet packet = new Packet(p);
  if (packet.isEmpty() || !packet.isHttpPacket()) {
    return; 
  }

  // 上述した一意なIDをキーにする
  String id = packet.createConnectionId();
  if (!nodes.containsKey(id)) {
    nodes.put(id, new TcpNode((int)random(200, 1000), (int)random(200, 700), 0.001));
  }

  nodes.get(id).addCommunicatedBytes(packet.getDataSize());
}

synchronized void updateNodes() {
  LinkedList<String> deactivatedIds = new LinkedList<String>();

  for(HashMap.Entry<String, TcpNode> e : nodes.entrySet()) {
    TcpNode node = e.getValue();
    if (node.isActive() && !node.hasCommunicated()) {
       node.deactivate();
    }

    node.draw();
    if (node.isDeactivated()) {
      deactivatedIds.add(e.getKey());
    }
  }

  for (String id : deactivatedIds) {
    nodes.remove(id);
  }
}
node

class Node {

  static final float EASING = 0.03;
  static final float DEACTIVATE_EASING = 0.06;

  color baseColor;
  int x, y, size;
  float displayedSize = 1;
  boolean active = true;
  float alpha = 1.0;

  Node(int x, int y, int size) {
    this.x = x;
    this.y = y;
    this.size = size;
    baseColor = createRandomColor();
  }

  private color createRandomColor() {
    return color(
      random(255),
      random(255),
      random(255)
    );
  }

  public int getX() {
    return x; 
  }

  public int getY() {
    return y; 
  }

  public int getSize() {
    return size; 
  }

  protected void setSize(int newSize) {
    size = newSize;
  }

  protected float getAlpha() {
    return alpha; 
  }

  public float calcOuterSize() {
    return getSize() + getSize() / 4.0;
  }

  public boolean isActive() {
    return active; 
  }

  public void deactivate() {
    active = false; 
  }

  public boolean isDeactivated() {
    return alpha <= 0.01;
  }

  public void draw() {

    if (!active) {
      alpha = alpha - alpha * DEACTIVATE_EASING;
    }

    displayedSize = displayedSize + (getSize() - displayedSize) * EASING;
    float outer = displayedSize + displayedSize / 4.0;

    fill(baseColor, 70 * alpha);
    noStroke();
    ellipse(x, y, outer, outer);

    fill(baseColor, 160 * alpha);
    stroke(90, alpha * 255);
    strokeWeight(1);
    ellipse(x, y, displayedSize, displayedSize);
  }
}
tcp_node

class TcpNode extends Node {

  static final int BASE_SIZE = 20;
  static final int GROWN_LIMIT = 600;

  float grownPerBytes;
  int totalCommunicatedBytes = 0;
  long lastCommunicated;

  // grownPerBytesで1バイト受信でどれくらい円を大きくするかを決定
  TcpNode(int x, int y, float grownPerBytes) {
    super(x, y, BASE_SIZE); 
    this.grownPerBytes = grownPerBytes;
    lastCommunicated = new Date().getTime();
  }

  public void addCommunicatedBytes(int bytes) {
     totalCommunicatedBytes += bytes;

     int grownSize = BASE_SIZE + (int)(totalCommunicatedBytes * grownPerBytes);
     setSize(min(grownSize, GROWN_LIMIT));
     lastCommunicated = new Date().getTime();
  }

  public boolean hasCommunicated() {
    long now = new Date().getTime();
    return now - lastCommunicated < 5000; // 5秒以上パケット受信なしで切断判定
  }

  public void draw() {
    super.draw();

    fill(100, 255 * getAlpha());
    textSize(12);
    String txt = String.valueOf(totalCommunicatedBytes) + " Bytes";
    float txtWidth = textWidth(txt);

    text(txt, getX() - txtWidth / 2, getY() + calcOuterSize() / 2 + 13);
  }
}
packet

class Packet {
   CarnivorePacket packet;

   Packet(CarnivorePacket packet) {
     this.packet = packet; 
   }

   public int getDataSize() {
     return packet.data.length;
   }

   public boolean isEmpty() {
     return getDataSize() == 0;
   }

   public boolean isHttpPacket() {
     return (
       packet.senderPort == 80 ||
       packet.senderPort == 443 ||
       packet.receiverPort == 80 ||
       packet.receiverPort == 443
     ); 
   }

   public String createConnectionId() {
     String sender = packet.senderSocket();
     String receiver = packet.receiverSocket();

     return sender.compareTo(receiver) < 0 ? sender + "-" + receiver : receiver + "-" + sender;
   }
}

Webページへのアクセスを観察する

適当なWebページにアクセスしてみる

いい感じですね(でも色が完全ランダムなのでちょっと汚い)

qiita_top.gif

ニコニコ動画とかはリソース多そう。

nico_top.gif

HTTP/1.1とHTTP/2の比較

次はデータをHTTP/1.1とHTTP/2でやりとりした場合の差異について見てみます。
自分でHTTP/2のサーバを用意するのはちょっとめんどくさかったので、どこか簡単に比較できるページがないか探してみると以下のページが見つかりました。

HTTP vs HTTPS Test - https://www.httpvshttps.com/

1クリックでHTTP/1.1, HTTP/2のリクエストを試行出来ていい感じですね。

HTTP/1.1での通信

ではスケッチを実行しつつリクエストを行ってみます。まずはHTTP/1.1の場合。

http_1_1.gif

ダウンロードされるチェックマーク画像の分だけ小さい円が大量に表示されるかと思いましたがちょっと違いますね。
このチェックマーク画像のやり取りに関してはレスポンスヘッダにConnection:keep-aliveが含まれているため、複数回のダウンロードにわたって1つのTCPコネクションが再利用されることになります。
「じゃあ円は1つしか表示されないはずじゃん」という話ですが、ChromeではWebページで指定されたリソースをダウンロードする際に、TCPコネクションを最大6つまで同時接続するそうです。
スケッチの実行中もチェックマークのダウンロードが進むにつれ6つの円が大きくなっていってますね。

ちなみにRFCの仕様上では"3つ以上のTCPコネクションは使わないように"となっているらしいですよ。
以下はRFC2616の"8.1.4 Practical Considerations"からの引用です。

Clients that use persistent connections SHOULD limit the number of
simultaneous connections that they maintain to a given server. A
single-user client SHOULD NOT maintain more than 2 connections with
any server or proxy.

HTTP/2での通信

では先ほどと同じページで、今度はHTTP/2による通信の様子を見てみましょう。

http_2.gif

HTTP/1.1の時と違い、円(TCPコネクション)は1つだけで、その大きさから1つのコネクションで多くのデータを送受信してることがわかりますね。
HTTP/2ではストリームの多重化というのが行われていて、これにより1つのTCPコネクションで多くのリソースのやり取りを同時に行うことが可能になっているそうです。

最後に

どうでしょう。HTTP/1.1とHTTP/2との違いなんかは、結構視覚的に違いが分かりやすくて個人的に楽しかったです。

ただ1つだけ問題があって、このスケッチ、実行中は問題ないんですが終了時にたまにクラッシュします。多分Carnivoreとの兼ね合いだと思うんですが、いまいち原因もわからないし実行中は問題ないので放置してしまいました。
もし手元でコードを実行される場合は、その点だけ注意してもらえればと思います。