com.linecorp.armeria.server.RoutingTrie Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of armeria-shaded Show documentation
Show all versions of armeria-shaded Show documentation
Asynchronous HTTP/2 RPC/REST client/server library built on top of Java 8, Netty, Thrift and GRPC (armeria-shaded)
/*
* Copyright 2017 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package com.linecorp.armeria.server;
import static com.google.common.base.Preconditions.checkArgument;
import static com.linecorp.armeria.server.RoutingTrie.Node.convertKey;
import static com.linecorp.armeria.server.RoutingTrie.Node.validatePath;
import static java.util.Objects.requireNonNull;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import javax.annotation.Nullable;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.base.MoreObjects.ToStringHelper;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
/**
* Trie implementation to route a request to the
* designated value.
*
* {@link RoutingTrie} uses the character ':' and '*' for special purpose to handle the request path
* efficiently.
*
* - ':' as a path variable like the regular expression of [^/]+
* - '*' as a catch-all like the regular expression of .*
*
* For example,
*
* - "/hello/world" exactly matches the request path
* - "/hello/*" matches every request paths starting with "/hello/"
* - "/hello/:/world" matches the request paths like "/hello/java/world" and "/hello/armeria/world"
* - "/hello/:/world/*" matches the request paths like "/hello/java/world" and
* "/hello/new/world/for/armeria
*
*
* @param Value type of {@link RoutingTrie}.
*/
final class RoutingTrie {
private final Node root;
private RoutingTrie(Node root) {
requireNonNull(root, "root");
this.root = root;
}
/**
* Returns the list of values which is mapped to the given {@code path}.
*/
@Nullable
List find(String path) {
final Node node = findNode(path, false);
return node == null ? ImmutableList.of() : node.values();
}
/**
* Returns a {@link Node} which is mapped to the given {@code path}.
*/
@Nullable
Node findNode(String path) {
return findNode(path, false);
}
/**
* Returns a {@link Node} which is mapped to the given {@code path}.
* If {@code exact} is {@code true}, internally-added node may be returned.
*/
@Nullable
@VisibleForTesting
Node findNode(String path, boolean exact) {
requireNonNull(path, "path");
return findNode(root, path, 0, exact);
}
/**
* Finds a {@link Node} which is mapped to the given {@code path}. It is recursively called by itself
* to visit the children of the given node. Returns {@code null} if there is no {@link Node} to find.
*/
@Nullable
private Node findNode(Node node, String path, int begin, boolean exact) {
final int next;
switch (node.type()) {
case EXACT:
final int len = node.path().length();
if (!path.regionMatches(begin, node.path(), 0, len)) {
// A given path does not start with the path of this node.
return null;
}
if (len == path.length() - begin) {
// Matched. No more input characters.
// If this node is not added by a user, then we should return a catch-all child
// if it exists. But if 'exact' is true, we just return this node to make caller
// have the exact matched node.
return exact || node.hasValues() || !node.hasCatchAllChild() ? node
: node.catchAllChild();
}
next = begin + len;
break;
case PARAMETER:
// Consume characters until the delimiter '/' as a path variable.
final int delim = path.indexOf('/', begin);
if (delim < 0 || path.length() == delim + 1) {
// No more delimiter, or ends with delimiter.
return node;
}
next = delim;
break;
default:
throw new Error("Should not reach here");
}
// The path is not matched to this node, but it is possible to be matched on my children
// because the path starts with the path of this node. So we need to visit children as the
// following sequences:
// - The child which is able to consume the next character of the path.
// - The child which has a path variable.
// - The child which is able to consume every remaining path. (catch-all)
Node child = node.child(path.charAt(next));
if (child != null) {
final Node found = findNode(child, path, next, exact);
if (found != null) {
return found;
}
}
child = node.parameterChild();
if (child != null) {
final Node found = findNode(child, path, next, exact);
if (found != null) {
return found;
}
}
child = node.catchAllChild();
if (child != null) {
return child;
}
return null;
}
public void dump(OutputStream output) {
// Do not close this writer in order to keep output stream open.
final PrintWriter p = new PrintWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
p.printf("Dump of %s:%n", this);
dump(p, root, 0);
p.flush();
}
private void dump(PrintWriter p, Node node, int depth) {
p.printf("<%d> %s%n", depth, node);
node.children().forEach(child -> dump(p, child, depth + 1));
}
/**
* Builds {@link RoutingTrie} with given paths and values.
* This helps to make {@link RoutingTrie} immutable.
*
* @param Value type of {@link RoutingTrie}.
*/
static final class Builder {
private final List> routes = new ArrayList<>();
@Nullable
private Comparator comparator;
/**
* Adds a path and a value to be built as {@link RoutingTrie}.
*
* @param path the path to serve
* @param value the value belonging to the path
*/
Builder add(String path, V value) {
requireNonNull(path, "path");
routes.add(Maps.immutableEntry(path, value));
return this;
}
/**
* Sets a {@link Comparator} to be used to sort values.
*
* @param comparator the comparator to sort values.
*/
Builder comparator(Comparator comparator) {
this.comparator = comparator;
return this;
}
/**
* Builds and returns {@link RoutingTrie} with given paths and values.
*/
RoutingTrie build() {
checkArgument(!routes.isEmpty(), "No routes added");
checkArgument(routes.stream()
.noneMatch(e -> e.getKey().startsWith("*") ||
e.getKey().startsWith(":")),
"A path starting with '*' or ':' is not allowed.");
routes.forEach(e -> validatePath(e.getKey()));
final Node root = insertAndGetRoot(routes.get(0).getKey(), routes.get(0).getValue());
for (int i = 1; i < routes.size(); i++) {
final Entry route = routes.get(i);
addRoute(root, route.getKey(), route.getValue());
}
return new RoutingTrie<>(root);
}
/**
* Adds a new route to the trie.
*/
private void addRoute(Node node, String path, V value) {
Node current = node;
while (true) {
final String p = current.path();
final int max = Math.min(p.length(), path.length());
// Count the number of characters having the same prefix.
int same = 0;
while (same < max && p.charAt(same) == path.charAt(same)) {
same++;
}
// We need to split the current node into two in order to ensure that this node has the
// same part of the path. Assume that the path is "/abb" and this node is "/abc/d".
// This node would be split into "/ab" as a parent and "c/d" as a child.
if (same < p.length()) {
current.split(same);
}
// If the same part is the last part of the path, we need to add the value to this node.
if (same == path.length()) {
current.addValue(value, comparator);
return;
}
// We need to find a child to be able to consume the next character of the path, or need to
// make a new sub trie to manage remaining part of the path.
final char nextChar = convertKey(path.charAt(same));
final Node next = current.child(nextChar);
if (next == null) {
// Insert node.
insertChild(current, path.substring(same), value);
return;
}
current = next;
path = path.substring(same);
}
}
/**
* Inserts the first route and gets the root node of the trie.
*/
private Node insertAndGetRoot(String path, V value) {
Node node = insertChild(null, path, value);
// Only the root node has no parent.
for (;;) {
final Node parent = node.parent();
if (parent == null) {
return node;
}
node = parent;
}
}
/**
* Makes a node and then inserts it to the given node as a child.
*/
private Node insertChild(@Nullable Node node, String path, V value) {
int pathStart = 0;
final int max = path.length();
for (int i = 0; i < max; i++) {
final char c = path.charAt(i);
// Find the prefix until the first wildcard (':' or '*')
if (c != '*' && c != ':') {
continue;
}
if (c == '*' && i + 1 < max) {
throw new IllegalStateException("Catch-all should be the last in the path: " + path);
}
if (i > pathStart) {
node = asChild(new Node<>(node, Type.EXACT, path.substring(pathStart, i)));
}
// Skip this '*' or ':' character.
pathStart = i + 1;
if (c == '*') {
node = asChild(new Node<>(node, Type.CATCH_ALL, "*"));
} else {
node = asChild(new Node<>(node, Type.PARAMETER, ":"));
}
}
// Make a new child node with the remaining characters of the path.
if (pathStart < max) {
node = asChild(new Node<>(node, Type.EXACT, path.substring(pathStart)));
}
// Attach the value to the last node.
assert node != null;
node.addValue(value, comparator);
return node;
}
/**
* Makes the given node as a child.
*/
private Node asChild(Node child) {
final Node parent = child.parent();
return parent == null ? child : parent.addChild(child);
}
}
/**
* Type of {@link Node}.
*/
enum Type {
EXACT, // Specify a path string
PARAMETER, // Specify a path variable
CATCH_ALL // Specify a catch-all
}
static final class Node {
private static final char KEY_PARAMETER = 0x01;
private static final char KEY_CATCH_ALL = 0x02;
// The parent may be changed when this node is split into two.
@Nullable
private Node parent;
private final Type type;
// The path may be changed when this node is split into two.
// But the first character of the path should not be changed even if this node is split.
private String path;
@Nullable
private Map> children;
// Short-cuts to the special-purpose children.
@Nullable
private Node parameterChild;
@Nullable
private Node catchAllChild;
// These values are sorted every time a new value is added.
@Nullable
private List values;
Node(@Nullable Node parent, Type type, String path) {
this.parent = parent;
this.type = requireNonNull(type, "type");
this.path = requireNonNull(path, "path");
}
String path() {
return path;
}
private void path(String path) {
checkArgument(path().charAt(0) == path.charAt(0),
"Not acceptable path for update: " + path);
this.path = path;
}
Type type() {
return type;
}
List values() {
return values == null ? ImmutableList.of() : values;
}
boolean hasValues() {
return values != null;
}
Collection> children() {
return children == null ?
ImmutableList.of() : Collections.unmodifiableCollection(children.values());
}
@Nullable
Node parameterChild() {
return parameterChild;
}
@Nullable
Node catchAllChild() {
return catchAllChild;
}
boolean hasCatchAllChild() {
return catchAllChild != null;
}
@Override
public String toString() {
final ToStringHelper toStringHelper =
MoreObjects.toStringHelper(this)
.add("path", path())
.add("type", type())
.add("parent", parent() == null ? "(null)"
: parent().path() + '#' + parent().type());
children().forEach(child -> toStringHelper.add("child",
child.path() + '#' + child.type()));
toStringHelper.add("values", values());
return toStringHelper.toString();
}
@VisibleForTesting
@Nullable
Node parent() {
return parent;
}
@Nullable
private Node child(char key) {
return children == null ? null : children.get(key);
}
/**
* Attaches a given {@code value} to the value list. If the list is not empty
* the {@code value} is added, and sorted by the given {@link Comparator}.
*/
private void addValue(V value, @Nullable Comparator comparator) {
if (values == null) {
values = new ArrayList<>();
}
values.add(value);
// Sort the values using the given comparator.
if (comparator != null && values.size() > 1) {
values.sort(comparator);
}
}
/**
* Adds a child {@link Node} into the {@code children} map.
*/
private Node addChild(Node child) {
requireNonNull(child, "child");
final char key = convertKey(child.path().charAt(0));
if (children == null) {
children = new HashMap<>();
}
if (children.containsKey(key)) {
// There should not exist two different children which starts with the same character in a trie.
throw new IllegalStateException("Path starting with '" + key + "' already exist:" + child);
}
children.put(key, child);
// Set short-cuts for the special-purpose children.
// Overwriting was validated while adding this child into the children map.
switch (child.type()) {
case PARAMETER:
parameterChild = child;
break;
case CATCH_ALL:
catchAllChild = child;
break;
}
return child;
}
/**
* Splits this {@link Node} into two by the given index of the path.
*/
private void split(int pathSplitPos) {
checkArgument(pathSplitPos > 0 && pathSplitPos < path().length(),
"Invalid split index of the path: %s", pathSplitPos);
// Would be split as:
// - AS-IS: /abc/ (me)
// d (child 1)
// e (child 2)
// - TO-BE: /ab (me: split)
// c/ (child: split)
// d (grandchild 1)
// e (grandchild 2)
final String parentPath = path().substring(0, pathSplitPos);
final String childPath = path().substring(pathSplitPos);
final Node child = new Node<>(this, type(), childPath);
// Move the values which belongs to this node to the new child.
child.values = values;
child.children = children;
child.parameterChild = parameterChild;
child.catchAllChild = catchAllChild;
child.children().forEach(c -> c.parent = child);
// Clear the values and update the path and children.
children = null;
parameterChild = null;
catchAllChild = null;
values = null;
path(parentPath);
addChild(child);
}
/**
* Converts the given character to the key of the children map.
* This is only used while building a {@link RoutingTrie}.
*/
static char convertKey(char key) {
switch (key) {
case ':':
return KEY_PARAMETER;
case '*':
return KEY_CATCH_ALL;
default:
return key;
}
}
/**
* Validates the given path.
*/
static void validatePath(@Nullable String path) {
checkArgument(path != null && !path.isEmpty(),
"A path should not be null and empty.");
checkArgument(path.indexOf(KEY_PARAMETER) < 0,
"A path should not contain %s: %s",
Integer.toHexString(KEY_PARAMETER), path);
checkArgument(path.indexOf(KEY_CATCH_ALL) < 0,
"A path should not contain %s: %s",
Integer.toHexString(KEY_CATCH_ALL), path);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy