プログラミングを頑張る日記

プログラミングを勉強して、ハッカーになろう

Common Lispを頑張る(37)

本日もCommon Lispです。少なくともサーバを書くまでは他の言語に移れません。

リクエストパラメータのリストをデコードする

リクエストパラメータには"name=bob&age=25&gender=male"というように名前と値の組が含まれるそうです。
それらの組をリストとして取り出せるようにします。名前と値なのでalitにしましょう。

CL-USER> (defun parse-params (s)
           (let ((i1 (position #\= s))  ;引数から=を探して先頭から何文字目にあるかがi1(0始まり)
                 (i2 (position #\& s))) ;&が現れる文字数
             (cond (i1 (cons (cons (intern (string-upcase (subseq s 0 i1))) ;文字列から=の前までを抜き出して
                                   (decode-param (subseq s (1+ i1) i2))) ;=の後から&の前までとconsする
                             (and i2 (parse-params (subseq s (1+ i2)))))) ;i2があればi2以降の文字列を引数に再帰
                   ((equal s "") nil)   ;文字列が終わったらnil
                   (t s))))              ;=も&もなければ引数をそのまま返す
PARSE-PARAMS
CL-USER>

internは引数をシンボルに変換するらしいです。subseqは3番目の引数をあげないと開始から最後まで抜いてくれそうです。
parse-paramsを試してみます。

CL-USER> (parse-params "name=ねこ&age=3&gender=male")
((NAME . "ねこ") (AGE . "3") (GENDER . "male"))
CL-USER> 

昨日の成果として、日本語だってデコードできます。
リクエストパラメータをalistにしておけば、後から特定の値を取り出すときに楽ですね。

リクエストヘッダ解析

お次はまず、リクエストヘッダの最初の行の文字列を解析します。

CL-USER> (defun parse-url (s)
           (let* ((url (subseq s        ;スペースのある箇所の2文字後~後ろから最初のスペースまで抜いてurlとする
                               (+ 2 (position #\Space s))
                               (position #\Space s :from-end t)))
                  (x (position #\? url))) ;上で定義したurlで?がある場所
             (if x                                                          ;?が引数に含まれていたらurlから?までと
                 (cons (subseq url 0 x) (parse-params (subseq url (1+ x)))) ;?からあとの文字列をデコードしてコンス
                 (cons url '()))))      ;?が含まれないのならリクエストパラメータは空なのでurlと空リストをcons
PARSE-URL
CL-USER> (parse-url "http://example.com/foo/var.php?name1=value1&name2=value2")
; Evaluation aborted on #<TYPE-ERROR expected-type: NUMBER datum: NIL>.
CL-USER> 

む、なにか間違えたかな。どこかでnilを渡している、まずsubseqでしょうな。数値を欲しがるのは。
おっと引数が悪いのか。スペースがない。「リクエストパラメータ 例」でググって出てきたものを渡したのですが。
とりあえず参考書の例で試します。

CL-USER> (parse-url "GET /lolcats.html HTTP/1.1")
("lolcats.html")
CL-USER> (parse-url "GET /lolcats.html?extra-funny=yes HTTP/1.1")
("lolcats.html" (EXTRA-FUNNY . "yes"))
CL-USER>

参考書通りには書けているようです。リクエストヘッダはどこでもこの形式なのかな、という心配があります。
プロトコルとして規定されているはずなので信じます。
...あ、なんでリクエストパラメータでググったんや。はい、自分がアホでした。疑ってごめんなさい。
上の関数はGETやPOSTの部分を無視していますが、ちゃんとしたWebサーバならここでリクエストメソッドも切り出すとのこと。

リクエストヘッダの残りを読みこみます。

CL-USER> (defun get-header (stream)
           (let* ((s (read-line stream)) ;渡されたストリームから1行読みこんでsに格納
                  (h (let ((i (position #\: s))) ;まずコロンの現れる場所を探す
                       (when i
                         (cons (intern (string-upcase (subseq s 0 i))) ;コロンがあったらコロンまでの文字列と
                               (subseq s (+ i 2)))))))                 ;コロンの次の次から終りまでの文字列をcons。「ホニャララ: なんたらかんたら」という感じの文字列が引数になるのでしょう
             (when h
               (cons h (get-header stream))))) ;再帰してリクエストヘッダの名前と値の組のリストができそう
GET-HEADER
CL-USER>

入力ストリームから1行ずつ読んで、コロンで区切られた名前と値の組へと変換し再帰しています。
入力行にコロンが見つからない=>リクエストヘッダの終わりを示す空行に到着ということなので処理が終わります。

internを使って文字列をシンボルにしていますが、ここでreadを使わないのはreadは多機能すぎた危険だからだそう。
悪意ある細工が仕込まれたリクエストヘッダがきたときに、ハックされてしまうかもしれません。
インターネット越しのデータのように、どういう入力がきてもおかしくないときは
internのような単一の機能に限られた関数を使うのが安全ですね。

get-headerをテストする

get-headerはソケットストリームからデータを読むことを意図している関数で、テストが大変そう。
じゃないのです。先日文字列ストリームはデバッグに有用という話がありました。
ソケットストリームの代わりに文字列ストリームを渡してちゃんと動くか確認します!

CL-USER> (get-header (make-string-input-stream "Host: localhost:8080
Connection: keep-alive
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36
Accept: */*
Referer: http://localhost:8080/
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: ja,en-US;q=0.8,en;q=0.6

"))
((HOST . "localhost:8080") (CONNECTION . "keep-alive")
 (USER-AGENT
  . "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36")
 (ACCEPT . "*/*") (REFERER . "http://localhost:8080/")
 (ACCEPT-ENCODING . "gzip, deflate, sdch, br")
 (ACCEPT-LANGUAGE . "ja,en-US;q=0.8,en;q=0.6"))
CL-USER>

例はこちらからひっぱってきました。良記事です。
HTTPとPOSTとGET
上手く動いてくれていますね。これならソケットストリームが相手でも大丈夫でしょう。

それではリクエストボディからパラメータを取り出します。

CL-USER> (defun get-content-params (stream header)
           (let ((length (cdr (assoc 'content-length header)))) ;リクエストボディに含まれるパラメータの長さを示すcontent-lenghtを探して値をlengthに格納する
             (when length                                       ;lengthがnil、つまりcontent-lengthがなかったということはパラメータがないので以降の処理は不要
               (let ((content (make-string (parse-integer length)))) ;lengthを数値にしてその長さだけの文字列を作ってcontentに格納
                 (read-sequence content stream)                      ;contentをstreamから読みこんだ文字列で書き換える
                 (parse-params content)))))                          ;contentをデコードする(これが戻り値)
GET-CONTENT-PARAMS
CL-USER>

サーバ関数

ここまでで部品はそろったようです。Webサーバの核心となる関数を書きます。
...と意気込んだのはいいものの、多分CLispでしか使えないようなsocket-serverという関数があるのを発見。
SBCLでソケットを作る方法を調べるときがやってきたようです。

まずは参考書のものを見て、何をしなければいけないのか理解します。

(defun serve (request-handler)
  (let ((socket (socket-server 8080)))
    (unwind-protect
        (loop (with-open-stream (stream (socket-accept socket))
                                (let* ((url (parse-url (read-line stream)))
                                       (path (car url))
                                       (header (get-header stream))
                                       (params (append (cdr url)
                                                       (get-content-params stream header)))
                                       (*standard-output* stream))
                                  (funcall request-handler path header params))))
      (socket-server-close socket))))

ふむむ...。引数はWebサイトの設計者自身がサイトの内容を作りだすための関数を書いて渡すそう。
funcallされていますもんね。
Webサーバはネットワークからリクエストを受けとると、これまで作った関数を使って解析し、引数の関数に解析結果を渡します。
引数の関数 request-handlerはhtmlを出力する関数だそう。

使われている関数を見ていきます。
socket-serverは、ポート8080にソケットを開きます。
unwind-protectで、何が起こってもsocket-serber-closeを呼ぶようにします。これはソケットを閉じるためのもの。
loopの中で、streamを開きます。開かれるstreamは、socketに接続した相手との固有のストリーム。
socket-acceptの力ですね。引数のソケットに繋がった相手との固有のオブジェクトを返すそうです。
あとは今まで作ってきた関数たちですね。

SBCLで探さなきゃいけないのは下記の方法ですね。
■指定したポート番号にソケットを開く方法。
■ソケットを閉じる方法。
■ソケットとの接続を確立した相手とのストリームを作成する方法。

...よし!なんとなく道が見えてきました。
明日はここからやっていきます。それでは、おやすみなさい。