소스 검색

Route every post at its default href by one template

Each post gave the routing table a route of its own, and _routes gave
each of those a /secure copy. A site with tens of thousands of posts
then gave tPSGI an OpenAPI document with tens of thousands of paths.
tPSGI checks that document against the OpenAPI schema when a worker
starts. The check costs about 6.6 ms and 36 KB for each path, so on
such a site each worker spent minutes and gigabytes on it, and never
answered its first request before the machine ran out of memory.

Almost all of those routes are the same operation: a GET of
/posts/<id> that Trog::Routes::HTML::posts answers, and posts() finds
the post by the id in the path. So _routes now drops those routes and
adds one template, /posts/{id}, and its /secure copy. On the site
above the document has 81 paths, and the router builds in 0.3 s. A
route under /posts that another callback answers, and a post at an
href of its own, keep their routes. An alias under /posts still
reaches its post, because tPSGI rewrites an alias before it routes.

The template names its callback as a string, as the data routes do.
The scripts and the inputs of a route are looked up by that name, and
a reference names whatever sub is there when it is taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
troglodyne-bot 2 일 전
부모
커밋
24a4eacf06
3개의 변경된 파일과 44개의 추가작업 그리고 21개의 파일을 삭제
  1. 6 0
      design.md
  2. 12 2
      lib/TCMS.pm
  3. 26 19
      t/TCMS.t

+ 6 - 0
design.md

@@ -75,6 +75,12 @@ into the OpenAPI 3 document that tPSGI routes by. Its flags, such as `auth` and
 `$query->{dispatcher}`. The path of a post cannot hold a `{` or a `}`, because
 OpenAPI has no way to escape them.
 
+A post at the href that every post gets by default, `/posts/<id>`, has no route
+of its own. One template, `/posts/{id}`, routes all of them to
+`Trog::Routes::HTML::posts`, which finds the post by the id in its path. tPSGI
+checks the document a route at a time, and a site can have tens of thousands of
+posts. A post at any other href keeps its own route.
+
 What a request may send is declared, and tPSGI refuses anything else with a 400.
 `%inputs` in `Trog::Routes::HTML` names what each callback takes: the query
 of a GET and the form of a POST.  `build_routes` declares that for every route of

+ 12 - 2
lib/TCMS.pm

@@ -32,6 +32,11 @@ use Trog::Data;
 use Trog::Themes;
 use Trog::Bundle;
 
