UnityのWWW、WWWFormのハマりどころ

UnityにバンドルされているWWWクラスを使うと簡単にHTTP通信を行うことができて便利なのだけど、色々やろうとするといくつか判り難い挙動があったので、その辺りをメモ。

Unityのバージョンは4.5。

WWWのコンストラクタには下記の4つ(最後のものは非推奨)がある。ドキュメントはUnity - Scripting API: WWW.WWW

  • WWW(string url);
  • WWW(string url, WWWForm form);
  • WWW(string url, byte[] postData);
  • WWW(string url, byte[] postData, Dictionary headers);
  • WWW(string url, byte[] postData, Hashtable headers); // deprecated

ここでまず気をつける必要があるのが、WWWFormクラスをそのまま渡す場合は、第三引数にHTTPヘッダを渡すことができない点。下記、ここから引き起こされるハマりどころ。

ファイル添付したらヘッダを追加できない?

WWWForm.AddBinaryData() を使うとファイルアップロードが可能だけど、HTTPヘッダも同時に変更したい場合に困ったことになる。

Dictionary<string, string> headers = new Dictionary<string, string>();
headers.Add("Cookie", "a=b");

WWForm form = new WWWForm();
form.AddBinaryData("foo", binary, "foo.png", "image/png");

WWW www = new WWW("http://example.com/", form.data, headers);

このようにすると正しくファイルアプロードができない。ここでHTTPヘッダを変更せずに WWW(string url, WWWForm form); コンストラクタを利用すると正しく動作するようになる。

WWForm form = new WWWForm();
form.AddBinaryData("foo", binary, "foo.png", "image/png");

WWW www = new WWW("http://example.com/", form);

ということは、コンストラクタの第二引数がformそのものではなくform.dataなのが駄目で、WWWFormインスタンスだから動作するのか、と結論付けてしまうとヘッダの変更ができなくなってしまう。

実際にはそうではなくて下記の挙動が考慮できていないことが原因。

  • WWW(string url, WWWForm form); コンストラクタを利用するとヘッダは内部でWWWForm.headersの値が利用される。
  • WWWForm.AddBinaryData() を呼び出すとWWWForm.headersにContent-Typeが自動的に追加される。
    • 実際の値は multipart/form-data; boundary="xxxx"' という感じ

つまり、WWWForm.AddBinaryData() 後に追加されているContent-Typeヘッダの存在を無視して自前のヘッダのみを追加したことが原因なので、下記のようにWWWForm.headersを値を最後に設定するようにすれば動作するようになる。

Dictionary<string, string> headers = new Dictionary<string, string>();
headers.Add("Cookie", "a=b");

WWForm form = new WWWForm();
form.AddBinaryData("foo", binary, "foo.png", "image/png");

foreach (DictionaryEntry entry in form.headers)
{
    headers[System.Convert.ToString(entry.Key)] = System.Convert.ToString(entry.Value);
}

WWW www = new WWW("http://example.com/", form.data, headers);

GETだとHTTPヘッダを追加できない?

WWWクラスはコンストラクタ呼び出し時にWWWForm、もしくはpostDataが存在するとPOSTと解釈するようになっている。POSTの場合は単に第三引数にヘッダのDictionaryを渡せば良いが、GETの場合は適切なコンストラクタがない、ように見えるけれど、nullを渡せばGETでもヘッダを追加できる。

WWW www = new WWW("http://example.com/", null, headers);

POSTでpostDataがない場合はHTTPヘッダを追加できない?

応用編。POSTにも関わらず、POSTするデータはない。けれどもHTTPヘッダを追加したい。みたいな場合はどうしようもないので

form.AddField("dummy", "dummy");

とでもするしかないと思われる。postDataが空の場合は下記エラーになる。

Error when creating request. POST request with a zero-sized post buffer is not supported.

GET、POSTだけではなくPUT、DELETEも使いたい

使えない。上述のように、GETとPOSTが自動判定されるような機構で、PUT、DELETEはサポートされていない。

そういうわけでWWW用の便利なクラスを書いた

こういったハマりポイントを解決しつつ、もう少し楽にWWWとWWWFormを使いたいと思って、一つクラスを書いたのでGistに貼っておいた。

特徴としては

  • コルーチンを使わないでコールバック(Delegate)で終了後の処理を書ける(内部ではコルーチンを使っている)
  • タイムアウトをサポート(地味に必要ですよね)
  • 上述の複雑なコンストラクタのハマりどころをできるだけ解決

タイムアウトに関してはDEBUG.LOG (WWWクラスのタイムアウト処理)を参考にさせてもらいました。

使い方は下記な感じ。

using WWWKit;

public class WWWClientExample : MonoBehaviour
{
    void Start()
    {
        // 単純なGETリクエスト。終了処理はOnDoneプロパティにDelegateを代入。
        // DelegateにはおなじみのWWWクラスが渡されるのでレスポンスに関する
        // 処理は今まで通りWWWクラスのノウハウが利用可能。
        WWWClient client = new WWWClient(this, "http://example.com/");
        client.OnDone = (WWW www) => {
            Debug.Log(www.text);
        };
        client.Request();

        // POSTリクエストの場合は、WWWクラスの思想を継承して、下記のように
        // postDataを設定したらPOSTになる、という仕様。
        WWWClient http = new WWWClient(this, "http://example.com/");
        client.AddData("foo", "bar");
        client.OnDone = (WWW www) => {
            Debug.Log(www.text);
        };
        client.Request();

        // ファイル添付したい場合。下記はstringをbyte[]にしてるけど画像など
        // 何でも可能。
        byte[] binary = System.Text.Encoding.Unicode.GetBytes("bar");
        WWWClient http = new WWWClient(this, "http://example.com/");
        client.AddBinaryData("foo", binary, "test.txt", "application/octet-stream");
        client.OnDone = (WWW www) => {
            Debug.Log(www.text);
        };
        client.Request();

        // エラーハンドリングをする場合は、OnFailプロパティにDelegateを代入。
        // 正常終了のハンドリングと同じくWWWクラスが渡されるのでごにょごにょする。
        client.OnFail = (WWW www) => {
            Debug.Log(www.error);
        };

        // タイムアウトの場合はWWWクラスがDispose()された後で利用できなくなって
        // いるので、引数なしのDelegateをOnDisposedプロパティに代入する。
        client.OnDisposed = () => {
            Debug.Log("Timed out");
        };

        // タイムアウトを設定する場合はTimeoutプロパティに値を代入(float)。
        // デフォルトはタイムアウト設定なし(無制限)。
        client.Timeout = 10f;

        // ヘッダを追加する。
        client.AddHeader("Cookie", "cookiename=cookievalue");   
    }
}