3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

N/S高等学校Advent Calendar 2023

Day 25

nimでTCPからHTTPサーバー建ててみた!

Last updated at Posted at 2023-12-24

Hello!

こんにちは!
あるときnimは速くてメモリ効率いいと聞いて使い始めた素人です

nimってなんやねん!

  • コンパイラ言語
  • Python、Ada、Modulaみたいなコンセプト
  • 速い!
  • メモリ管理がすごい!
  • C、C++、JavaScriptにコンパイルできる
  • proc便利!
  • 効率的で表現豊かで優雅 (by wikipedia)

って感じらしい

きっかけ

いつも通りYoutubeを見ていた。センチメンタルな気持ちで見ていたわけでは無い。

How I Made HTTP From Scratch / ゼロからHTTPを作った方法
https://www.youtube.com/watch?v=4IXJE8sRM-A

という動画が流れてきた。
HTTPはTCPで構文に従ってテキスト転送してるだけって知って(RFCに厳密に従って作ったりしない限り)意外と簡単そうだ!ということで作ることにした

とりあえず作る

全体像

最終的な全体像としては

import HTTPserver
import std/[
    tables,
    net
]

proc index(req: Request): Response =
    return Response(status: 200, body: "<h1>Hello World!</h1>", headers: {"Content-Type": "text/html"}.toTable)

var server = HTTPserver.create(Port(12345))
server.register("GET", "/index.html", index)
# server.register("GET", "/*", notfound)
server.run()

的な感じにしたいね(URLの正規表現対応もしたいけどめんどくさいからやってない)

とりあえずぱぱっとdirtyなcodeを書いてイメージをつかむ

まずはTCPサーバーを建てる。

std/netでできるらしい

let socket = newSocket()

socket.bindAddr(Port(7781))
socket.listen()

echo &"listening at 127.0.0.1:7781"

var client: Socket
var address = ""
while true:
    socket.acceptAddr(client, address)
    echo &"Client connected from: {address}"

ということで次、データを受信する

HTTPリクエストのセマンティクス

HTTPリクエストのセマンティクスは

METHOD URI VERSION
HEADER

BODY

って感じで送られるらしくHEADERの末尾には\r\n\r\nがついてくるらしい

recv系との格闘

var data: string
let connState = client.recv(data, 1024)

こんなふうにすればできるかなって実行してみたが、第二引数のsize: intに届くまで値が返されないらしい

ということで次に見つけたのがrecvLine行ごとに取得できるみたい
しかしこれを使うとリクエストのbodyの最後の行が取得できなかった
困り果てた挙句asynchttpserverというライブラリが標準のライブラリのソースを読んで作ってみることにする
https://github.com/nim-lang/Nim/blob/db9d8003b074cb6d150f7ece467f29006ccefde7/lib/pure/asynchttpserver.nim#L197-L202

asynchttpserver.nim
197  # We should skip at least one empty line before the request
198  # https://tools.ietf.org/html/rfc7230#section-3.5
199  for i in 0..1:
200    lineFut.mget().setLen(0)
201    lineFut.clean()
202    await client.recvLineInto(lineFut, maxLength = maxLine) # TODO: Timeouts.

asyncnetのrecvLineIntoを使ってるみたい
でもrecvLine使えなかったぞ?ということでrecvLineIntoも見てみる
https://github.com/nim-lang/Nim/blob/db9d8003b074cb6d150f7ece467f29006ccefde7/lib/pure/asyncnet.nim#L582-L601

asyncnet.nim
582    while true:
583      c = await recv(socket, 1, flags)
584      if c.len == 0:
585        resString.mget.setLen(0)
586        resString.complete()
587        return
588      if c == "\r":
589        c = await recv(socket, 1, flags) # Skip \L
590        assert c == "\L"
591        addNLIfEmpty()
592        resString.complete()
593        return
594      elif c == "\L":
595        addNLIfEmpty()
596        resString.complete()
597        return
598      resString.mget.add c
599
600
601      # Verify that this isn't a DOS attack: #3847.
602      if resString.mget.len > maxLength: break

なるほど1文字ずつ読んでるのか
ということで実装する

やっとできた

import asyncdispatch
import std/[
    net,
    strformat,
    strutils,
    tables,
    options
]

proc ClientConnection(client:Socket) {.async.}=
    var stack: string
    while true:
        var data: string
        let connState = client.recv(data, 1)
        if connState == 0:
            client.close()
            return
        stack &= data

        if data == "":
            client.close()
            echo "Connection closed"
            return

        if "\r\n\r\n" in stack:
            let headerContent = stack.split("\r\n", 1)[1].split("\r\n\r\n", 1)[0].toLower
            if ("content-length: " in headerContent and headerContent.split("content-length: ")[1].split("\r\n")[0].parseInt == stack.split("\r\n\r\n", 1)[1].len) or "content-length: " notin headerContent:
                echo [stack]

var client: Socket
var address = ""
while true:
    socket.acceptAddr(client, address)
    echo &"Client connected from: {address}"
    discard ClientConnection(client)

こんな感じでデータの受け取り部分が完成
(
書いてる途中に気づいたけど
recv(body, headerContent.split("content-length: ")[1].split("\r\n")[0].parseInt, 10)
とかにしてもよかったな timeoutも指定できるし
)

次に受け取ったデータをパースする部分をつくる
とりあえずいっぱいsplit

type
    Request = object
        reqMethod: string
        path: string
        headers: Table[string, string]
        body: string

proc `?`(a:auto, b:tuple): auto =
    if bool(a):
        return b[0]
    else:
        return b[1]

