One of the cores of the gateway is the routing logic, which determines how a request needs to be processed and which downstream service it should be forwarded to.
About 80% of routing requirements are URL-based. It is necessary to clearly describe how URLs or sets of URLs with certain characteristics correspond to a series of downstream processing strategies.
For example, URLs starting with ‘/test/’ route to the testing environment cluster, while URLs starting with ‘/release/user/’ route to the production environment’s user services cluster.
Additionally, as the entry point for all requests, every millisecond of delay accumulates across the entire business in a mesh scenario, where delays can further magnify with the depth of the call chain. According to the standard for production environments, response times of <=7 milliseconds set a very demanding performance requirement for rule matching. Performance must not degrade as the number of rules increases.
This section describes how Pixiu’s configuration file delineates URL-related routing rules from the user’s perspective. (Next, we will explain how to configure URL routing rules.)
Below is a sample Pixiu API configuration file, which will be parsed to create a corresponding memory model as the initial state for Pixiu’s routing configuration. Subsequently, the memory model obtained by parsing will be modified by the RDS protocol to achieve dynamic routing logic. The content related to the RDS protocol (RDS: the part of the xDS protocol that describes routing rules) will not be detailed here. Let’s focus on the resource section.
The path part under resource represents the URL-related routing description discussed above. This means that URLs that match the path description will be successfully matched.
name: server
description: server sample
resources:
- path: '/api/v1/test-dubbo/user/name/:name'
type: restful
description: user
methods:
- httpVerb: GET
enable: true
timeout: 1000ms
inboundRequest:
requestType: http
uri:
- name: name
required: true
integrationRequest:
requestType: dubbo
mappingParams:
- name: uri.name
mapTo: 0
mapType: "string"
applicationName: "UserProvider"
interface: "com.dubbogo.server.UserService"
method: "GetUserByName"
group: "test"
version: 1.0.0
clusterName: "test_dubbo"
Matched requests will be converted into the Dubbo protocol and forwarded to the test_dubbo cluster to call the GetUserByName service under com.dubbogo.server.UserService.
Let’s further focus on the following scope:
path: '/api/v1/test-dubbo/user/name/:name'
To describe a URL or a group of URLs clearly, the routing engine needs to have the following capabilities:
To use Pixiu correctly, you may need to understand the following content.
This is not a novel concept; it is a priority logic commonly possessed by frameworks like Spring in Java:
The priority rule is just one way to resolve conflicts. When multiple URL descriptions match simultaneously, the one with the higher priority will take effect. However, the priority strategy cannot cover all cases.
If two resource paths are configured to be exactly the same but forwarded to different downstream services, a conflict will occur. Pixiu’s strategy for handling conflicts is to fail fast; if conflicting rules are found during the initialization phase of Pixiu, the startup will fail, allowing developers to detect and address the issue early.
Upon selecting the technology and determining the use of Pixiu to handle unforeseen situations and potential bugs, developers need to have a deeper understanding of the routing principles of Pixiu.
Next, we will detail the relevant principles and implementations of the routing engine for those interested.
Those who read this part are likely to subconsciously think of the structure of a trie. Utilizing a trie structure can achieve performance optimization in matching irrespective of the number of existing rules.
A trie that stores strings as nodes can express URLs.
This depiction is equivalent to the URL set ‘/api/v1’, ‘/api/v2’, ‘/web’.
Maintaining a standard trie involves several key operations:
Some simple operations that do not involve recursion or reuse the above logic:
As noted, a standard trie structure still has gaps in its capabilities for a general routing engine, lacking generalized representation and variable expression abilities. Let’s look at how to improve this.
Adding subordinate trees that describe generalized logic to form a default component of the subtree.
Now our variant trie gains variable expression capability.
How should a URL like ‘/web/:appname/test/*’ be represented in the diagram?
Correct, it should be this path.
Continuing to analyze whether any key operations in the trie need changes:
However, we currently still lack the expressiveness of multi-level wildcards represented by ‘/’. Analyzing requirement conclusions indicates these wildcards should not have subtrees; they are specialized leaf nodes used for special judgments during match logic backtracking. Continuing to introduce some special nodes evolves this into:
Now, all requirements are sufficed.
Equivalent paths for ‘/api/’:
Other logic is largely the same; match logic now has one more level of judgment. If the single-level wildcard subtree still finds no results, check if the multi-level wildcard subtree is empty (one flag would suffice, but a subtree is used here for model uniformity and clarity).
At this point, all capabilities previously discussed can effectively support the relevant operations. Analyzing the complexity, URLs are divided into segments by ‘/’, making matching complexity O(n), where n = number of URL segments. This complex analysis remains indifferent to the quantity of rules stored in the tree. Further analyzing scope of n reveals it is not an unlimited number; some browsers even constrain the URL length to below 2000 characters. Assuming an average segment length of 5, segments would generally be capped at around 400. If n could be considered a constant, the complexity can thus be viewed as O(1).
In brief, explain the difference between find and match, and why both node retrieval methods are necessary. Consider this example: if the tree has already added the path ‘/api/v1/:name/add’, then
find("/api/v1/:name/add") and find("/api/v1/*/add") should yield results during the add process for conflict checking.
Assuming a request comes in with URL ‘/api/v1/:name/add’, match("/api/v1/:name/list") should also yield results, setting the variable name to :name.
If a request with the URL ‘/api/v1/yq/add’ comes in, match("/api/v1/yq/list") should produce results with name being yq, while find("/api/v1/yq/add") will yield no result.
Currently, a global lock is acquired before reading or writing to the tree. On failure, it will spin until success before proceeding with read or write.
This is done because extensive use of Go’s map structure is present in the code. Concurrent read-write accesses lead to this error: concurrent map read and map write.
The current implementation is as follows:
A command queue is introduced, where all user write operations go in first, while separating read and write from each other. One thread is maintained to capture logs and write to the write tree. The read tree, which is purely read, does not require a write thread; hence, no locks are necessary. The write tree, written by only one thread, incurs no competition issues and, therefore, can also operate without locks (write operations are not frequent and are manageable via a single thread).
A configuration defining an effective delay time, say 3s, is set.
Every 3 seconds, the roles of the read and write trees switch. Each trie maintains a cursor for its command queue, indicating which record has been traced in the log where this trie is at, while the writing thread switches references of the writing cursor every 3 seconds.
In the above image, the top part represents a first-in-first-out command queue; the logging thread reads from this queue for user write operations. It maintains two cursor indices, index1 and index2; index1 indicates that trie1 has traced its logs to index1 position, while index2 shows trie2 has traced to index2. At any one moment, the logging thread will operate on one reference for write operations, shifting the corresponding index cursor down one space after completing each write. The other trie reference will be used for read operations; all read requests will come from the tree represented by the read reference. Since they are tracing the same log, eventual consistency is guaranteed.
Switching logic:
pr:
https://github.com/apache/dubbo-go-pixiu/pull/262
pkg/common/router/trie/trie.go:26