@marcel91200 / caddy Mirror · read-only / commits / 9157051f
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 14, 05:15 UTC

caddyhttp: Optimize large host matchers

Matthew Holt committed Dec 2, 2020 at 13:26 UTC 9157051f45b8c1354b6c8432457ca4930ba90d9e
2 files changed +95
modules/caddyhttp/matchers.go
+68
@@ -23,6 +23,7 @@ import (
23 "net/url"
24 "path/filepath"
25 "regexp"
26 + "sort"
27 "strings"
28
29 "github.com/caddyserver/caddy/v2"
@@ -51,6 +52,8 @@ type (
52 //
53 // The wildcard can be useful for matching all subdomains, for example:
54 // `*.example.com` matches `foo.example.com` but not `foo.bar.example.com`.
55 + //
56 + // Duplicate entries will return an error.
57 MatchHost []string
58
59 // MatchPath matches requests by the URI's path (case-insensitive). Path
@@ -167,6 +170,40 @@ func (m *MatchHost) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
170 return nil
171 }
172
173 +// Provision sets up and validates m, including making it more efficient for large lists.
174 +func (m MatchHost) Provision(_ caddy.Context) error {
175 + // check for duplicates; they are nonsensical and reduce efficiency
176 + // (we could just remove them, but the user should know their config is erroneous)
177 + seen := make(map[string]int)
178 + for i, h := range m {
179 + h = strings.ToLower(h)
180 + if firstI, ok := seen[h]; ok {
181 + return fmt.Errorf("host at index %d is repeated at index %d: %s", firstI, i, h)
182 + }
183 + seen[h] = i
184 + }
185 +
186 + if m.large() {
187 + // sort the slice lexicographically, grouping "fuzzy" entries (wildcards and placeholders)
188 + // at the front of the list; this allows us to use binary search for exact matches, which
189 + // we have seen from experience is the most common kind of value in large lists; and any
190 + // other kinds of values (wildcards and placeholders) are grouped in front so the linear
191 + // search should find a match fairly quickly
192 + sort.Slice(m, func(i, j int) bool {
193 + iInexact, jInexact := m.fuzzy(m[i]), m.fuzzy(m[j])
194 + if iInexact && !jInexact {
195 + return true
196 + }
197 + if !iInexact && jInexact {
198 + return false
199 + }
200 + return m[i] < m[j]
201 + })
202 + }
203 +
204 + return nil
205 +}
206 +
207 // Match returns true if r matches m.
208 func (m MatchHost) Match(r *http.Request) bool {
209 reqHost, _, err := net.SplitHostPort(r.Host)
@@ -179,10 +216,31 @@ func (m MatchHost) Match(r *http.Request) bool {
216 reqHost = strings.TrimSuffix(reqHost, "]")
217 }
218
219 + if m.large() {
220 + // fast path: locate exact match using binary search (about 100-1000x faster for large lists)
221 + pos := sort.Search(len(m), func(i int) bool {
222 + if m.fuzzy(m[i]) {
223 + return false
224 + }
225 + return m[i] >= reqHost
226 + })
227 + if pos < len(m) && m[pos] == reqHost {
228 + return true
229 + }
230 + }
231 +
232 repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
233
234 outer:
235 for _, host := range m {
236 + // fast path: if matcher is large, we already know we don't have an exact
237 + // match, so we're only looking for fuzzy match now, which should be at the
238 + // front of the list; if we have reached a value that is not fuzzy, there
239 + // will be no match and we can short-circuit for efficiency
240 + if m.large() && !m.fuzzy(host) {
241 + break
242 + }
243 +
244 host = repl.ReplaceAll(host, "")
245 if strings.Contains(host, "*") {
246 patternParts := strings.Split(host, ".")
@@ -207,6 +265,15 @@ outer:
265 return false
266 }
267
268 +// fuzzy returns true if the given hostname h is not a specific
269 +// hostname, e.g. has placeholders or wildcards.
270 +func (MatchHost) fuzzy(h string) bool { return strings.ContainsAny(h, "{*") }
271 +
272 +// large returns true if m is considered to be large. Optimizing
273 +// the matcher for smaller lists has diminishing returns.
274 +// See related benchmark function in test file to conduct experiments.
275 +func (m MatchHost) large() bool { return len(m) > 100 }
276 +
277 // CaddyModule returns the Caddy module information.
278 func (MatchPath) CaddyModule() caddy.ModuleInfo {
279 return caddy.ModuleInfo{
@@ -909,6 +976,7 @@ const regexpPlaceholderPrefix = "http.regexp"
976 // Interface guards
977 var (
978 _ RequestMatcher = (*MatchHost)(nil)
979 + _ caddy.Provisioner = (*MatchHost)(nil)
980 _ RequestMatcher = (*MatchPath)(nil)
981 _ RequestMatcher = (*MatchPathRE)(nil)
982 _ caddy.Provisioner = (*MatchPathRE)(nil)
modules/caddyhttp/matchers_test.go
+27
@@ -1018,6 +1018,33 @@ func TestNotMatcher(t *testing.T) {
1018 }
1019 }
1020 }
1021 +func BenchmarkLargeHostMatcher(b *testing.B) {
1022 + // this benchmark simulates a large host matcher (thousands of entries) where each
1023 + // value is an exact hostname (not a placeholder or wildcard) - compare the results
1024 + // of this with and without the binary search (comment out the various fast path
1025 + // sections in Match) to conduct experiments
1026 +
1027 + const n = 10000
1028 + lastHost := fmt.Sprintf("%d.example.com", n-1)
1029 + req := &http.Request{Host: lastHost}
1030 + repl := caddy.NewReplacer()
1031 + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
1032 + req = req.WithContext(ctx)
1033 +
1034 + matcher := make(MatchHost, n)
1035 + for i := 0; i < n; i++ {
1036 + matcher[i] = fmt.Sprintf("%d.example.com", i)
1037 + }
1038 + err := matcher.Provision(caddy.Context{})
1039 + if err != nil {
1040 + b.Fatal(err)
1041 + }
1042 +
1043 + b.ResetTimer()
1044 + for i := 0; i < b.N; i++ {
1045 + matcher.Match(req)
1046 + }
1047 +}
1048
1049 func BenchmarkHostMatcherWithoutPlaceholder(b *testing.B) {
1050 req := &http.Request{Host: "localhost"}