Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(req): use URLSearchParams for parsing query params #3565

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

yusukebe
Copy link
Member

@yusukebe yusukebe commented Oct 27, 2024

This PR changes the implementation of parsing query params using c.req.query(). This change affects performance and file size. In addition, it fixes the problem that can't parse invalid percent strings.

File size

The logic to parse query params like ?name=hono is written by ourown method. The method started with following:

hono/src/utils/url.ts

Lines 213 to 217 in 3a59d86

const _getQueryParam = (
url: string,
key?: string,
multiple?: boolean
): string | undefined | Record<string, string> | string[] | Record<string, string[]> => {

This enables them to be parsed fast, but the logic and code are complicated.

With this PR, it uses URLSearchParams instead of that logic. So, the code will decrease, and the application file size will be smaller. The below compares the current release version and this PR. The application is "Hello World" using hono/tiny preset and minified with esbuild:

CleanShot 2024-10-28 at 17 26 30@2x

The result is that this change decreases 642bytes. This seems to be a small change for the non-Hono user, but I think it's a big diff for us.

The important point is that hono should not provide a lot of code for the user that does not use a lot of features. For example, we don't want to take much code to parse a query if the application does not use c.req.query(). So, using URLSearchParams built-in API makes sense.

Performance

Instead, performance will decrease. The following is the result:

CleanShot 2024-10-28 at 16 23 06@2x

The application code:

import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text(c.req.query('name') ?? 'no name'))

export default app

At a first grance, you may be impressed that this difference is enormous, but it's a slight, 4% slowdown. I think there's more advantage in using URLSearchParasm for making it short and easy to understand.

Small vs Fast

In this place, you may think "small vs. fast "—which should we choose, small or fast? I think the answer is "both." We have to consider it case by case.

In this parsing query param matter, "small" is better. Being small has some time-saving effects because a small application can be faster in an environment where resources such as Cloudflare Workers are limited. So, in some cases, "small" makes "fast".

Parting invalid percent strings

This PR will fix the problem of paring invalid percent strings. Before this PR, it will throw the error if you want to do c.req.query('q') for the following URL:

http://example.com/?q=%h

In this PR, it can parse the query and you can get a %h.

Conclusion

This PR using URLSearchParams introduce decrease of the performance to parse query params, but reducing the file size and making the code clean is more efficient. BUT, this is just my current thought. We can discuss it.

Copy link

codecov bot commented Oct 27, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 94.68%. Comparing base (7e11832) to head (1714606).
Report is 75 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3565      +/-   ##
==========================================
- Coverage   94.71%   94.68%   -0.04%     
==========================================
  Files         157      157              
  Lines        9539     9480      -59     
  Branches     2819     2799      -20     
==========================================
- Hits         9035     8976      -59     
  Misses        504      504              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@yusukebe yusukebe changed the title [PoC] refactor(request): use URLSearchParams for parsing query params refactor(req): use URLSearchParams for parsing query params Oct 28, 2024
@yusukebe yusukebe marked this pull request as ready for review October 28, 2024 08:57
@yusukebe
Copy link
Member Author

Hi @usualoma ! I want to hear your opinion!

@usualoma
Copy link
Member

Another benchmark

When we do local benchmarking of query string parsing, I confirm that using URLSearchParams is, on average, several times slower. However, in some exceptional cases, URLSearchParams may be faster (depending on the runtime).

diff --git a/benchmarks/query-param/src/bench.mts b/benchmarks/query-param/src/bench.mts
index e8be60e0..857d24c9 100644
--- a/benchmarks/query-param/src/bench.mts
+++ b/benchmarks/query-param/src/bench.mts
@@ -1,4 +1,5 @@
 import { run, group, bench } from 'mitata'
+import { getQueryStrings } from '../../../src/utils/url'
 import fastQuerystring from './fast-querystring.mts'
 import hono from './hono.mts'
 ;[
@@ -36,6 +37,17 @@ import hono from './hono.mts'
   group(JSON.stringify(data), () => {
     bench('hono', () => hono(url, key))
     bench('fastQuerystring', () => fastQuerystring(url, key))
+    bench('URLSearchParams', () => {
+      const params = new URLSearchParams(getQueryStrings(url))
+      if (key) {
+        return params.get(key)
+      }
+      const obj = {}
+      for (const [k, v] of params) {
+        obj[k] = v
+      }
+      return obj
+    })
   })
 })
 
