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"}