proc readRequest(data: string): Request =
    let d = data.split("\r\n\r\n", 1)

    let control = d[0].split("\r\n", 1)[0]
    let control_data = control.split(" ")
    if control_data.len == 3:
        result.reqMethod = control_data[0]
        result.path = control_data[1]
    else:
        result.reqMethod = ""
        result.path = ""

    let headers = d[0].split("\r\n", 1)[1]
    for line in headers.splitlines():
        result.headers[line.split(": ", 1)[0]] = line.split(": ", 1)[1]

    result.body = d.len == 2 ? (d[1], "")

した

レスポンスの送信

レスポンスの送信は

client.send("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 20\r\n\r\n<h1>Hello World</h1>")

でできた
レスポンスのセマンティクスもリクエストのと同じような感じで

VERSION STATUS STATUS_MSG
HEADER

BODY

の形式で送ればいい

ライブラリっぽくする(完成)

あとはいい感じにライブラリっぽくして使えるようにする

HTTPserver.nim
import std/[
    net,
    strformat,
    strutils,
    tables
]
import asyncdispatch


proc `?`(a:auto, b:tuple): auto =
    if bool(a):
        return b[0]
    else:
        return b[1]


type
    Request* = object
        reqMethod: string
        path: string
        headers: Table[string, string]
        body: string
    Response* = object
        status*: int16
        statusText*: string
        headers*: Table[string, string]
        body*: string
    Handler = proc(req: Request): Response
    HTTPServer = object
        socket: Socket
        endpoints: Table[string, Table[string, Handler]]

proc readRequest(data: string): Request =
    let d = data.split("\r\n\r\n", 1)

    let control = d[0].split("\r\n", 1)[0]
    let control_data = control.split(" ")
    if control_data.len == 3:
        result.reqMethod = control_data[0]
        result.path = control_data[1]
    else:
        result.reqMethod = ""
        result.path = ""

    let headers = d[0].split("\r\n", 1)[1]
    for line in headers.splitlines():
        result.headers[line.split(": ", 1)[0]] = line.split(": ", 1)[1]

    result.body = d.len == 2 ? (d[1], "")

proc parseResponse(data: Response): string =
    result = &"HTTP/1.1 {data.status}" & (data.statusText=="" ? ("", " ")) & data.statusText & "\r\n"
    for k, v in data.headers.pairs:
        result &= &"{k}: {v}\r\n"
    if data.body != "":
        result &= &"Content-Length: {data.body.len}\r\n\r\n"
        result &= data.body
    else:
        result &= "\r\n"

proc ClientConnection(server: HTTPServer, client: Socket) {.async.}=
    var stack: string
    while true:
        var data: string
        discard client.recv(data, 1)
        stack &= data

        if data == "":
            client.close()
            echo "Connection aborted"
            break

        if "\r\n\r\n" in stack:
            let headerContent = stack.split("\r\n", 1)[1].split("\r\n\r\n", 1)[0].toLower
            if ("content-length: " in headerContent and headerContent.split("content-length: ")[1].split("\r\n")[0].parseInt == stack.split("\r\n\r\n", 1)[1].len) or "content-length: " notin headerContent:
                let request = readRequest(stack)
                if request.path notin server.endpoints:
                    echo &"No endpoint found ({request.path})"
                    client.send(parseResponse(Response(status:404)))
                else:
                    if request.reqMethod notin server.endpoints[request.path]:
                        var allowedMethods = ""
                        for k in server.endpoints[request.path].keys:
                            allowedMethods &= k & ", "
                        allowedMethods.removeSuffix(", ")
                        client.send(parseResponse(Response(status:405, statusText: "Method Not Allowed", headers: {"Allow": allowedMethods}.toTable)))
                    else:
                        let response = server.endpoints[request.path][request.reqMethod](request)
                        client.send(parseResponse(response))
                stack = ""
                client.close()
                return

proc create*(port: Port, address: string=""): HTTPServer =
    result.socket = newSocket()
    result.socket.bindAddr(port, address)

proc run*(server: var HTTPServer) =
    server.socket.listen()
    echo "Start server"
    var client: Socket
    var address = ""
    while true:
        server.socket.acceptAddr(client, address)
        echo &"Client connected: {address}"
        discard ClientConnection(server, client)

proc register*(server: var HTTPServer, httpMethod:string, path: string, callback: Handler) =
    if path notin server.endpoints:
        server.endpoints[path] = initTable[string, Handler]()
    server.endpoints[path][httpMethod] = callback
server.nim
import HTTPserver
import std/[
    tables,
    net
]

proc CreateHTMLResponse(html:string): Response =
    return Response(status: 200, body: html, headers: {"Content-Type": "text/html"}.toTable)


proc index(req: Request): Response =
    return Response(status: 200, body: "<h1>Hello World!</h1>", headers: {"Content-Type": "text/html"}.toTable)

var server = HTTPserver.create(Port(8848))
server.register("GET", "/index.html", index)
server.register("GET", "/hello", proc (req:Request): Response = Response(status: 200, body: "Hello!")))
server.register("POST", "/api/v1/users", proc (req:Request): Response = Response(status: 200, body: """["kaggle", "dii", "norm", "coline"]""", headers: {"Content-Type": "application/json"}.toTable))
server.run()

お し ま い

ということでnimでHTTPサーバーを建ててみました!
nim始めたばっかなのでもっとこうしたら効率的だったりパフォーマンスが上がったりとかまだまだですね
初めて記事書いたので結構読みにくかったりいろいろ違和感感じるとこ多いと思いますがどうしようか?
個人的には自分の知識が浅く詳しく書けなかったのが悔しいですね

あとこれ非同期呼び出ししてやってるけどスレッド毎回立ててもいいのかな?
今回のgithub: https://github.com/wew-ptr/nimmyttp

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?