コーヒーポット制御プロトコル(HTCPCP / RFC 2324)詳解と C# フルスクラッチ実装
0. はじめに ―― なぜコーヒーポットにプロトコルが要るのか
皆さんはコーヒーポット制御プロトコル(RFC 2324)というものをご存じだろうか。
RFC 2324 は 1998 年 4 月 1 日、Larry Masinter(当時 Xerox PARC)によって公開された、いわゆる エイプリルフール RFC である。ジョークではあるが、ジョークとして優秀すぎた結果、そこで定義された 418 I'm a teapot は現在も多くの Web フレームワークに実装が残り、HTTP を学ぶ者への通過儀礼のようになっている。
冗談 RFC を「フルスクラッチで実装する」ことには、実は真っ当な教育的価値がある。HTCPCP は HTTP の薄い拡張として設計されているため、これを一から書くと、HTTP のワイヤフォーマット(リクエストライン・ヘッダ・ボディ)を自分の手でパースするという、普段フレームワークが隠してくれている部分を丸ごと体験できる。HttpListener や HttpClient に頼らず TcpListener / TcpClient から書くのは、そのためだ。
本稿の構成は次の通り。
- 仕様編 ―― RFC 2324 を条項ごとに読む
- 実装編 ―― C# で server / client / parser を全文実装
- 疎通編 ―― ビルドし、自前クライアントと curl の両方で疎通確認
第 1 部:RFC 2324 仕様編
1.1 背景 ―― Trojan Room Coffee Pot
そもそもの発端は、ケンブリッジ大学コンピュータ研究所の「トロイの部屋(Trojan Room)」に置かれた 1 台のコーヒーメーカーである。研究者たちは、廊下まで淹れに行ったのにポットが空だった、という悲劇を避けるため、カメラの映像を配信して残量を監視した。これが後に「世界初の Web カメラ」として知られるようになる。RFC 2324 はこの逸話(および CMU の Coke マシン、Internet Toaster など)を下敷きに、「ネットワーク接続されたコーヒーポットには制御プロトコルが要る」という体で書かれている。
1.2 なぜ HTTP を土台にするのか(例の三段論法)
RFC 本文には、HTCPCP を HTTP の上に構築する理由として、次の有名な循環論法が置かれている。要旨は「HTTP はどこにでもある。これほど普及したのだから良いものに違いない。ゆえに HTTP は良い。良いコーヒーが欲しいなら HTCPCP も良くあるべきで、そのためには良いものである HTTP を土台にするのが良い」というもの。ジョークだが、「既存の普及プロトコルに薄く相乗りする」という設計判断そのものは真面目な工学的教訓でもある。
1.3 追加メソッド(§2.1)
HTCPCP は HTTP に対し、いくつかのメソッド・ヘッダ・リターンコードを足すだけの拡張である。追加・再解釈されるメソッドは 4 つ。
| メソッド | 由来 | 意味 |
|---|---|---|
| BREW(および POST) | §2.1.1 | 抽出の開始/停止。ボディ start / stop を送る。サーバは BREW と POST を等価に扱わねばならないが、POST 利用は非推奨 |
| GET | §2.1.2 | ポットの状態を取得。ただし「コーヒー URI が指すのは物理資源であり、大半の "データ" にはカフェインが含まれない」と皮肉が付く |
| PROPFIND | §2.1.3 | 淹れられた資源のメタデータを WebDAV 流に取得 |
| WHEN | §2.1.4 | ミルクを注いでいるとき「もう十分(when)」と告げて止める |
BREW が新設された理由づけも人を食っている。「コーヒーポットは電気で加熱し火を使わないので firewall は不要」「ただし POST はコーヒーの商標かもしれないので BREW を足した」といった調子である。
1.4 ヘッダフィールド(§2.2)
Safe レスポンスヘッダ(§2.2.1.1)
リクエストの再送が安全かを示す Safe ヘッダを拡張する。文法は次の通り。
Safe = "Safe" ":" safe-nature
safe-nature = "yes" | "no" | conditionally-safe
conditionally-safe = "if-" safe-condition
safe-condition = "user-awake" | token
Safe: if-user-awake(利用者が起きていれば安全)のような条件付き安全性を表現できる。抽出開始のような副作用のある操作に、機械的な冪等性とは別の「人間側の条件」を持ち込んでいるのが面白い。
Accept-Additions ヘッダ(§2.2.2.1)
HTCPCP 独自の新ヘッダ。通常の Accept が「受理可能なメディアタイプ」を表すのに対し、こちらは コーヒーに加える副材料(addition) を指定する。文法(抜粋)は次の通り。
Accept-Additions = "Accept-Additions" ":" #( addition-range [ accept-params ] )
addition-type = ( "*" | milk-type | syrup-type | sweetener-type
| spice-type | alcohol-type ) *( ";" parameter )
milk-type = ( "Cream" | "Half-and-half" | "Whole-milk"
| "Part-Skim" | "Skim" | "Non-Dairy" )
syrup-type = ( "Vanilla" | "Almond" | "Raspberry" | "Chocolate" )
alcohol-type = ( "Whisky" | "Rum" | "Kahlua" | "Aquavit" )
実装上の注意:文法には
sweetener-typeとspice-typeも列挙されているが、RFC 本文はその具体名を定義していない。したがって実装側で「対応する甘味料・スパイスの集合」を定義するしかない。本稿では未定義扱い(=非対応)とし、要求されたら後述の 406 を返す。
なお §2.2.3 には「デカフェのオプションは用意しない。意味がないだろう?」という一文だけの節がある。
1.5 リターンコード(§2.3)
| コード | 意味 |
|---|---|
| 406 Not Acceptable(§2.3.1) | Accept-Additions の要求に応えられないとき返す。HEAD 以外なら、応答ボディに利用可能なアディションの一覧を含めるべき(SHOULD) |
| 418 I'm a teapot(§2.3.2) | ティーポットでコーヒーを淹れようとしたら返す。応答ボディは "short and stout"(ずんぐりむっくり)でよい ―― 童謡 I'm a Little Teapot への引用 |
1.6 coffee: URI スキーム(§3)と message/coffeepot(§4)
コーヒーは国際的なので、URI スキームも国際化されている。「コーヒー」を意味する語を 29 言語ぶん、UTF-8 の URL エンコードで書ける。英語なら coffee:、日本語なら %E3%82%B3%E3%83%BC%E3%83%92%E3%83%BC:(=「コーヒー」)といった具合。複数ポットを持つ機械のために pot-designator = "pot-" integer が用意される。
BREW / POST のエンティティボディはメディアタイプ message/coffeepot でなければならず、その中身は次の 1 行に尽きる。
coffee-message-body = "start" | "stop"
1.7 RFC 内の“表記揺れ”という実装上の落とし穴
ここが実装者にとって最重要ポイントである。RFC 2324 は Content-Type について 2 か所で異なることを言っている。
- §2.1.1:「ボディの Content-Type を
application/coffee-pot-commandに設定する」 - §4:「POST / BREW のエンティティボディは Content-Type
message/coffeepotでなければならない」
これは原典に含まれる矛盾(不整合)である。ジョーク RFC とはいえ、「仕様書が自己矛盾しているとき、実装はどう振る舞うべきか」という現実の課題を体現している。本実装は 両方を受理し、それ以外でも寛容に処理する(Postel の法則:送るものは厳格に、受けるものは寛容に)方針を採る。
1.8 その後 ―― RFC 7168 と、実世界の 418
- RFC 7168(HTCPCP-TEA, 2014) が RFC 2324 を更新し、紅茶用に拡張した。ティーポットに関する扱いや、茶葉の種類などを追加している。
418 I'm a teapotは冗談コードだが、実装として広く残った。2017 年に一部フレームワークが削除を検討した際、"Save 418" 運動が起きて存続した経緯がある。IANA のステータスコードレジストリにも「(Unused)」として予約が残る。
以上を踏まえ、実装に移る。
第 2 部:C# フルスクラッチ実装
2.1 設計方針
- 生ソケットで書く:
HttpListener/HttpClientを使わず、TcpListener/TcpClientから HTTP 風メッセージを自前でパース/生成する。これがフルスクラッチの肝。 - 応答ステータスラインは
HTTP/1.1:HTCPCP は HTTP 上に載る拡張なので、418等も HTTP レスポンスとして返す。これにより curl など既存 HTTP クライアントとそのまま相互運用できる。 - ポットは状態機械:
Ready → Brewing → Finishedの簡易ステートを持ち、複数接続からの操作をロックで保護する。 - RFC の矛盾を吸収:Content-Type は 2 種を許容。
2.2 ファイル構成
src/
├── Htcpcp.cs … プロトコルモデル(Request/Response)+ 生パーサ
├── HtcpcpServer.cs … サーバ(ポット状態機械・メソッドディスパッチ)
├── HtcpcpClient.cs … クライアント(リクエスト送信・応答パース)
├── Program.cs … 疎通確認デモ(エントリポイント)
└── Htcpcp.csproj … .NET 用プロジェクトファイル
以下、全ファイルを省略なしで掲載する。
尚、使用したソースコードは GitHub にもアップしている。
GitHub
GitHub - jundo414/coffeepot
Contribute to jundo414/coffeepot development by creating an account on GitHub.
https://github.com/jundo414/coffeepot
2.3 Htcpcp.cs ―― モデルと生パーサ
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace Htcpcp
{
// ------------------------------------------------------------------
// ステータスコード(標準 HTTP + HTCPCP 固有)
// 406 Not Acceptable : Accept-Additions を満たせない (RFC 2324 §2.3.1)
// 418 I'm a teapot : ティーポットでコーヒーを淹れようとした (§2.3.2)
// ------------------------------------------------------------------
public static class StatusCodes
{
public const int OK = 200;
public const int Accepted = 202;
public const int MultiStatus = 207; // WebDAV (PROPFIND)
public const int BadRequest = 400;
public const int NotFound = 404;
public const int MethodNotAllowed = 405;
public const int NotAcceptable = 406;
public const int ImATeapot = 418;
public const int ServiceUnavailable = 503;
public static string ReasonPhrase(int code)
{
switch (code)
{
case 200: return "OK";
case 202: return "Accepted";
case 207: return "Multi-Status";
case 400: return "Bad Request";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 406: return "Not Acceptable";
case 418: return "I'm a teapot";
case 503: return "Service Unavailable";
default: return "Unknown";
}
}
}
// ------------------------------------------------------------------
// HTCPCP メソッド(RFC 2324 §2.1)
// ------------------------------------------------------------------
public static class Methods
{
public const string Brew = "BREW"; // §2.1.1 抽出開始/停止
public const string Post = "POST"; // §2.1.1 BREW と等価(非推奨)
public const string Get = "GET"; // §2.1.2 状態取得
public const string Propfind = "PROPFIND"; // §2.1.3 メタデータ取得
public const string When = "WHEN"; // §2.1.4 ミルクを止める
}
// ------------------------------------------------------------------
// 受信リクエスト
// ------------------------------------------------------------------
public class HtcpcpRequest
{
public string Method;
public string Target; // 例: /pot-0
public string Version; // 例: HTTP/1.1
public Dictionary<string, string> Headers; // 大文字小文字を無視
public string Body;
public HtcpcpRequest()
{
Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Body = "";
}
public string Header(string name)
{
string v;
return Headers.TryGetValue(name, out v) ? v : null;
}
// Accept-Additions ヘッダをアディション名の列に分解する。
// "Cream;portion=2, Vanilla" -> ["Cream", "Vanilla"]
public List<string> AcceptAdditions()
{
var result = new List<string>();
var raw = Header("Accept-Additions");
if (string.IsNullOrEmpty(raw)) return result;
foreach (var part in raw.Split(','))
{
var token = part.Trim();
if (token.Length == 0) continue;
int semi = token.IndexOf(';'); // ";" 以降のパラメータを捨てる
if (semi >= 0) token = token.Substring(0, semi).Trim();
if (token.Length > 0) result.Add(token);
}
return result;
}
}
// ------------------------------------------------------------------
// 送信レスポンス
// ------------------------------------------------------------------
public class HtcpcpResponse
{
public int Status;
public Dictionary<string, string> Headers;
public string Body;
public HtcpcpResponse(int status)
{
Status = status;
Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Body = "";
}
// ステータスライン + ヘッダ + ボディをバイト列へ直列化する。
// HTCPCP は HTTP 上に載るため、応答ステータスラインは HTTP/1.1 を用いる。
public byte[] ToBytes()
{
var bodyBytes = Encoding.UTF8.GetBytes(Body ?? "");
var sb = new StringBuilder();
sb.Append("HTTP/1.1 ").Append(Status).Append(' ')
.Append(StatusCodes.ReasonPhrase(Status)).Append("\r\n");
if (!Headers.ContainsKey("Content-Type"))
Headers["Content-Type"] = "text/plain; charset=utf-8";
Headers["Content-Length"] = bodyBytes.Length.ToString();
if (!Headers.ContainsKey("Connection"))
Headers["Connection"] = "close";
foreach (var kv in Headers)
sb.Append(kv.Key).Append(": ").Append(kv.Value).Append("\r\n");
sb.Append("\r\n");
var headBytes = Encoding.UTF8.GetBytes(sb.ToString());
var all = new byte[headBytes.Length + bodyBytes.Length];
Buffer.BlockCopy(headBytes, 0, all, 0, headBytes.Length);
Buffer.BlockCopy(bodyBytes, 0, all, headBytes.Length, bodyBytes.Length);
return all;
}
}
// ------------------------------------------------------------------
// フルスクラッチのワイヤパーサ(HTTP 風メッセージを手で解析する)
// ------------------------------------------------------------------
public static class WireParser
{
// ストリームからリクエストを 1 件読み切る。接続が閉じられていたら null。
public static HtcpcpRequest ReadRequest(Stream stream)
{
var headerText = ReadUntilDoubleCrlf(stream);
if (headerText == null) return null;
var req = new HtcpcpRequest();
var lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None);
// リクエストライン: METHOD SP request-target SP HTTP-version
var parts = lines[0].Split(' ');
if (parts.Length < 3)
throw new FormatException("Malformed request line: " + lines[0]);
req.Method = parts[0];
req.Target = parts[1];
req.Version = parts[2];
// ヘッダ行
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.Length == 0) continue;
int colon = line.IndexOf(':');
if (colon <= 0) continue;
var name = line.Substring(0, colon).Trim();
var value = line.Substring(colon + 1).Trim();
req.Headers[name] = value;
}
// ボディ(Content-Length のバイト数だけ読む)
var cl = req.Header("Content-Length");
if (!string.IsNullOrEmpty(cl))
{
int length;
if (int.TryParse(cl, out length) && length > 0)
req.Body = Encoding.UTF8.GetString(ReadExactly(stream, length));
}
return req;
}
// ヘッダ終端 "\r\n\r\n" までを読み取り、その手前までを返す。
private static string ReadUntilDoubleCrlf(Stream stream)
{
var buffer = new List<byte>();
int b;
while ((b = stream.ReadByte()) != -1)
{
buffer.Add((byte)b);
int n = buffer.Count;
if (n >= 4 &&
buffer[n - 4] == (byte)'\r' && buffer[n - 3] == (byte)'\n' &&
buffer[n - 2] == (byte)'\r' && buffer[n - 1] == (byte)'\n')
{
return Encoding.ASCII.GetString(buffer.ToArray(), 0, n - 4);
}
}
if (buffer.Count == 0) return null;
return Encoding.ASCII.GetString(buffer.ToArray());
}
// 指定バイト数を確実に読み取る。
private static byte[] ReadExactly(Stream stream, int count)
{
var result = new byte[count];
int offset = 0;
while (offset < count)
{
int read = stream.Read(result, offset, count - offset);
if (read <= 0) break;
offset += read;
}
return result;
}
}
}
要点:ReadUntilDoubleCrlf は 1 バイトずつ読んで \r\n\r\n を探し、ヘッダ本体を取り出す。その後 Content-Length の数だけ ReadExactly でボディを読む。これでフレーミング(どこまでが 1 メッセージか)を自前で解決している。フレームワークが裏でやっているのは、まさにこの処理である。
2.4 HtcpcpServer.cs ―― ポット状態機械
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
namespace Htcpcp
{
// 器具の種類。ティーポットに BREW すると 418 を返す。
public enum ApplianceKind { CoffeePot, Teapot }
// ポットの内部状態
public enum PotState { Ready, Brewing, Finished }
public class HtcpcpServer
{
private readonly IPAddress _address;
private readonly int _port;
private readonly ApplianceKind _kind;
private TcpListener _listener;
private Thread _acceptThread;
private volatile bool _running;
// ポットの内部状態(複数接続から触るのでロックで保護)
private PotState _state = PotState.Ready;
private bool _milkPouring = false;
private readonly object _lock = new object();
// 対応アディション:RFC 2324 §2.2.2.1 で列挙される milk / syrup / alcohol。
// sweetener-type / spice-type は文法には現れるが RFC 本文で具体名が
// 定義されていないため、ここでは実装定義として扱う(=対応しない)。
private static readonly HashSet<string> SupportedAdditions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
// milk-type
"Cream", "Half-and-half", "Whole-milk", "Part-Skim", "Skim", "Non-Dairy",
// syrup-type
"Vanilla", "Almond", "Raspberry", "Chocolate",
// alcohol-type
"Whisky", "Rum", "Kahlua", "Aquavit"
};
public HtcpcpServer(IPAddress address, int port, ApplianceKind kind)
{
_address = address;
_port = port;
_kind = kind;
}
public void Start()
{
_listener = new TcpListener(_address, _port);
_listener.Start();
_running = true;
_acceptThread = new Thread(AcceptLoop) { IsBackground = true };
_acceptThread.Start();
}
public void Stop()
{
_running = false;
try { _listener.Stop(); } catch { }
}
private void AcceptLoop()
{
while (_running)
{
TcpClient client;
try { client = _listener.AcceptTcpClient(); }
catch { break; } // Stop() で listener が閉じられた
var t = new Thread(() => HandleClient(client)) { IsBackground = true };
t.Start();
}
}
private void HandleClient(TcpClient client)
{
using (client)
using (var stream = client.GetStream())
{
try
{
var req = WireParser.ReadRequest(stream);
if (req == null) return;
var res = Dispatch(req);
var bytes = res.ToBytes();
stream.Write(bytes, 0, bytes.Length);
stream.Flush();
}
catch (Exception ex)
{
var res = new HtcpcpResponse(StatusCodes.BadRequest)
{ Body = "Bad Request: " + ex.Message + "\n" };
var bytes = res.ToBytes();
try { stream.Write(bytes, 0, bytes.Length); } catch { }
}
}
}
// メソッドディスパッチ
private HtcpcpResponse Dispatch(HtcpcpRequest req)
{
switch (req.Method)
{
case Methods.Brew:
case Methods.Post: return HandleBrew(req);
case Methods.Get: return HandleGet(req);
case Methods.Propfind: return HandlePropfind(req);
case Methods.When: return HandleWhen(req);
default:
var res = new HtcpcpResponse(StatusCodes.MethodNotAllowed)
{ Body = "Method " + req.Method + " is not supported.\n" };
res.Headers["Allow"] = "BREW, POST, GET, PROPFIND, WHEN";
return res;
}
}
// BREW / POST : 抽出開始(body="start")または停止(body="stop")
private HtcpcpResponse HandleBrew(HtcpcpRequest req)
{
// ティーポットにコーヒーを頼んだら 418(RFC 2324 §2.3.2)
if (_kind == ApplianceKind.Teapot)
{
var teapot = new HtcpcpResponse(StatusCodes.ImATeapot);
teapot.Headers["Safe"] = "no";
// 「short and stout(ずんぐりむっくり)」なボディ
teapot.Body =
"I'm a teapot.\n" +
" _______\n" +
" | |\\\n" +
" | | ) short\n" +
" |_______|/ and stout\n";
return teapot;
}
// Content-Type:RFC 内で表記が割れている(§2.1.1=application/coffee-pot-command,
// §4=message/coffeepot)ため両方を許容し、それ以外は寛容に受ける。
var ct = req.Header("Content-Type");
bool ctOk = ct != null &&
(ct.StartsWith("application/coffee-pot-command", StringComparison.OrdinalIgnoreCase) ||
ct.StartsWith("message/coffeepot", StringComparison.OrdinalIgnoreCase));
// coffee-message-body = "start" | "stop"(RFC 2324 §4)
var command = (req.Body ?? "").Trim().ToLowerInvariant();
if (command == "stop")
{
lock (_lock)
{
_state = PotState.Ready;
_milkPouring = false;
}
return new HtcpcpResponse(StatusCodes.OK)
{ Body = "Brewing stopped. Pot is now idle.\n" };
}
if (command != "start")
{
return new HtcpcpResponse(StatusCodes.BadRequest)
{
Body = "coffee-message-body must be \"start\" or \"stop\" " +
"(got: \"" + req.Body + "\").\n"
};
}
// Accept-Additions の充足チェック
var requested = req.AcceptAdditions();
var unsupported = new List<string>();
foreach (var a in requested)
if (a != "*" && !SupportedAdditions.Contains(a))
unsupported.Add(a);
if (unsupported.Count > 0)
{
// 406 + 利用可能なアディション一覧(RFC 2324 §2.3.1)
var na = new HtcpcpResponse(StatusCodes.NotAcceptable);
var sb = new StringBuilder();
sb.Append("Cannot comply with Accept-Additions: ")
.Append(string.Join(", ", unsupported.ToArray())).Append("\n\n");
sb.Append("Available additions:\n");
foreach (var a in SupportedAdditions)
sb.Append(" - ").Append(a).Append("\n");
na.Body = sb.ToString();
return na;
}
// 抽出開始
lock (_lock)
{
if (_state == PotState.Brewing)
{
var busy = new HtcpcpResponse(StatusCodes.ServiceUnavailable)
{ Body = "Pot is already brewing. Please wait.\n" };
busy.Headers["Retry-After"] = "10";
return busy;
}
_state = PotState.Brewing;
_milkPouring = requested.Count > 0; // アディション指定があれば注ぎ始める
}
var res = new HtcpcpResponse(StatusCodes.Accepted);
res.Headers["Safe"] = "if-user-awake"; // RFC 2324 §2.2.1.1
var body = new StringBuilder();
body.Append("Brewing has started at ").Append(req.Target).Append(".\n");
if (requested.Count > 0)
body.Append("Additions: ")
.Append(string.Join(", ", requested.ToArray())).Append("\n");
if (!ctOk)
body.Append("(note: unusual Content-Type \"")
.Append(ct ?? "<none>").Append("\" accepted leniently)\n");
res.Body = body.ToString();
return res;
}
// GET : ポットの状態を返す(デモでは呼ぶたびに 1 段進める)
private HtcpcpResponse HandleGet(HtcpcpRequest req)
{
string stateText;
lock (_lock)
{
if (_state == PotState.Brewing)
{
_state = PotState.Finished;
stateText = "brewing (almost ready)";
}
else if (_state == PotState.Finished)
{
stateText = "ready \u2014 coffee is served";
}
else
{
stateText = "idle";
}
}
var res = new HtcpcpResponse(StatusCodes.OK)
{ Body = "Coffee pot status: " + stateText + "\n" };
res.Headers["Safe"] = "yes";
return res;
}
// PROPFIND : WebDAV 風のメタデータ応答(RFC 2324 §2.1.3)
private HtcpcpResponse HandlePropfind(HtcpcpRequest req)
{
string state;
lock (_lock) { state = _state.ToString(); }
var sb = new StringBuilder();
sb.Append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
sb.Append("<D:multistatus xmlns:D=\"DAV:\" xmlns:C=\"coffee:\">\n");
sb.Append(" <D:response>\n");
sb.Append(" <D:href>").Append(req.Target).Append("</D:href>\n");
sb.Append(" <D:propstat>\n");
sb.Append(" <D:prop>\n");
sb.Append(" <C:kind>").Append(_kind).Append("</C:kind>\n");
sb.Append(" <C:state>").Append(state).Append("</C:state>\n");
sb.Append(" <C:brew-strength>strong-dark-rich</C:brew-strength>\n");
sb.Append(" </D:prop>\n");
sb.Append(" <D:status>HTTP/1.1 200 OK</D:status>\n");
sb.Append(" </D:propstat>\n");
sb.Append(" </D:response>\n");
sb.Append("</D:multistatus>\n");
var res = new HtcpcpResponse(StatusCodes.MultiStatus) { Body = sb.ToString() };
res.Headers["Content-Type"] = "text/xml; charset=utf-8";
return res;
}
// WHEN : ミルクを止める(RFC 2324 §2.1.4)
private HtcpcpResponse HandleWhen(HtcpcpRequest req)
{
bool wasPouring;
lock (_lock)
{
wasPouring = _milkPouring;
_milkPouring = false;
}
return new HtcpcpResponse(StatusCodes.OK)
{
Body = wasPouring
? "Enough! Milk pouring stopped.\n"
: "No milk was being poured, but okay \u2014 when.\n"
};
}
}
}
要点:接続ごとにスレッドを起こす素朴な thread-per-connection 型。_state と _milkPouring は複数スレッドが触るので _lock で保護する。_kind == Teapot のインスタンスは BREW に対し必ず 418 を返す ―― これが仕様のクライマックスである。
2.5 HtcpcpClient.cs ―― 送信と応答パース
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Sockets;
using System.Text;
namespace Htcpcp
{
// クライアントが受信した応答
public class ClientResponse
{
public int Status;
public string ReasonPhrase = "";
public Dictionary<string, string> Headers =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public string Body = "";
}
public class HtcpcpClient
{
private readonly string _host;
private readonly int _port;
public HtcpcpClient(string host, int port)
{
_host = host;
_port = port;
}
// 任意メソッドのリクエストを手組みで送信し、応答を読み取る。
public ClientResponse Send(string method, string target,
Dictionary<string, string> headers, string body)
{
using (var client = new TcpClient())
{
client.Connect(_host, _port);
using (var stream = client.GetStream())
{
var bodyBytes = Encoding.UTF8.GetBytes(body ?? "");
var sb = new StringBuilder();
sb.Append(method).Append(' ').Append(target).Append(" HTTP/1.1\r\n");
sb.Append("Host: ").Append(_host).Append(':').Append(_port).Append("\r\n");
if (headers != null)
foreach (var kv in headers)
sb.Append(kv.Key).Append(": ").Append(kv.Value).Append("\r\n");
sb.Append("Content-Length: ").Append(bodyBytes.Length).Append("\r\n");
sb.Append("Connection: close\r\n");
sb.Append("\r\n");
var headBytes = Encoding.UTF8.GetBytes(sb.ToString());
stream.Write(headBytes, 0, headBytes.Length);
if (bodyBytes.Length > 0)
stream.Write(bodyBytes, 0, bodyBytes.Length);
stream.Flush();
return ReadResponse(stream);
}
}
}
// ---- 便利メソッド ------------------------------------------------
public ClientResponse Brew(string target, string additions)
{
var headers = new Dictionary<string, string>();
headers["Content-Type"] = "application/coffee-pot-command";
if (!string.IsNullOrEmpty(additions))
headers["Accept-Additions"] = additions;
return Send(Methods.Brew, target, headers, "start");
}
public ClientResponse Stop(string target)
{
var headers = new Dictionary<string, string>();
headers["Content-Type"] = "application/coffee-pot-command";
return Send(Methods.Brew, target, headers, "stop");
}
public ClientResponse Status(string target)
{
return Send(Methods.Get, target, null, "");
}
public ClientResponse Propfind(string target)
{
return Send(Methods.Propfind, target, null, "");
}
public ClientResponse When(string target)
{
return Send(Methods.When, target, null, "");
}
// ---- 応答パーサ --------------------------------------------------
private ClientResponse ReadResponse(Stream stream)
{
// ヘッダ終端 "\r\n\r\n" まで読む
var buffer = new List<byte>();
int b;
while ((b = stream.ReadByte()) != -1)
{
buffer.Add((byte)b);
int n = buffer.Count;
if (n >= 4 &&
buffer[n - 4] == (byte)'\r' && buffer[n - 3] == (byte)'\n' &&
buffer[n - 2] == (byte)'\r' && buffer[n - 1] == (byte)'\n')
break;
}
var headerText = Encoding.ASCII.GetString(buffer.ToArray()).TrimEnd('\r', '\n');
var res = new ClientResponse();
var lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None);
// ステータスライン: HTTP/1.1 418 I'm a teapot
var statusParts = lines[0].Split(new[] { ' ' }, 3);
if (statusParts.Length >= 2) int.TryParse(statusParts[1], out res.Status);
if (statusParts.Length >= 3) res.ReasonPhrase = statusParts[2];
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i];
int colon = line.IndexOf(':');
if (colon <= 0) continue;
res.Headers[line.Substring(0, colon).Trim()] =
line.Substring(colon + 1).Trim();
}
// ボディ(Content-Length 分)
string cl;
if (res.Headers.TryGetValue("Content-Length", out cl))
{
int len;
if (int.TryParse(cl, out len) && len > 0)
{
var body = new byte[len];
int off = 0;
while (off < len)
{
int r = stream.Read(body, off, len - off);
if (r <= 0) break;
off += r;
}
res.Body = Encoding.UTF8.GetString(body, 0, off);
}
}
return res;
}
}
}
2.6 Program.cs ―― 疎通確認デモ
using System;
using System.Net;
using System.Text;
using System.Threading;
namespace Htcpcp
{
public static class Program
{
private const int CoffeePort = 8080;
private const int TeapotPort = 8081;
public static void Main(string[] args)
{
// Encoding.UTF8 は BOM 付きのため、コンソールに BOM バイトが混入しないよう BOM なしを使う。
try { Console.OutputEncoding = new UTF8Encoding(false); } catch { /* リダイレクト時は無視 */ }
if (args.Length > 0 && string.Equals(args[0], "serve", StringComparison.OrdinalIgnoreCase))
{
RunServe();
return;
}
RunDemo();
}
// サーバのみ起動し、Ctrl+C まで待機する。curl 等の外部クライアントからの手動検証用。
private static void RunServe()
{
var coffee = new HtcpcpServer(IPAddress.Loopback, CoffeePort, ApplianceKind.CoffeePot);
var teapot = new HtcpcpServer(IPAddress.Loopback, TeapotPort, ApplianceKind.Teapot);
coffee.Start();
teapot.Start();
Thread.Sleep(200); // listen 開始を待つ
Console.WriteLine("Coffee Pot : http://127.0.0.1:" + CoffeePort + "/pot-0");
Console.WriteLine("Teapot : http://127.0.0.1:" + TeapotPort + "/pot-0");
Console.WriteLine("Ctrl+C で終了します。");
var stopSignal = new ManualResetEventSlim(false);
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true; // プロセスの即時終了を防ぎ、後続のクリーンアップを行う
stopSignal.Set();
};
stopSignal.Wait();
coffee.Stop();
teapot.Stop();
}
private static void RunDemo()
{
// サーバ起動:コーヒーポット と ティーポット
var coffee = new HtcpcpServer(IPAddress.Loopback, CoffeePort, ApplianceKind.CoffeePot);
var teapot = new HtcpcpServer(IPAddress.Loopback, TeapotPort, ApplianceKind.Teapot);
coffee.Start();
teapot.Start();
Thread.Sleep(200); // listen 開始を待つ
var potClient = new HtcpcpClient("127.0.0.1", CoffeePort);
var teaClient = new HtcpcpClient("127.0.0.1", TeapotPort);
Banner("疎通確認: HTCPCP over TCP (RFC 2324)");
Step("(1) GET coffee://localhost/pot-0 \u2014 初期状態");
Print(potClient.Status("/pot-0"));
Step("(2) BREW coffee://localhost/pot-0 Accept-Additions: Cream");
Print(potClient.Brew("/pot-0", "Cream"));
Step("(3) GET coffee://localhost/pot-0 \u2014 抽出状況");
Print(potClient.Status("/pot-0"));
Step("(4) WHEN coffee://localhost/pot-0 \u2014 ミルクを止める");
Print(potClient.When("/pot-0"));
Step("(5) PROPFIND coffee://localhost/pot-0 \u2014 メタデータ");
Print(potClient.Propfind("/pot-0"));
Step("(6) BREW coffee://localhost/pot-0 Accept-Additions: Ketchup(対応外)");
Print(potClient.Brew("/pot-0", "Ketchup"));
Step("(7) BREW coffee://localhost/pot-0 body=stop(抽出停止)");
Print(potClient.Stop("/pot-0"));
Step("(8) BREW coffee://localhost:8081/pot-0 \u2014 相手はティーポット");
Print(teaClient.Brew("/pot-0", null));
coffee.Stop();
teapot.Stop();
Banner("疎通確認 完了");
}
private static void Banner(string title)
{
Console.WriteLine();
Console.WriteLine("======================================================");
Console.WriteLine(" " + title);
Console.WriteLine("======================================================");
}
private static void Step(string label)
{
Console.WriteLine();
Console.WriteLine(">>> " + label);
}
private static void Print(ClientResponse res)
{
Console.WriteLine("<<< " + res.Status + " " + res.ReasonPhrase);
foreach (var kv in res.Headers)
Console.WriteLine(" " + kv.Key + ": " + kv.Value);
if (!string.IsNullOrEmpty(res.Body))
{
Console.WriteLine(" ----");
foreach (var line in res.Body.Replace("\r", "").Split('\n'))
Console.WriteLine(" " + line);
}
}
}
}
2.7 Htcpcp.csproj ―― C# プロジェクトファイル
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<RootNamespace>Htcpcp</RootNamespace>
<AssemblyName>Htcpcp</AssemblyName>
</PropertyGroup>
</Project>
第 3 部:ビルドと疎通確認
3.1 ビルド方法
モダンな .NET(推奨) ―― src/ に置いた Htcpcp.csproj を使う。
cd src
dotnet run
Mono(本稿の検証環境) ―― mcs で 4 ファイルをまとめてコンパイルする。
Mono が未インストールの場合は、必要に応じて Mono 6.12.0.206 (64bit版) 等を事前にインストールする。
cd src
mcs -out:htcpcp.exe Htcpcp.cs HtcpcpServer.cs HtcpcpClient.cs Program.cs
# 日本語版Windowsのコマンドプロンプトは既定で 932(Shift-JIS)になっているため、これを 65001 (UTF-8) に変更
chcp 65001
mono htcpcp.exe
本稿のコードは Mono 6.8.0(C# コンパイラ mcs) でビルド・実行して検証済み。C# 7 の範囲で書いてあり、
System.Net.Socketsしか使わないため、.NET 6/7/8 でもそのまま動く。
3.2 疎通確認の結果(実行ログ)
Program.Main は、コーヒーポット(:8080)とティーポット(:8081)を起動し、自前クライアントで 8 通りのやり取りを行う。実際の出力は次の通り。
======================================================
疎通確認: HTCPCP over TCP (RFC 2324)
======================================================
>>> (1) GET coffee://localhost/pot-0 — 初期状態
<<< 200 OK
Safe: yes
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Connection: close
----
Coffee pot status: idle
>>> (2) BREW coffee://localhost/pot-0 Accept-Additions: Cream
<<< 202 Accepted
Safe: if-user-awake
Content-Type: text/plain; charset=utf-8
Content-Length: 48
Connection: close
----
Brewing has started at /pot-0.
Additions: Cream
>>> (3) GET coffee://localhost/pot-0 — 抽出状況
<<< 200 OK
Safe: yes
Content-Type: text/plain; charset=utf-8
Content-Length: 42
Connection: close
----
Coffee pot status: brewing (almost ready)
>>> (4) WHEN coffee://localhost/pot-0 — ミルクを止める
<<< 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 30
Connection: close
----
Enough! Milk pouring stopped.
>>> (5) PROPFIND coffee://localhost/pot-0 — メタデータ
<<< 207 Multi-Status
Content-Type: text/xml; charset=utf-8
Content-Length: 404
Connection: close
----
<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="coffee:">
<D:response>
<D:href>/pot-0</D:href>
<D:propstat>
<D:prop>
<C:kind>CoffeePot</C:kind>
<C:state>Finished</C:state>
<C:brew-strength>strong-dark-rich</C:brew-strength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>
>>> (6) BREW coffee://localhost/pot-0 Accept-Additions: Ketchup(対応外)
<<< 406 Not Acceptable
Content-Type: text/plain; charset=utf-8
Content-Length: 240
Connection: close
----
Cannot comply with Accept-Additions: Ketchup
Available additions:
- Cream
- Half-and-half
- Whole-milk
- Part-Skim
- Skim
- Non-Dairy
- Vanilla
- Almond
- Raspberry
- Chocolate
- Whisky
- Rum
- Kahlua
- Aquavit
>>> (7) BREW coffee://localhost/pot-0 body=stop(抽出停止)
<<< 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 34
Connection: close
----
Brewing stopped. Pot is now idle.
>>> (8) BREW coffee://localhost:8081/pot-0 — 相手はティーポット
<<< 418 I'm a teapot
Safe: no
Content-Type: text/plain; charset=utf-8
Content-Length: 98
Connection: close
----
I'm a teapot.
_______
| |\
| | ) short
|_______|/ and stout
======================================================
疎通確認 完了
======================================================
200 → 202 → 200 → 200 → 207 → 406 → 200 → 418 と、狙い通りのステータス遷移が観測できた。
3.3 curl による相互運用確認
HTCPCP は HTTP に載っているので、既存の HTTP クライアントからも同じワイヤ表現が通る。
まずは、コーヒーポットとティーポットのサーバを事前に起動しておく。
dotnet run -- serve
curl でカスタムメソッド BREW を送ると、次の通り。
# コーヒーポットに Cream 指定で BREW
$ curl -s -i -X BREW \
-H "Content-Type: application/coffee-pot-command" \
-H "Accept-Additions: Cream" \
--data "start" http://127.0.0.1:8080/pot-0
HTTP/1.1 202 Accepted
Safe: if-user-awake
Content-Type: text/plain; charset=utf-8
Content-Length: 48
Connection: close
Brewing has started at /pot-0.
Additions: Cream
# ティーポットに BREW → 418
$ curl -s -i -X BREW \
-H "Content-Type: application/coffee-pot-command" \
--data "start" http://127.0.0.1:8081/pot-0
HTTP/1.1 418 I'm a teapot
Safe: no
Content-Type: text/plain; charset=utf-8
Content-Length: 98
Connection: close
I'm a teapot.
_______
| |\
| | ) short
|_______|/ and stout
自前クライアントと curl で完全に同一の応答が得られ、フルスクラッチ実装が実 HTTP と相互運用可能であることが確認できた。
RFC 2324 の欠点・課題
RFC 2324 の欠点・課題はいくつもあります。RFC 2324 はジョークなので「わざと空けてある穴」と「本気の実装者がぶつかる本物の課題」が混ざっているのですが、後者はそのまま現実のプロトコル設計の教訓になっています。先ほど実装したときに実際に判断を迫られた箇所とあわせて整理します。
仕様そのものの不備
いちばん実害があるのが自己矛盾です。Content-Type について §2.1.1 は application/coffee-pot-command、§4 は message/coffeepot と、同じRFC内で別のことを言っています。仕様書が矛盾していると実装ごとに解釈が割れ、相互運用が壊れます。今回は両方許容して吸収しましたが、これは「仕様のバグを実装が肩代わりしている」状態で、本来あってはいけません。
次に未定義の文法要素。Accept-Additions の文法には sweetener-type(甘味料)と spice-type(スパイス)が列挙されているのに、本文がその具体名を一切定義していません。結果、どの値を受理すべきかが完全に実装依存になり、あるサーバでは通る注文が別のサーバでは 406 になる、という非互換が生じます。
そして状態機械と並行性がまったく規定されていない。「抽出中に別のクライアントがもう一度 BREW したらどうなるか」「空のポットに GET したら何が返るか」が仕様に無いため、今回は自前で Ready→Brewing→Finished を作り、競合時は 503 を返す設計にしましたが、これも各自の判断次第です。物理デバイスを扱う以上、状態と排他制御こそ規定すべき中核なのに、そこが空いています。
セキュリティの欠如も挙げられます。§7 は「私と朝のコーヒーの間に割り込む奴は不安定であるべきだ」といった冗談で、認証は「別memoで議論する」と先送りされたまま。実際「denial of coffee service(コーヒー不能攻撃)」に無防備で、誰でも他人のポットを操作できてしまいます。
より本質的な設計上のミスマッチ
これは HTCPCP に限らず「HTTP で物理世界を制御すること」の根本問題です。
WHEN メソッドが象徴的です。「ミルクがもう十分」と伝える操作ですが、止めたい瞬間とサーバに届く瞬間がネットワーク遅延ぶんずれます。連続的・リアルタイムな物理動作を、離散的なリクエスト/レスポンス型のHTTPで制御しようとする無理がここに凝縮されています。
同じ理由で状態のプッシュ通知がないのも弱点です。抽出完了を知るにはクライアントが GET を繰り返すポーリングしかありません。§5.1 はキャッシュの話に触れますが、コーヒーの状態は刻々変わる動的リソースで、HTTP の pull 型モデルとは相性が悪い。今回のデモで「GET を呼ぶたびに状態を1段進める」という苦しい実装になったのは、まさにこの制約の表れです。
冪等性・安全性の意味論も曖昧です。Safe: if-user-awake(利用者が起きていれば安全)は洒落ていますが、機械的に検証不能な人間側の条件を持ち込んでいます。BREW は明らかに非冪等(呼ぶたびに淹れる)なのに、副作用のある物理操作に HTTP の安全性概念をどう当てはめるかが整理されていません。
加えて、そもそもコーヒーポット制御に TCP/80 の HTTP は重いという指摘もできます。制約の多いIoTデバイスには MQTT や CoAP のような軽量プロトコルが向きます。HTCPCP は「HTTP がどこにでもあるから相乗りする」という設計判断ですが、それは非力な組込み機器には必ずしも最適ではありません。
実装・運用上に漏れ出した混乱
418 I'm a teapot は冗談として生まれたのに、ステータスコードとして各種フレームワークに実装が残り、「実際どう使うのが正しいのか」で長年議論になりました。2017年には一部が削除を検討し "Save 418" 運動で存続した経緯があります。ジョークが本番コードに漏れ出し、IANAレジストリに「(Unused)」として予約が残る半端な状態になっている——標準に遊びを混ぜたことの副作用と言えます。
拡張性の薄さも実際に露呈しました。最初の版に紅茶を淹れる機能がなく、2014年の RFC 7168(HTCPCP-TEA)で後追い拡張する必要がありました。「将来 espresso 対応を入れるかも」と述べつつ具体的な拡張機構を用意していないため、機能追加のたびに別RFCを起こすことになっています。
まとめると、致命的なのは「仕様の自己矛盾」「未定義文法」「状態・並行性・認証の欠落」で、これらは実装者が肩代わりせざるを得ません。より深いのは「リクエスト/レスポンス型HTTPで連続的な物理制御をやる」ことの構造的な無理で、WHEN のタイミング問題やポーリング依存はその現れです。ジョークとはいえ、**「普及プロトコルへの相乗りは楽だが、対象ドメインに合わないと歪みが出る」**という真っ当な教訓が全部詰まっているのが、この RFC を実装してみる価値でもあります。
まとめ
- RFC 2324 は冗談 RFC だが、HTTP の薄い拡張として設計されているため、フルスクラッチ実装の題材として優秀。ワイヤのフレーミング(
\r\n\r\n終端、Content-Lengthによるボディ長)を自分で解くと、フレームワークが隠している層が見える。 - 実装のポイントは、(1) 生ソケットからの HTTP 風メッセージのパース、(2)
BREW/POST/GET/PROPFIND/WHENのディスパッチ、(3)Accept-Additions不充足時の406と一覧返却、(4) ティーポットの418、(5) RFC 内の Content-Type 表記揺れを寛容に吸収すること。 - 応答を
HTTP/1.1で返すことで、自前クライアントだけでなく curl などとも相互運用できる。
拡張の練習としては、coffee: URI の日本語スキーム(%E3%82%B3%E3%83%BC%E3%83%92%E3%83%BC)の解釈、pot-designator による複数ポット管理、RFC 7168(HTCPCP-TEA)の紅茶対応、Safe: if-user-awake を使ったリトライ制御などが面白い。
参考文献
- RFC 2324, Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0), L. Masinter, 1998年4月1日
- RFC 7168, The Hyper Text Coffee Pot Control Protocol for Tea Efflux Appliances (HTCPCP-TEA), 2014年(RFC 2324 を更新)
- RFC 2616 / RFC 7230-7235(HTTP/1.1)
- "The Trojan Room Coffee Pot", University of Cambridge Computer Laboratory