"h1" 고속 HTTP/1.1 파서

Silverlining HTTP 라이브러리


HTTP/1.1 요청 파서 설계

GET / HTTP/1.1
Host: localhost:8080
Connection: keep-alive

HTTP/1.1 요청은 CRLF로 구분되어 있습니다.

따라서 \r\n을 사용하여 각 라인을 분리합니다.

불필요한 string[]byte사이의 변환을 최소화 하려고 노력했습니다.

Request Line 파싱

Request Line은 요청 메시지의 첫 라인입니다.

Method Request-URI HTTP-Version

메서드, 요청 URI, HTTP 버전이 Space로 구분되어 있습니다.

h1 파서는 GET, PUT, HEAD, POST, BREW, TRACE, PATCH, DELETE, CONNECT, OPTIONS 등의 메서드를 지원하고 있습니다.

h1은 속도를 위해 메서드를 첫 3글자로 판별하고 있습니다.

var methodTable = [256]Method{}

var _ = func() int {
    //GET
    const GETIndex = 'G' ^ 'E' + 'T'
    methodTable[GETIndex] = MethodGET
    //PUT
    const PUTIndex = 'P' ^ 'U' + 'T'
    methodTable[PUTIndex] = MethodPUT
    //HEAD
    const HEADIndex = 'H' ^ 'E' + 'A'
    methodTable[HEADIndex] = MethodHEAD
    //POST
    const POSTIndex = 'P' ^ 'O' + 'S'
    methodTable[POSTIndex] = MethodPOST
    //BREW
    const BREWIndex = 'B' ^ 'R' + 'E'
    methodTable[BREWIndex] = MethodBREW
    //TRACE
    const TRACEIndex = 'T' ^ 'R' + 'A'
    methodTable[TRACEIndex] = MethodTRACE
    //PATCH
    const PATCHIndex = 'P' ^ 'A' + 'T'
    methodTable[PATCHIndex] = MethodPATCH
    //DELETE
    const DELETEIndex = 'D' ^ 'E' + 'L'
    methodTable[DELETEIndex] = MethodDELETE
    //CONNECT
    const CONNECTIndex = 'C' ^ 'O' + 'N'
    methodTable[CONNECTIndex] = MethodCONNECT
    //OPTIONS
    const OPTIONSIndex = 'O' ^ 'P' + 'T'
    methodTable[OPTIONSIndex] = MethodOPTIONS

    // all methods should have distinct index number
    // Go에서는 상수값을 키로 하는 map은 컴파일 시점에 키의 충돌을 검증할 수 있습니다.
    var _ = map[int]Method{
        GETIndex:     MethodGET,
        PUTIndex:     MethodPUT,
        HEADIndex:    MethodHEAD,
        POSTIndex:    MethodPOST,
        BREWIndex:    MethodBREW,
        TRACEIndex:   MethodTRACE,
        PATCHIndex:   MethodPATCH,
        DELETEIndex:  MethodDELETE,
        CONNECTIndex: MethodCONNECT,
        OPTIONSIndex: MethodOPTIONS,
    }

    return 0
}()

원본 소스

크기가 256인 Lookup Table을 사용하여 불필요한 map 조회 오버헤드를 줄일 수 있었습니다.

func ParseRequestLine(dst *Request, src []byte) (next []byte, err error) {
    next = src
    var line []byte
    line, next, err = splitLine(next)
    if err != nil {
        return next, err
    }
    MethodIndex := bytes.IndexByte(line, ' ')
    if MethodIndex < 0 || MethodIndex < 3 {
        return next, ErrInvalidMethod
    }
    URIIndex := bytes.IndexByte(line[MethodIndex+1:], ' ')
    if URIIndex < 0 {
        return next, ErrInvalidURI
    }
    dst.RawURI = line[MethodIndex+1 : MethodIndex+1+URIIndex]
    dst.Version = line[MethodIndex+1+URIIndex+1:]

    m := line[:MethodIndex]

    // m[0]^m[1]+m[2] 메서드를 1byte로 압축합니다.
    dst.Method = methodTable[m[0]^m[1]+m[2]]
    return next, nil
}

원본 소스

bytes.IndexByte를 사용하여 Space를 검색하고 메서드와 URI, HTTP 버전을 구분합니다.

bytes.IndexByte는 내부적으로 Assembly로 구현된 bytealg.IndexByte를 사용하고 있습니다.

Request Header 파싱

Request Header는 CRLF와 :으로 구분되어 있습니다.

따라서 Request Line 파서와 구조가 비슷합니다.

func ParseHeaderLine(src []byte) (name []byte, value []byte) {
    idx := bytes.IndexByte(src, ':')
    if idx < 0 {
        return src[:0], nil
    }
    // RFC2616 Section 4.2
    // Remove all leading and trailing LWS on field contents

    // skip leading LWS
    var i int = idx + 1
    for ; i < len(src); i++ {
        if src[i] != ' ' && src[i] != '\t' {
            break
        }
    }
    // skip trailing LWS
    var j int = len(src) - 1
    for ; j > i; j-- {
        if src[j] != ' ' && src[j] != '\t' {
            break
        }
    }
    return src[:idx], src[i : j+1]
}

func ParseHeaders(dst *Request, src []byte) (next []byte, err error) {
    next = src
    var line []byte
    for {
        line, next, err = splitLine(next) // CRLF를 기준으로 라인을 분리합니다.
        if err != nil {
            return next, err
        }
        if len(line) == 0 {
            break
        }
        h := Header{}
        h.Name, h.RawValue = ParseHeaderLine(line) // Header Line을 파싱합니다.
        dst.Headers = append(dst.Headers, h)

        if stricmp(h.Name, ContentLengthHeader) { // Content-Length Header는 따로 처리합니다.
            dst.ContentLength, err = ParseContentLength(h.RawValue)
            if err != nil {
                return next, err
            }
        }
    }
    return next, nil
}

원본 소스

Header는 메모리 효율성을 위해서 map이 아닌 slice로 구현하였습니다.

지속적인 프로파일링

h1은 각각의 함수들의 병목 지점과 성능을 지속적으로 확인하기 위해서 GitHub Actions으로 지속적인 프로파일링을 진행하고 있습니다.

Benchmark Results

일정한 환경에서 성능을 지속적으로 감시하면서 개발을 진행할 수 있었습니다.

소스코드

h1