Skip to content

Commit a2da061

Browse files
Reduce slightly allocations when reading/writing HTTP messages (#950)
* Avoid creating additional string when writing headers * Define our own simpler Version type * Update src/Messages.jl Co-authored-by: Jacob Quinn <[email protected]> * Update src/Messages.jl Co-authored-by: Jacob Quinn <[email protected]> * Test we don't allocate * Tidy tests * Remove unused code * Update src/clientlayers/MessageRequest.jl * Write headers to temporary buffer first - ...before flushing to socket, to avoid task switching mid writing to the socket itself. * Remove unneeded temp buffer in Stream startwrite * Make loopback test less flaky by using Event instead of sleep * Don't connect a socket for Loopback test - unnecessary and was leading to occasional DNS Errors Co-authored-by: Jacob Quinn <[email protected]>
1 parent 7a54ffc commit a2da061

11 files changed

+158
-45
lines changed

src/ConnectionPool.jl

+2-1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ mutable struct Connection <: IO
7272
timestamp::Float64
7373
readable::Bool
7474
writable::Bool
75+
writebuffer::IOBuffer
7576
state::Any # populated & used by Servers code
7677
end
7778

@@ -92,7 +93,7 @@ Connection(host::AbstractString, port::AbstractString,
9293
Connection(host, port, idle_timeout,
9394
require_ssl_verification,
9495
safe_getpeername(io)..., localport(io),
95-
io, client, PipeBuffer(), time(), false, false, nothing)
96+
io, client, PipeBuffer(), time(), false, false, IOBuffer(), nothing)
9697

9798
Connection(io; require_ssl_verification::Bool=true) =
9899
Connection("", "", 0, require_ssl_verification, io, false)

src/Messages.jl

+32-30
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export Message, Request, Response,
6161

6262
using URIs, CodecZlib
6363
using ..Pairs, ..IOExtras, ..Parsers, ..Strings, ..Forms, ..Conditions
64+
using ..ConnectionPool
6465

6566
const nobody = UInt8[]
6667
const unknown_length = typemax(Int)
@@ -77,7 +78,7 @@ abstract type Message end
7778
7879
Represents an HTTP response message with fields:
7980
80-
- `version::VersionNumber`
81+
- `version::HTTPVersion`
8182
[RFC7230 2.6](https://tools.ietf.org/html/rfc7230#section-2.6)
8283
8384
- `status::Int16`
@@ -94,14 +95,14 @@ Represents an HTTP response message with fields:
9495
9596
"""
9697
mutable struct Response <: Message
97-
version::VersionNumber
98+
version::HTTPVersion
9899
status::Int16
99100
headers::Headers
100101
body::Any # Usually Vector{UInt8} or IO
101102
request::Union{Message, Nothing} # Union{Request, Nothing}
102103
end
103104

104-
function Response(status::Integer, headers, body; version::VersionNumber=v"1.1", request=nothing)
105+
function Response(status::Integer, headers, body; version=HTTPVersion(1, 1), request=nothing)
105106
b = isbytes(body) ? bytes(body) : something(body, nobody)
106107
@assert (request isa Request || request === nothing)
107108
return Response(version, status, mkheaders(headers), b, request)
@@ -119,7 +120,7 @@ Response(body) = Response(200; body=body)
119120
Base.convert(::Type{Response}, s::AbstractString) = Response(s)
120121

121122
function reset!(r::Response)
122-
r.version = v"1.1"
123+
r.version = HTTPVersion(1, 1)
123124
r.status = 0
124125
if !isempty(r.headers)
125126
empty!(r.headers)
@@ -136,8 +137,10 @@ body(r::Response) = getfield(r, :body)
136137
const Context = Dict{Symbol, Any}
137138

138139
"""
139-
HTTP.Request(method, target, headers=[], body=nobody;
140-
version=v"1.1", url::URI=URI(), responsebody=nothing, parent=nothing, context=HTTP.Context())
140+
HTTP.Request(
141+
method, target, headers=[], body=nobody;
142+
version=v"1.1", url::URI=URI(), responsebody=nothing, parent=nothing, context=HTTP.Context()
143+
)
141144
142145
Represents a HTTP Request Message with fields:
143146
@@ -147,7 +150,7 @@ Represents a HTTP Request Message with fields:
147150
- `target::String`
148151
[RFC7230 5.3](https://tools.ietf.org/html/rfc7230#section-5.3)
149152
150-
- `version::VersionNumber`
153+
- `version::HTTPVersion`
151154
[RFC7230 2.6](https://tools.ietf.org/html/rfc7230#section-2.6)
152155
153156
- `headers::HTTP.Headers`
@@ -170,7 +173,7 @@ Represents a HTTP Request Message with fields:
170173
mutable struct Request <: Message
171174
method::String
172175
target::String
173-
version::VersionNumber
176+
version::HTTPVersion
174177
headers::Headers
175178
body::Any # Usually Vector{UInt8} or some kind of IO
176179
response::Response
@@ -181,8 +184,10 @@ end
181184

182185
Request() = Request("", "")
183186

184-
function Request(method::String, target, headers=[], body=nobody;
185-
version=v"1.1", url::URI=URI(), responsebody=nothing, parent=nothing, context=Context())
187+
function Request(
188+
method::String, target, headers=[], body=nobody;
189+
version=HTTPVersion(1, 1), url::URI=URI(), responsebody=nothing, parent=nothing, context=Context()
190+
)
186191
b = isbytes(body) ? bytes(body) : body
187192
r = Request(method, target == "" ? "/" : target, version,
188193
mkheaders(headers), b, Response(0; body=responsebody),
@@ -463,26 +468,19 @@ function decode(m::Message, encoding::String="gzip")::Vector{UInt8}
463468
end
464469

465470
# Writing HTTP Messages to IO streams
466-
"""
467-
HTTP.httpversion(::Message)
468-
469-
e.g. `"HTTP/1.1"`
470-
"""
471-
httpversion(m::Message) = "HTTP/$(m.version.major).$(m.version.minor)"
471+
Base.write(io::IO, v::HTTPVersion) = write(io, "HTTP/", string(v.major), ".", string(v.minor))
472472

473473
"""
474474
writestartline(::IO, ::Message)
475475
476476
e.g. `"GET /path HTTP/1.1\\r\\n"` or `"HTTP/1.1 200 OK\\r\\n"`
477477
"""
478478
function writestartline(io::IO, r::Request)
479-
write(io, "$(r.method) $(r.target) $(httpversion(r))\r\n")
480-
return
479+
return write(io, r.method, " ", r.target, " ", r.version, "\r\n")
481480
end
482481

483482
function writestartline(io::IO, r::Response)
484-
write(io, "$(httpversion(r)) $(r.status) $(statustext(r.status))\r\n")
485-
return
483+
return write(io, r.version, " ", string(r.status), " ", statustext(r.status), "\r\n")
486484
end
487485

488486
"""
@@ -491,14 +489,18 @@ end
491489
Write `Message` start line and
492490
a line for each "name: value" pair and a trailing blank line.
493491
"""
494-
function writeheaders(io::IO, m::Message)
495-
writestartline(io, m)
492+
writeheaders(io::IO, m::Message) = writeheaders(io, m, IOBuffer())
493+
writeheaders(io::Connection, m::Message) = writeheaders(io, m, io.writebuffer)
494+
495+
function writeheaders(io::IO, m::Message, buf::IOBuffer)
496+
writestartline(buf, m)
496497
for (name, value) in m.headers
497498
# match curl convention of not writing empty headers
498-
!isempty(value) && write(io, "$name: $value\r\n")
499+
!isempty(value) && write(buf, name, ": ", value, "\r\n")
499500
end
500-
write(io, "\r\n")
501-
return
501+
write(buf, "\r\n")
502+
nwritten = write(io, take!(buf))
503+
return nwritten
502504
end
503505

504506
"""
@@ -507,15 +509,15 @@ end
507509
Write start line, headers and body of HTTP Message.
508510
"""
509511
function Base.write(io::IO, m::Message)
510-
writeheaders(io, m)
511-
write(io, m.body)
512-
return
512+
nwritten = writeheaders(io, m)
513+
nwritten += write(io, m.body)
514+
return nwritten
513515
end
514516

515517
function Base.String(m::Message)
516518
io = IOBuffer()
517519
write(io, m)
518-
String(take!(io))
520+
return String(take!(io))
519521
end
520522

521523
# Reading HTTP Messages from IO streams
@@ -589,7 +591,7 @@ end
589591
function compactstartline(m::Message)
590592
b = IOBuffer()
591593
writestartline(b, m)
592-
strip(String(take!(b)))
594+
return strip(String(take!(b)))
593595
end
594596

595597
# temporary replacement for isvalid(String, s), until the

src/Parsers.jl

+2-2
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ function parse_request_line!(bytes::AbstractString, request)::SubString{String}
191191
end
192192
request.method = group(1, re, bytes)
193193
request.target = group(2, re, bytes)
194-
request.version = VersionNumber(group(3, re, bytes))
194+
request.version = HTTPVersion(group(3, re, bytes))
195195
return nextbytes(re, bytes)
196196
end
197197

@@ -205,7 +205,7 @@ function parse_status_line!(bytes::AbstractString, response)::SubString{String}
205205
if !exec(re, bytes)
206206
throw(ParseError(:INVALID_STATUS_LINE, bytes))
207207
end
208-
response.version = VersionNumber(group(1, re, bytes))
208+
response.version = HTTPVersion(group(1, re, bytes))
209209
response.status = parse(Int, group(2, re, bytes))
210210
return nextbytes(re, bytes)
211211
end

src/Servers.jl

+1-1
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ function check_readtimeout(c, readtimeout, wait_for_timeout)
494494
if inactiveseconds(c) > readtimeout
495495
@warnv 2 "Connection Timeout: $c"
496496
try
497-
writeheaders(c.io, Response(408, ["Connection" => "close"]))
497+
writeheaders(c, Response(408, ["Connection" => "close"]))
498498
finally
499499
closeconnection(c)
500500
end

src/Streams.jl

+1-3
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,7 @@ function IOExtras.startwrite(http::Stream)
7676
else
7777
http.writechunked = ischunked(m)
7878
end
79-
buf = IOBuffer()
80-
writeheaders(buf, m)
81-
n = write(http.stream, take!(buf))
79+
n = writeheaders(http.stream, m)
8280
# nwritten starts at -1 so that we can tell if we've written anything yet
8381
http.nwritten = 0 # should not include headers
8482
return n

src/Strings.jl

+80-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,88 @@
11
module Strings
22

3-
export escapehtml, tocameldash, iso8859_1_to_utf8, ascii_lc_isequal
3+
export HTTPVersion, escapehtml, tocameldash, iso8859_1_to_utf8, ascii_lc_isequal
44

55
using ..IOExtras
66

7+
# A `Base.VersionNumber` is a SemVer spec, whereas a HTTP versions is just 2 digits,
8+
# This allows us to use a smaller type and more importantly write a simple parse method
9+
# that avoid allocations.
10+
"""
11+
HTTPVersion(major, minor)
12+
13+
The HTTP version number consists of two digits separated by a
14+
"." (period or decimal point). The first digit (`major` version)
15+
indicates the HTTP messaging syntax, whereas the second digit (`minor`
16+
version) indicates the highest minor version within that major
17+
version to which the sender is conformant and able to understand for
18+
future communication.
19+
20+
See [RFC7230 2.6](https://tools.ietf.org/html/rfc7230#section-2.6)
21+
"""
22+
struct HTTPVersion
23+
major::UInt8
24+
minor::UInt8
25+
end
26+
27+
HTTPVersion(major::Integer) = HTTPVersion(major, 0x00)
28+
HTTPVersion(v::AbstractString) = parse(HTTPVersion, v)
29+
HTTPVersion(v::VersionNumber) = convert(HTTPVersion, v)
30+
# Lossy conversion. We ignore patch/prerelease/build parts even if non-zero/non-empty,
31+
# because we don't want to add overhead for a case that should never be relevant.
32+
Base.convert(::Type{HTTPVersion}, v::VersionNumber) = HTTPVersion(v.major, v.minor)
33+
Base.VersionNumber(v::HTTPVersion) = VersionNumber(v.major, v.minor)
34+
35+
Base.show(io::IO, v::HTTPVersion) = print(io, "HTTPVersion(\"", string(v.major), ".", string(v.minor), "\")")
36+
37+
Base.:(==)(va::VersionNumber, vb::HTTPVersion) = va == VersionNumber(vb)
38+
Base.:(==)(va::HTTPVersion, vb::VersionNumber) = VersionNumber(va) == vb
39+
40+
Base.isless(va::VersionNumber, vb::HTTPVersion) = isless(va, VersionNumber(vb))
41+
Base.isless(va::HTTPVersion, vb::VersionNumber) = isless(VersionNumber(va), vb)
42+
function Base.isless(va::HTTPVersion, vb::HTTPVersion)
43+
va.major < vb.major && return true
44+
va.major > vb.major && return false
45+
va.minor < vb.minor && return true
46+
return false
47+
end
48+
49+
function Base.parse(::Type{HTTPVersion}, v::AbstractString)
50+
ver = tryparse(HTTPVersion, v)
51+
ver === nothing && throw(ArgumentError("invalid HTTP version string: $(repr(v))"))
52+
return ver
53+
end
54+
55+
# We only support single-digits for major and minor versions
56+
# - we can parse 0.9 but not 0.10
57+
# - we can parse 9.0 but not 10.0
58+
function Base.tryparse(::Type{HTTPVersion}, v::AbstractString)
59+
isempty(v) && return nothing
60+
len = ncodeunits(v)
61+
62+
i = firstindex(v)
63+
d1 = v[i]
64+
if isdigit(d1)
65+
major = parse(UInt8, d1)
66+
else
67+
return nothing
68+
end
69+
70+
i = nextind(v, i)
71+
i > len && return HTTPVersion(major)
72+
dot = v[i]
73+
dot == '.' || return nothing
74+
75+
i = nextind(v, i)
76+
i > len && return HTTPVersion(major)
77+
d2 = v[i]
78+
if isdigit(d2)
79+
minor = parse(UInt8, d2)
80+
else
81+
return nothing
82+
end
83+
return HTTPVersion(major, minor)
84+
end
85+
786
"""
887
escapehtml(i::String)
988

src/clientlayers/MessageRequest.jl

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ module MessageRequest
22

33
using URIs
44
using ..IOExtras, ..Messages, ..Parsers, ..Exceptions
5+
using ..Messages, ..Parsers
6+
using ..Strings: HTTPVersion
57

68
export messagelayer
79

@@ -12,7 +14,7 @@ Construct a [`Request`](@ref) object from method, url, headers, and body.
1214
Hard-coded as the first layer in the request pipeline.
1315
"""
1416
function messagelayer(handler)
15-
return function(method::String, url::URI, headers::Headers, body; response_stream=nothing, http_version=v"1.1", kw...)
17+
return function(method::String, url::URI, headers::Headers, body; response_stream=nothing, http_version=HTTPVersion(1, 1), kw...)
1618
req = Request(method, resource(url), headers, body; url=url, version=http_version, responsebody=response_stream)
1719
local resp
1820
try

test/httpversion.jl

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
@testset "HTTPVersion" begin
2+
3+
# Constructors
4+
@test HTTPVersion(1) == HTTPVersion(1, 0)
5+
@test HTTPVersion(1) == HTTPVersion("1")
6+
@test HTTPVersion(1) == HTTPVersion("1.0")
7+
@test HTTPVersion(1) == HTTPVersion(v"1")
8+
@test HTTPVersion(1) == HTTPVersion(v"1.0")
9+
10+
@test HTTPVersion(1, 1) == HTTPVersion("1.1")
11+
@test HTTPVersion(1, 1) == HTTPVersion(v"1.1")
12+
@test HTTPVersion(1, 1) == HTTPVersion(v"1.1.0")
13+
14+
@test VersionNumber(HTTPVersion(1)) == v"1"
15+
@test VersionNumber(HTTPVersion(1, 1)) == v"1.1"
16+
17+
# Important that we can parse a string into a `HTTPVersion` without allocations,
18+
# as we do this for every request/response. Similarly if we then want a `VersionNumber`.
19+
@test @allocated(HTTPVersion("1.1")) == 0
20+
@test @allocated(VersionNumber(HTTPVersion("1.1"))) == 0
21+
22+
# Test comparisons with `VersionNumber`s
23+
req = HTTP.Request("GET", "http://httpbin.org/anything")
24+
res = HTTP.Response(200)
25+
for r in (req, res)
26+
@test r.version == v"1.1"
27+
@test r.version <= v"1.1"
28+
@test r.version < v"1.2"
29+
end
30+
31+
end # testset

test/loopback.jl

+4-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Base.readavailable(fio::FunctionIO) = (call(fio); readavailable(fio.buf))
4343
Base.readavailable(lb::Loopback) = readavailable(lb.io)
4444
Base.unsafe_read(lb::Loopback, p::Ptr, n::Integer) = unsafe_read(lb.io, p, n)
4545

46-
HTTP.IOExtras.tcpsocket(::Loopback) = Sockets.connect("$httpbin", 80)
46+
HTTP.IOExtras.tcpsocket(::Loopback) = TCPSocket()
4747

4848
lbreq(req, headers, body; method="GET", kw...) =
4949
HTTP.request(method, "http://test/$req", headers, body; config..., kw...)
@@ -262,8 +262,9 @@ end
262262
@test_throws HTTP.StatusError begin
263263
r = lbopen("abort", []) do http
264264
@sync begin
265+
event = Base.Event()
265266
@async try
266-
sleep(0.1)
267+
wait(event)
267268
write(http, "Hello World!")
268269
closewrite(http)
269270
body_sent = true
@@ -278,6 +279,7 @@ end
278279
startread(http)
279280
body = read(http)
280281
closeread(http)
282+
notify(event)
281283
end
282284
end
283285
end

0 commit comments

Comments
 (0)