Просмотр исходного кода

Match literal route patterns without building a std::regex (#2538)

Server::make_matcher() built a std::regex for every pattern that did not
contain "/:", even though most route patterns are plain literals with no
regular expression syntax in them. Matching those went through
std::regex_match on every request, for every registered route the
dispatcher scanned before reaching the one that matches.

PathParamsMatcher already performs an exact literal comparison when it
captures no parameter, so no new matcher class is needed: a pattern with
no regex metacharacter can simply use it. Add an early return for the
zero parameter case in PathParamsMatcher::match(), and select the matcher
by also looking for the 14 ECMAScript metacharacters instead of only for
"/:". Path params keep taking precedence, so a pattern that mixes both,
such as "/users/:id/(.*)", is unaffected.

Measured with clang -O2 on macOS, scanning routes that all miss until the
last one: at 100 routes a scan drops from 10.3us to 0.46us, and end to end
throughput rises by about 24%. At 1000 routes throughput is roughly 3
times higher. Registering 5000 routes drops from about 3.0ms to about
0.9ms, since no std::regex is built for literal patterns.

This also keeps CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH confined to the
routes it is meant for. That limit rejects overlong paths before calling
std::regex_match, but until now every literal route was a RegexMatcher
too, so a literal route longer than the limit stopped matching even though
no regular expression was involved. Literal routes no longer go through
RegexMatcher, so only real regex routes are capped.

Patterns containing a metacharacter keep their current behavior, so
"/index.html" still matches "/indexXhtml" the way it always has. One
visible change: a literal route no longer populates Request::matches,
which is now a default constructed std::smatch. Path parameter routes
have always behaved that way, and Request::matches only carries useful
information for regex routes.
yhirose 2 дней назад
Родитель
Сommit
1e9d6f0b0b
2 измененных файлов с 89 добавлено и 2 удалено
  1. 16 2
      httplib.h
  2. 73 0
      test/test.cc

+ 16 - 2
httplib.h

@@ -11603,6 +11603,10 @@ inline PathParamsMatcher::PathParamsMatcher(const std::string &pattern)
 inline bool PathParamsMatcher::match(Request &request) const {
   request.matches = std::smatch();
   request.path_params.clear();
+
+  // A pattern without parameters is just a literal path to compare against
+  if (param_names_.empty()) { return request.path == pattern(); }
+
   request.path_params.reserve(param_names_.size());
 
   // One past the position at which the path matched the pattern last time
@@ -12075,11 +12079,21 @@ inline Server::~Server() = default;
 
 inline std::unique_ptr<detail::MatcherBase>
 Server::make_matcher(const std::string &pattern) {
+  // Path params take precedence, so "/users/:id/(.*)" keeps being matched as
+  // a path params pattern
   if (pattern.find("/:") != std::string::npos) {
     return detail::make_unique<detail::PathParamsMatcher>(pattern);
-  } else {
-    return detail::make_unique<detail::RegexMatcher>(pattern);
   }
+
+  // A pattern with no regex metacharacter only has to be compared literally,
+  // which is what PathParamsMatcher already does when it captures no
+  // parameter, so std::regex is only worth building for the patterns that
+  // actually need it
+  if (pattern.find_first_of(".^$|()[]{}*+?\\") == std::string::npos) {
+    return detail::make_unique<detail::PathParamsMatcher>(pattern);
+  }
+
+  return detail::make_unique<detail::RegexMatcher>(pattern);
 }
 
 inline Server &Server::Get(const std::string &pattern, Handler handler) {

+ 73 - 0
test/test.cc

@@ -15461,6 +15461,79 @@ TEST(RegexMatcherTest, ServerSurvivesOverlongPathOnRegexRoute) {
   EXPECT_NE(std::string::npos, response.find("404"));
 }
 
+TEST(RouteMatcherTest, LiteralPatternsAndRegexPatterns) {
+  Server svr;
+
+  auto handler = [](const Request & /*req*/, Response &res) {
+    res.set_content("hit", "text/plain");
+  };
+
+  // No regex metacharacter, so this one is compared literally
+  svr.Get("/literal/route", handler);
+  // '.' is a regex metacharacter, so this one must keep regex semantics
+  svr.Get("/a.c", handler);
+
+  auto port = svr.bind_to_any_port(HOST);
+  std::thread t([&]() { svr.listen_after_bind(); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+    ASSERT_FALSE(svr.is_running());
+  });
+
+  svr.wait_until_ready();
+
+  Client cli(HOST, port);
+
+  struct {
+    const char *path;
+    int status;
+  } cases[] = {
+      {"/literal/route", StatusCode::OK_200},
+      {"/literal/routes", StatusCode::NotFound_404},
+      {"/literal/rout", StatusCode::NotFound_404},
+      {"/abc", StatusCode::OK_200},
+      {"/a.c", StatusCode::OK_200},
+  };
+
+  for (const auto &x : cases) {
+    auto res = cli.Get(x.path);
+    ASSERT_TRUE(res) << x.path;
+    EXPECT_EQ(x.status, res->status) << x.path;
+  }
+}
+
+TEST(RouteMatcherTest, LongLiteralPatternIsNotSubjectToTheRegexPathLimit) {
+  // CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH exists to keep std::regex_match
+  // from exhausting the stack, so it must only constrain routes that actually
+  // run a regular expression. A literal route is matched by comparison and
+  // stays usable at any length.
+  Server svr;
+
+  const std::string pattern =
+      "/files/" + std::string(CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH * 2, 'a');
+
+  svr.Get(pattern, [](const Request & /*req*/, Response &res) {
+    res.set_content("hit", "text/plain");
+  });
+
+  auto port = svr.bind_to_any_port(HOST);
+  std::thread t([&]() { svr.listen_after_bind(); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+    ASSERT_FALSE(svr.is_running());
+  });
+
+  svr.wait_until_ready();
+
+  Client cli(HOST, port);
+
+  auto res = cli.Get(pattern);
+  ASSERT_TRUE(res);
+  EXPECT_EQ(StatusCode::OK_200, res->status);
+}
+
 TEST(ParseUrlTest, VariousPatterns) {
   {
     detail::UrlComponents uc;