% npm run bench:node

> bench:node
> tsx ./src/bench.mts

(node:19644) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`:
--import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("file%3A///Users/taku/src/github.com/honojs/hono/benchmarks/query-param/node_modules/tsx/dist/loader.js", pathToFileURL("./"));'
(Use `node --trace-warnings ...` to show where the warning was created)
cpu: Apple M2 Pro
runtime: node v20.14.0 (arm64-darwin)

benchmark            time (avg)             (min … max)       p75       p99      p995
------------------------------------------------------- -----------------------------
• {"url":"http://example.com/?page=1","key":"page"}
------------------------------------------------------- -----------------------------
hono              55.29 ns/iter   (48.29 ns … 72.54 ns)  56.93 ns  64.82 ns  67.65 ns
fastQuerystring   72.86 ns/iter    (67.3 ns … 83.71 ns)  74.87 ns  82.22 ns  82.56 ns
URLSearchParams   87.86 ns/iter  (81.46 ns … 104.03 ns)  89.55 ns  96.67 ns     98 ns

summary for {"url":"http://example.com/?page=1","key":"page"}
  hono
   1.32x faster than fastQuerystring
   1.59x faster than URLSearchParams

• {"url":"http://example.com/?url=http://example.com&page=1","key":"page"}
------------------------------------------------------- -----------------------------
hono              77.51 ns/iter   (70.67 ns … 92.51 ns)  78.74 ns  87.02 ns  89.45 ns
fastQuerystring  206.31 ns/iter (196.72 ns … 227.85 ns)  207.4 ns 226.08 ns 226.62 ns
URLSearchParams  235.62 ns/iter (227.06 ns … 279.42 ns) 239.84 ns 258.11 ns 264.34 ns

summary for {"url":"http://example.com/?url=http://example.com&page=1","key":"page"}
  hono
   2.66x faster than fastQuerystring
   3.04x faster than URLSearchParams

• {"url":"http://example.com/?page=1"}
------------------------------------------------------- -----------------------------
hono              97.54 ns/iter  (89.05 ns … 113.99 ns) 100.43 ns  111.7 ns 113.39 ns
fastQuerystring   73.91 ns/iter   (70.23 ns … 85.17 ns)  76.53 ns  80.67 ns  81.42 ns
URLSearchParams  138.78 ns/iter (130.23 ns … 155.33 ns) 140.43 ns 152.97 ns  154.7 ns

summary for {"url":"http://example.com/?page=1"}
  fastQuerystring
   1.32x faster than hono
   1.88x faster than URLSearchParams

• {"url":"http://example.com/?url=http://example.com&page=1"}
------------------------------------------------------- -----------------------------
hono             174.13 ns/iter (165.96 ns … 184.61 ns) 175.66 ns 180.56 ns 181.09 ns
fastQuerystring  205.18 ns/iter  (197.78 ns … 223.4 ns) 205.85 ns 219.93 ns 222.64 ns
URLSearchParams  329.48 ns/iter  (314.24 ns … 351.4 ns) 334.73 ns 348.99 ns  351.4 ns

summary for {"url":"http://example.com/?url=http://example.com&page=1"}
  hono
   1.18x faster than fastQuerystring
   1.89x faster than URLSearchParams

• {"url":"http://example.com/?url=http://example.com/very/very/deep/path/to/something&search=very-long-search-string"}
------------------------------------------------------- -----------------------------
hono             214.94 ns/iter  (201.22 ns … 245.8 ns) 220.49 ns 242.58 ns 244.95 ns
fastQuerystring  410.29 ns/iter (402.25 ns … 462.79 ns) 410.93 ns 446.06 ns 462.79 ns
URLSearchParams  556.61 ns/iter (539.25 ns … 591.04 ns) 567.25 ns  587.7 ns 591.04 ns

summary for {"url":"http://example.com/?url=http://example.com/very/very/deep/path/to/something&search=very-long-search-string"}
  hono
   1.91x faster than fastQuerystring
   2.59x faster than URLSearchParams

• {"url":"http://example.com/?search=Hono+is+a+small,+simple,+and+ultrafast+web+framework+for+the+Edge.&page=1"}
------------------------------------------------------- -----------------------------
hono               3.21 µs/iter     (3.18 µs … 3.29 µs)   3.22 µs   3.29 µs   3.29 µs
fastQuerystring    3.31 µs/iter      (3.25 µs … 3.4 µs)   3.39 µs    3.4 µs    3.4 µs
URLSearchParams  723.09 ns/iter (680.95 ns … 783.58 ns) 740.12 ns 783.58 ns 783.58 ns

summary for {"url":"http://example.com/?search=Hono+is+a+small,+simple,+and+ultrafast+web+framework+for+the+Edge.&page=1"}
  URLSearchParams
   4.44x faster than hono
   4.58x faster than fastQuerystring

• {"url":"http://example.com/?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10"}
------------------------------------------------------- -----------------------------
hono             415.17 ns/iter (400.33 ns … 434.02 ns) 417.63 ns 433.17 ns 434.02 ns
fastQuerystring  395.34 ns/iter (382.32 ns … 426.68 ns) 398.51 ns 411.61 ns 426.68 ns
URLSearchParams  587.83 ns/iter (575.63 ns … 618.53 ns) 594.34 ns 618.53 ns 618.53 ns

summary for {"url":"http://example.com/?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10"}
  fastQuerystring
   1.05x faster than hono
   1.49x faster than URLSearchParams
% npm run bench:bun

> bench:bun
> bun run ./src/bench.mts

cpu: Apple M2 Pro
runtime: bun 1.1.30 (arm64-darwin)

benchmark            time (avg)             (min … max)       p75       p99      p995
------------------------------------------------------- -----------------------------
• {"url":"http://example.com/?page=1","key":"page"}
------------------------------------------------------- -----------------------------
hono              56.17 ns/iter  (50.86 ns … 144.73 ns)  56.02 ns  93.92 ns  102.5 ns
fastQuerystring   88.13 ns/iter   (80.9 ns … 158.18 ns)  88.23 ns 136.54 ns 144.22 ns
URLSearchParams  300.22 ns/iter (238.77 ns … 722.29 ns)  311.5 ns 655.42 ns 722.29 ns

summary for {"url":"http://example.com/?page=1","key":"page"}
  hono
   1.57x faster than fastQuerystring
   5.34x faster than URLSearchParams

• {"url":"http://example.com/?url=http://example.com&page=1","key":"page"}
------------------------------------------------------- -----------------------------
hono             105.29 ns/iter  (97.54 ns … 239.88 ns) 105.29 ns 147.07 ns 154.31 ns
fastQuerystring  150.95 ns/iter (139.22 ns … 230.01 ns) 152.44 ns 199.83 ns 217.17 ns
URLSearchParams  521.94 ns/iter   (435.99 ns … 1.89 µs)    516 ns   1.11 µs   1.89 µs

summary for {"url":"http://example.com/?url=http://example.com&page=1","key":"page"}
  hono
   1.43x faster than fastQuerystring
   4.96x faster than URLSearchParams

• {"url":"http://example.com/?page=1"}
------------------------------------------------------- -----------------------------
hono              92.67 ns/iter  (85.98 ns … 182.63 ns)  92.57 ns 136.81 ns 140.65 ns
fastQuerystring   85.43 ns/iter  (79.54 ns … 141.13 ns)  86.14 ns 131.65 ns  134.6 ns
URLSearchParams  534.39 ns/iter  (428.79 ns … 699.6 ns) 577.35 ns 690.42 ns  699.6 ns

summary for {"url":"http://example.com/?page=1"}
  fastQuerystring
   1.08x faster than hono
   6.26x faster than URLSearchParams

• {"url":"http://example.com/?url=http://example.com&page=1"}
------------------------------------------------------- -----------------------------
hono             174.34 ns/iter (162.04 ns … 285.48 ns) 175.02 ns 215.94 ns 219.05 ns
fastQuerystring  152.69 ns/iter (140.99 ns … 205.06 ns) 154.36 ns 199.17 ns  200.7 ns
URLSearchParams  832.56 ns/iter   (717.03 ns … 1.38 µs) 863.65 ns   1.38 µs   1.38 µs

summary for {"url":"http://example.com/?url=http://example.com&page=1"}
  fastQuerystring
   1.14x faster than hono
   5.45x faster than URLSearchParams

• {"url":"http://example.com/?url=http://example.com/very/very/deep/path/to/something&search=very-long-search-string"}
------------------------------------------------------- -----------------------------
hono             213.67 ns/iter (200.91 ns … 277.14 ns) 215.71 ns  258.9 ns 259.39 ns
fastQuerystring  236.22 ns/iter (221.84 ns … 293.94 ns) 237.02 ns 281.23 ns 284.47 ns
URLSearchParams    1.05 µs/iter   (967.05 ns … 1.18 µs)   1.07 µs   1.18 µs   1.18 µs

summary for {"url":"http://example.com/?url=http://example.com/very/very/deep/path/to/something&search=very-long-search-string"}
  hono
   1.11x faster than fastQuerystring
   4.89x faster than URLSearchParams

• {"url":"http://example.com/?search=Hono+is+a+small,+simple,+and+ultrafast+web+framework+for+the+Edge.&page=1"}
------------------------------------------------------- -----------------------------
hono             599.99 ns/iter (539.21 ns … 758.78 ns) 612.83 ns 758.78 ns 758.78 ns
fastQuerystring  498.18 ns/iter (479.87 ns … 620.67 ns) 497.73 ns 554.57 ns 620.67 ns
URLSearchParams  947.22 ns/iter   (833.05 ns … 1.11 µs) 998.14 ns   1.11 µs   1.11 µs

summary for {"url":"http://example.com/?search=Hono+is+a+small,+simple,+and+ultrafast+web+framework+for+the+Edge.&page=1"}
  fastQuerystring
   1.2x faster than hono
   1.9x faster than URLSearchParams

• {"url":"http://example.com/?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10"}
------------------------------------------------------- -----------------------------
hono             318.32 ns/iter (302.19 ns … 389.28 ns) 320.43 ns 369.13 ns 389.28 ns
fastQuerystring  219.94 ns/iter (206.72 ns … 288.48 ns) 224.32 ns 271.62 ns 274.78 ns
URLSearchParams    2.09 µs/iter     (1.97 µs … 2.24 µs)   2.14 µs   2.24 µs   2.24 µs

summary for {"url":"http://example.com/?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10"}
  fastQuerystring
   1.45x faster than hono
   9.5x faster than URLSearchParams

@usualoma
Copy link
Member

Is it an app that works in a persistent environment?

From the benchmark mentioned above, we can see that using URLSearchParams takes several times longer. However, in that case, it is only a few hundred nanoseconds.
If this change reduces the code by 600 bytes, it will reduce the time it takes for the runtime to parse the code. This will vary depending on the environment, but I think there is a good chance that it will be a reduction of more than a few hundred nanoseconds.
Therefore, if the code is parsed each time it is called (which is likely to be the case in some cloud runtime environments), I think it is more appropriate to use the code that uses URLSearchParams.

@usualoma
Copy link
Member

As you mentioned in your comment, there is a trade-off between “small” and “fast” (and “small causes fast " complicates the problem), so I think it's a difficult point.

When using c.req. query()`, there is often corresponding backend processing (accessing the DB, proxying to the outside), so I don't think optimizing a few hundred nanoseconds here is very meaningful in a real-world production app.

