Title: Experimental, optimized route dispatch code
Let's start with our module declaration and imports.
> module Yesod.Routes.Dispatch
> ( Piece (..)
> , Route (..)
> , Dispatch
> , toDispatch
> ) where
>
> import Data.Text (Text)
> import qualified Data.Vector as V
> import Data.Maybe (fromMaybe, mapMaybe)
> import qualified Data.Map as Map
> import Data.List (sortBy)
> import Data.Ord (comparing)
> import Control.Arrow (second)
> import Control.Exception (assert)
This module provides an efficient routing system. The code is pure, requires no
fancy extensions, has no Template Haskell involved and is not Yesod specific.
It does, however, assume a routing system similar to that of Yesod.
Routing works based on splitting up a path into its components. This is handled
very well by both the web-routes and http-types packages, and this module does
not duplicate that functionality. Instead, it assumes that the requested path
will be provided as a list of 'Text's.
A route will be specified by a list of pieces (using the 'Piece' datatype).
> data Piece = Static Text | Dynamic
Each piece is either a static piece- which is required to match a component of
the path precisely- or a dynamic piece, which will match any component.
Additionally, a route can optionally match all remaining components in the
path, or fail if extra components exist.
Usually, the behavior of dynamic is not what you really want. Often times, you
will want to match integers, or slugs, or some other limited format. This
brings us nicely to the dispatch function. Each route provides a function of
type:
> type Dispatch res = [Text] -> Maybe res
The res argument is application-specific. For example, in a simple
WAI application, it could be the Application datatype. The important
thing to point out about Dispatch is that is takes a list of 'Text's and
returns its response in a Maybe. This gives you a chance to have
finer-grained control over how individual components are parsed. If you don't
want to deal with it, you return 'Nothing' and routing continues.
Note: You do *not* need to perform any checking on your static pieces, this
module handles that for you automatically.
So each route is specified by:
> data Route res = Route
> { rhPieces :: [Piece]
> , rhHasMulti :: Bool
> , rhDispatch :: Dispatch res
> }
Your application needs to provide this module with a list of routes, and then
this module will give you back a new dispatch function. In other words:
> toDispatch :: [Route res] -> Dispatch res
> toDispatch rhs =
> bcToDispatch bc
> where
> bc = toBC rhs
In addition to the requirements listed above for routing, we add one extra
rule: your specified list of routes is treated as ordered, with the earlier
ones matching first. If you have an overlap between two routes, the first one
will be dispatched.
The simplest approach would be to loop through all of your routes and compare
against the path components. But this has linear complexity. Many existing
frameworks (Rails and Django at least) have such algorithms, usually based on
regular expressions. But we can provide two optimizations:
* Break up routes based on how many components they can match. We can then
select which group of routes to continue testing. This lookup runs in
constant time.
* Use a Map to reduce string comparisons for each route to logarithmic
complexity.
Let's start with the first one. Each route has a fixed number of pieces. Let's
call this *n*. If that route can also match trailing components (rhHasMulti
above), then it will match *n* and up. Otherwise, it will match specifically on
*n*.
If *max(n)* is the maximum value of *n* for all routes, what we need is
(*max(n)* + 2) groups: a zero group (matching a request for the root of the
application), 1 - *max(n)* groups, and a final extra group containing all
routes that can match more than *max(n)* components. This group will consist of
all the routes with rhHasMulti, and only those routes.
> data ByCount res = ByCount
> { bcVector :: !(V.Vector (PieceMap res))
> , bcRest :: !(PieceMap res)
> }
We haven't covered PieceMap yet; it is used for the second optimization. We'll
discuss it below.
The following function breaks up a list of routes into groups. Again, please
ignore the PieceMap references for the moment.
> toBC :: [Route res] -> ByCount res
> toBC rhs =
> ByCount
> { bcVector = groups
> , bcRest = allMultis
> }
> where
Determine the value of *max(n)*.
> maxLen
> | null rhs = 0
> | otherwise = maximum $ map (length . rhPieces) rhs
Get the list of all routes which can have multis. This will make up the *rest*
group.
> allMultis = toPieceMap maxLen $ filter rhHasMulti rhs
And now get all the numbered groups. For each group, we need to get all routes
with *n* components, __and__ all routes with less than *n* components and that
have rhHasMulti set to True.
> groups = V.map group $ V.enumFromN 0 (maxLen + 1)
> group i = toPieceMap i $ filter (canHaveLength i) rhs
>
> canHaveLength :: Int -> Route res -> Bool
> canHaveLength i rh =
> len == i || (len < i && rhHasMulti rh)
> where
> len = length $ rhPieces rh
Next we'll set up our routing by maps. What we need is a bunch of nested Maps.
For example, if we have the following routings:
/foo/bar/1
/foo/baz/2
We would want something that looks vaguely like:
/foo
/bar
/1
/baz
/2
But there's an added complication: we need to deal with dynamic compnents and HasMulti as well. So what we'd really have is routes looking like:
/foo/bar/1
/foo/baz/2
/*dynamic*/bin/3
/multi/*bunch of multis*
We can actually simplify away the multi business. Remember that for each group,
we will have a fixed number of components to match. In the list above, it's
three. Even though the last route only has one component, we can actually just
fill up the missing components with *dynamic*, which will give the same result
for routing. In other words, we'll treat it as:
/foo
/bar
/1
/baz
/2
/*dynamic*
/bin
/3
/multi
/*dynamic*
/*dynamic*
What we need is then two extra features on our datatype:
* Support both a 'Map Text PieceMap' for static pieces, and a general
'PieceMap' for all dynamic pieces.
* An extra constructive after we've gone three levels deep, to provide all
matching routes.
What we end up with is:
> data PieceMap res = PieceMap
> { pmDynamic :: PieceMap res
> , pmStatic :: Map.Map Text (PieceMap res)
> } | PieceMapEnd [(Int, Dispatch res)]
Note that the PieceMapEnd is a list of pairs, including an Int. Since the map
process will confuse the original order of our routes, we need some way to get
that back to make sure overlapping is handled correctly.
We'll need two pieces of information to make a PieceMap: the depth to drill
down to, and the routes in the current group. We'll immediately zip up those
routes with an Int to indicate route priority.
> toPieceMap :: Int -> [Route res] -> PieceMap res
> toPieceMap depth = toPieceMap' depth . zip [1..]
>
> toPieceMap' :: Int
> -> [(Int, Route res)]
> -> PieceMap res
The stopping case: we've exhausted the full depth, so let's put together a
PieceMapEnd. Technically speaking, the sort here is unnecessary, since we'll
sort again later. However, that second sorting occurs during each dispatch
occurrence, whereas this sorting only occurs once, in the initial construction
of the PieceMap. Therefore, we presort here.
> toPieceMap' 0 rhs =
> PieceMapEnd $ map (second rhDispatch)
> $ sortBy (comparing fst) rhs
Note also that we apply rhDispatch to the route. We are no longer interested in
the rest of the route information, so it can be discarded.
Now the heart of this algorithm: we construct the pmDynamic and pmStatic
records. For both, we recursively call toPieceMap' again, with the depth
knocked down by 1.
> toPieceMap' depth rhs = PieceMap
> { pmDynamic = toPieceMap' depth' dynamics
> , pmStatic = Map.map (toPieceMap' depth') statics
> }
> where
> depth' = depth - 1
We turn our list of routes into a list of pairs. The first item in the pair
gives the next piece, and the second gives the route again, minus that piece.
> pairs = map toPair rhs
> toPair (i, Route (p:ps) b c) = (p, (i, Route ps b c))
And as we mentioned above, for multi pieces we fill in the remaining pieces
with Dynamic.
> toPair (i, Route [] b c) = assert b (Dynamic, (i, Route [] b c))
Next, we break up our list of dynamics.
> getDynamic (Dynamic, rh) = Just rh
> getDynamic _ = Nothing
> dynamics = mapMaybe getDynamic pairs
And now we make a Map for statics. Note that Map.fromList would not be
appropriate here, since it would only keep one route per Text.
> getStatic (Static t, rh) = Just $ Map.singleton t [rh]
> getStatic _ = Nothing
> statics = Map.unionsWith (++) $ mapMaybe getStatic pairs
The time has come to actually dispatch.
> bcToDispatch :: ByCount res -> Dispatch res
> bcToDispatch (ByCount vec rest) ts0 =
> bcToDispatch' ts0 pm0
> where
Get the PieceMap for the appropriate group. If the length of the requested path
is greater than *max(n)*, then use the "rest" group.
> pm0 = fromMaybe rest $ vec V.!? length ts0
Stopping case: we've found our list of routes. Sort them, then starting
applying their dispatch functions. If the first one returns Nothing, go to the
next, and so on.
> bcToDispatch' _ (PieceMapEnd r) = firstJust (\f -> f ts0) $ map snd r
For each component, get the static PieceMap and the dynamic one, combine them
together, and then continue dispatching.
> bcToDispatch' (t:ts) (PieceMap dyn sta) = bcToDispatch' ts $
> case Map.lookup t sta of
> Nothing -> dyn
> Just pm -> append dyn pm
Handle an impossible case that should never happen.
> bcToDispatch' [] _ = assert False Nothing
Helper function: get the first Just response.
> firstJust :: (a -> Maybe b) -> [a] -> Maybe b
> firstJust _ [] = Nothing
> firstJust f (a:as) = maybe (firstJust f as) Just $ f a
Combine two PieceMaps together.
> append :: PieceMap res -> PieceMap res -> PieceMap res
At the end, just combine the list of routes. But we combine them in such a way
so as to preserve their order. Since a and b come presorted (as mentioned
above), we can just merge the two lists together in linear time.
> append (PieceMapEnd a) (PieceMapEnd b) = PieceMapEnd $ merge a b
Combine the dynamic and static portions of the maps.
> append (PieceMap a x) (PieceMap b y) =
> PieceMap (append a b) (Map.unionWith append x y)
An impossible case.
> append _ _ = assert False $ PieceMapEnd []
Our O(n) merge.
> merge :: Ord a => [(a, b)] -> [(a, b)] -> [(a, b)]
> merge x [] = x
> merge [] y = y
> merge x@(a@(ai, _):xs) y@(b@(bi, _):ys)
> | ai < bi = a : merge xs y
> | otherwise = b : merge x ys