+# The data of a route names its callback, and the code of a route holds a reference to it.
+my sub _callback_name ($callback) {
+    return ref $callback ? Sub::Util::subname($callback) : $callback // '';
+}
+
 #XXX Return a clone of the routing table ref, because code modifies it later
 my sub _routes ( $data = {} ) {
     state %routes;
@@ -50,6 +55,12 @@ my sub _routes ( $data = {} ) {
         delete $roots{$href};
     }
 
+    # One template routes every post at the href that a post gets by default,
+    # because tPSGI checks a route at a time, and a site has thousands of posts.
+    my @posts = grep { m{\A/posts/[^/]+\z} && _callback_name( $roots{$_}{callback} ) eq 'Trog::Routes::HTML::posts' } keys(%roots);
+    delete @roots{@posts};
+    $roots{'/posts/{id}'} = { method => 'GET', callback => 'Trog::Routes::HTML::posts' };
+
     # Setup the /secure/... versions for logged in users, except for already secured routes.
     foreach my $route ( keys(%roots) ) {
         next if index( $route, '/secure/' ) == 0;
@@ -66,8 +77,7 @@ my sub _routes ( $data = {} ) {
 
     # A page of posts gets the scripts of one, unless its route names its own.
     foreach my $path ( keys(%routes) ) {
-        my $callback = $routes{$path}{callback} // '';
-        my $name     = ref $callback ? Sub::Util::subname($callback) : $callback;
+        my $name = _callback_name( $routes{$path}{callback} );
         next if exists $routes{$path}{scripts} || !List::Util::any { $_ eq $name } @Trog::Routes::HTML::post_page_callbacks;
         $routes{$path} = { %Trog::Routes::HTML::post_page_scripts, %{ $routes{$path} } };
     }

+ 26 - 19
t/TCMS.t

@@ -52,8 +52,11 @@ my $index = Test::MockModule->new('Trog::SQLite::TagIndex');
 $index->redefine(
     routes => sub {
         return (
-            '/posts/p-one'   => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
-            '/posts/{curly}' => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
+            '/posts/p-one'    => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
+            '/posts/p-two'    => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
+            '/posts/p-series' => { method => 'GET', callback => 'Trog::Routes::HTML::series' },
+            '/p-elsewhere'    => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
+            '/posts/{curly}'  => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
         );
     }
 );
@@ -99,19 +102,23 @@ subtest 'an operation' => sub {
 };
 
 subtest 'the routes of posts' => sub {
-    is( ref $paths->{'/posts/p-one'}{get}{'x-tpsgi-callback'}, 'CODE', 'a post named by its callback is routed' );
-    ok( $paths->{'/secure/posts/p-one'}{get}, 'and so is its /secure copy' );
+    is( ref $paths->{'/posts/{id}'}{get}{'x-tpsgi-callback'}, 'CODE', 'the posts at the href a post gets by default are routed by one template' );
+    ok( $paths->{'/secure/posts/{id}'}{get}, 'and so is its /secure copy' );
+    is_deeply( [ grep { m{/posts/p-[ot]} } keys(%$paths) ], [], 'and none of them has a route of its own' );
+    ok( $paths->{'/posts/p-series'}{get},     'but a path there that another callback answers keeps its route' );
+    ok( $paths->{'/p-elsewhere'}{get},        'as does a post at an href of its own' );
+    ok( $paths->{'/secure/p-elsewhere'}{get}, 'and its /secure copy' );
 
     # The flag that says setup is done, or the default route answers instead.
     open( my $fh, '>', 'config/setup' ) or die $!;
     close($fh)                          or die $!;
-    my $res = $paths->{'/posts/p-one'}{get}{'x-tpsgi-callback'}->( { log_dir => "$ROOT/logs", verbose => 0 }, { route => '/posts/p-one', dispatcher => $paths->{'/posts/p-one'}{get} } );
+    my $res = $paths->{'/posts/{id}'}{get}{'x-tpsgi-callback'}->( { log_dir => "$ROOT/logs", verbose => 0 }, { route => '/posts/p-one', dispatcher => $paths->{'/posts/{id}'}{get} } );
     is_deeply( $res->[2], ['the post'], 'and it runs the sub that the name names' );
     ok( !exists $paths->{'/posts/{curly}'}, 'but a post whose path has a brace is not, since it would be a template' );
 };
 
 subtest 'where a redirect may send you' => sub {
-    my $operation = $paths->{'/posts/p-one'}{get};
+    my $operation = $paths->{'/posts/{id}'}{get};
     my $to        = sub ($to) {
         return $operation->{'x-tpsgi-callback'}->( { log_dir => "$ROOT/logs", verbose => 0 }, { route => '/posts/p-one', dispatcher => $operation, input => { to => $to } } )->[2][0];
     };
@@ -123,10 +130,10 @@ subtest 'where a redirect may send you' => sub {
 };
 
 subtest 'the scripts of a route' => sub {
-    my $post = $paths->{'/posts/p-one'}{get};
-    is_deeply( $post->{'x-tcms-scripts'},                              [qw{highlight.min.js reveal.min.js}], 'a post page has the scripts of one' );
-    is_deeply( $post->{'x-tcms-authenticated_scripts'},                [qw{post.js post_relations.js}],      'and the editing scripts for a reader who is logged in' );
-    is_deeply( $paths->{'/secure/posts/p-one'}{get}{'x-tcms-scripts'}, $post->{'x-tcms-scripts'},            'and so does its /secure copy' );
+    my $post = $paths->{'/posts/{id}'}{get};
+    is_deeply( $post->{'x-tcms-scripts'},                             [qw{highlight.min.js reveal.min.js}], 'a post page has the scripts of one' );
+    is_deeply( $post->{'x-tcms-authenticated_scripts'},               [qw{post.js post_relations.js}],      'and the editing scripts for a reader who is logged in' );
+    is_deeply( $paths->{'/secure/posts/{id}'}{get}{'x-tcms-scripts'}, $post->{'x-tcms-scripts'},            'and so does its /secure copy' );
 
     is_deeply( $paths->{'/metrics'}{get}{'x-tcms-scripts'}, ['chart.min.js'], 'a route that names its own has those' );
     ok( !exists $paths->{'/metrics'}{get}{'x-tcms-authenticated_scripts'}, 'and no others' );
@@ -141,11 +148,11 @@ subtest 'the scripts of a route' => sub {
 
     local @Theme::scripts = qw{theme.js};
     my $themed = { TCMS::build_routes() }->{paths};
-    is_deeply( $themed->{'/login'}{get}{'x-tcms-scripts'},                     [qw{theme.js}],                                "a theme's scripts go on a route that names none" );
-    is_deeply( $themed->{'/metrics'}{get}{'x-tcms-scripts'},                   [qw{theme.js chart.min.js}],                   "and before the scripts of a route that names its own" );
-    is_deeply( $themed->{'/posts/p-one'}{get}{'x-tcms-scripts'},               [qw{theme.js highlight.min.js reveal.min.js}], "and before the scripts of a post page" );
-    is_deeply( $themed->{'/secure/posts/p-one'}{get}{'x-tcms-scripts'},        [qw{theme.js highlight.min.js reveal.min.js}], "once, on a /secure copy that shares the route of its post" );
-    is_deeply( $themed->{'/posts/p-one'}{get}{'x-tcms-authenticated_scripts'}, [qw{post.js post_relations.js}],               "and not among the scripts for a reader who is logged in, who gets both lists" );
+    is_deeply( $themed->{'/login'}{get}{'x-tcms-scripts'},                    [qw{theme.js}],                                "a theme's scripts go on a route that names none" );
+    is_deeply( $themed->{'/metrics'}{get}{'x-tcms-scripts'},                  [qw{theme.js chart.min.js}],                   "and before the scripts of a route that names its own" );
+    is_deeply( $themed->{'/posts/{id}'}{get}{'x-tcms-scripts'},               [qw{theme.js highlight.min.js reveal.min.js}], "and before the scripts of a post page" );
+    is_deeply( $themed->{'/secure/posts/{id}'}{get}{'x-tcms-scripts'},        [qw{theme.js highlight.min.js reveal.min.js}], "once, on a /secure copy that shares the route of its post" );
+    is_deeply( $themed->{'/posts/{id}'}{get}{'x-tcms-authenticated_scripts'}, [qw{post.js post_relations.js}],               "and not among the scripts for a reader who is logged in, who gets both lists" );
     ok( $Trog::Bundle::declared{'theme+chart.min'}, 'the bundle of a themed route is declared' );
 
     @Theme::scripts = ();
@@ -169,13 +176,13 @@ subtest 'what a route takes from the request' => sub {
 
     my @urchin = qw{utm_campaign utm_content utm_medium utm_source utm_term};
     is_deeply(
-        $names->( $paths->{'/posts/p-one'}{get} ),
+        $names->( $paths->{'/posts/{id}'}{get} ),
         [ sort qw{like page limit version older newer format embed nochrome year month}, @urchin ],
         'a page of posts takes its paging, search, feed and embed, and a campaign'
     );
-    is_deeply( $names->( $paths->{'/humans.txt'}{get} ), $names->( $paths->{'/posts/p-one'}{get} ), 'and so does a route of posts that fixes its tag' );
-    is_deeply( $names->( $paths->{'/search'}{get} ),     $names->( $paths->{'/posts/p-one'}{get} ), 'and so does the search, which is a page of posts' );
-    is_deeply( $names->( $paths->{'/totp'}{get} ),       \@urchin,                                  'and a page that is not one takes no search' );
+    is_deeply( $names->( $paths->{'/humans.txt'}{get} ), $names->( $paths->{'/posts/{id}'}{get} ), 'and so does a route of posts that fixes its tag' );
+    is_deeply( $names->( $paths->{'/search'}{get} ),     $names->( $paths->{'/posts/{id}'}{get} ), 'and so does the search, which is a page of posts' );
+    is_deeply( $names->( $paths->{'/totp'}{get} ),       \@urchin,                                 'and a page that is not one takes no search' );
     ok( !( grep { $_ eq 'map' } @{ $names->( $paths->{'/sitemap/{map}.xml'}{get} ) } ), 'a name the path holds is not one of the query too' );
     is_deeply( $names->( $paths->{'/sitemap/{map}.xml'}{get}, 'path' ), ['map'], 'but of the path' );
     ok( !( grep { $_ eq 'title' || $_ eq 'scripts' || $_ eq 'template' } @{ $names->( $paths->{'/login'}{get} ) } ), 'the login page takes none of the names its page is made of' );