The results of specific benchmarks will be worse, so we have to think about that.

As an alternative, I think there is also the option of “using URLSearchParams when using the hono/tiny preset.” The maintenance cost will be a little higher, though.

@usualoma
Copy link
Member

@yusukebe
It's difficult to evaluate the trade-offs, but overall, I agree with the changes in this pull request!

@nakasyou
Copy link
Contributor

How about that code?:

import { Hono } from 'hono/hono-base'
import { FastURLSearchParams } from 'hono/...'

const app = new Hono({
  URLSearchParams: URLSearchParams
})

and implementing using URLSearchParams when using the hono/tiny preset like @usualoma said is easier.

@yusukebe
Copy link
Member Author

yusukebe commented Oct 30, 2024

@usualoma Thank you for the comments!

Is it an app that works in a persistent environment?

As you know, Hono has to support both "one-time" and "persistent" apps.

For example, each popular environment has a different life cycle:

  • Node.js/Bun on Fly.io - Once it starts, it's up and running forever.
  • Cloudflare Workers - Once it starts, it stays alive for a while, but dies soon after.
  • Fastly Compute - Once it starts, it dies immediately.

The following benchmarks for Cloudflare Workers are of interest. The smaller file size is faster.

https://github.com/TigersWay/cloudflare-playground

In that benchmark, the application receives one access every 60 seconds, which is not a lot. My guess is that once the access is handled, the application shuts down. And then it starts anew for the next access. In a real-world application, it will stay running and Hono will perform better because it handles more accesses than once every 60 seconds.

However, many people make judgments based on benchmark results, so it is also important to be fast, even "one-time".

When using c.req. query()`, there is often corresponding backend processing (accessing the DB, proxying to the outside), so I don't think optimizing a few hundred nanoseconds here is very meaningful in a real-world production app.

On the other hand, there is this way of thinking about it.

For example, if you use a Hono app as a proxy server, as in the Issue #3518 , the code is short. However, even though you are not using c.req.query(), the code to parse queries is included. I would like to avoid having code for other functions which is not used in your apps.

As an alternative, I think there is also the option of “using URLSearchParams when using the hono/tiny preset.” The maintenance cost will be a little higher, though.

This is interesting! It is truly tiny. We can consider it together with @nakasyou's opinion, but it would certainly complicate the code.


Either way, I believe this PR is effective for now. But there is no need to rush!

@yusukebe
Copy link
Member Author

yusukebe commented Oct 30, 2024

In this case, the important point is that URLSearchParams is a runtime API. So, if the performance of the runtime implementation improves, the performance of Hono will also improve. In fact, the runtime-sides are working hard to do it.

@rafaell-lycan
Copy link

rafaell-lycan commented Nov 1, 2024

@yusukebe from what I see it makes sense to ship the fastest (default) with Hono preset and change it to URLSearchParams when using a different preset such as hono/tiny as mentioned by @usualoma

Comparing Hono with Express is unfair, but one of the reasons why we've decided to use it instead of Fastify was its performance. I'm not asking to change its DNA or roots, but users might need to be aware of the trade-off.

@yusukebe
Copy link
Member Author

yusukebe commented Nov 3, 2024

Hi @rafaell-lycan

Thank you for the suggestion and your opinion!

Comparing Hono with Express is unfair, but one of the reasons why we've decided to use it instead of Fastify was its performance. I'm not asking to change its DNA or roots, but users might need to be aware of the trade-off.

You may saying right thing for us. We have to focus on speed.

The only way to achieve both is by taking the idea to make hono/tiny using URLSearchParams. However, that would complicate the code a bit. I think putting this issue aside for now is better.

@bompi88
Copy link

bompi88 commented Nov 14, 2024

One consideration: it could be useful to provide an option for overriding the parseQuery method. For example, this would allow for the use of alternative parsers like qs, if needed. A performant and sensible default would, of course, be ideal.

I added an issue #3667 right before I saw this PR.

@yusukebe
Copy link
Member Author

Hi @bompi88

By the way, the qs is slower than the current Hono's query parsing method: #3674

Do you have any motivation to use the qs?

@EdamAme-x
Copy link
Contributor

EdamAme-x commented Nov 15, 2024

I like the idea of having URLSearchParams only on hono/tiny.

@bompi88
Copy link

bompi88 commented Nov 15, 2024

@yusukebe My motivation is that I want to migrate my current APIs to Hono :) I'm using a special query "syntax" which is supported by qs. For example I can do ?amount[lte]=300 which resolves to:

{
  amount: {
    lte: 300
  }
}

If there is another way to do this, I'm happy to ditch qs.

@yusukebe
Copy link
Member Author

@bompi88

I'm using a special query "syntax" which is supported by qs.

I see! But it's not good that the API will be changed depending on the implementation, such as Hono's current logic, URLSearchParams, or qs. If you want the feature, we should add it as a Hono feature, though it introduces a breaking change. Can you create an issue for the feature request?

@yusukebe
Copy link
Member Author

@bompi88

Sorry, I missed #3667. Let's discuss in it.

@bompi88 bompi88 mentioned this pull request Nov 15, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants