Best Practices for Express Middleware Management

When structuring Express apps, middleware play a crucial role in handling cross-cutting concerns like logging, security, and error handling. However, as our middleware chain grows, managing the installation order can quickly become messy. In this post, I’ll go over a few patterns for installing Express middleware - from simple to more advanced - and discuss the pros and cons of each approach. The goal is to provide some ideas and best practices to keep our middleware pipeline maintainable as our app grows.

Practice 1: Installing Middleware Sequentially

This approach installs middleware by specifying each one sequentially in code:

1
2
3
4
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(routes);

This loads the middleware in a predefined order, which works for simple use cases.

However, this approach has some downsides:

  • The middleware loading order is inflexible. Dependencies between middleware aren’t handled automatically.
  • It makes it harder to share middleware configuration across projects.
  • There’s no central place to see what middleware is in use.
  • It doesn’t facilitate configuration per environment.
  • Testing middleware in isolation is more difficult.

Practice 2: Installing Middleware by Priority

While installing middleware sequentially directly in code is simple, it can quickly become unmanageable for larger apps. To add more structure, we can define middleware priorities and install them in a sorted order based on those priorities. This allows us to control the middleware loading order more granularly.

This approach assigns a priority to each middleware and installs them sorted by priority:

  1. Define middleware priorities:

    1
    2
    3
    4
    5
    6
    const MiddlewarePriority = {
    Logging: 1,
    BodyParser: 2,
    Helmet: 3,
    ErrorHandler: 4,
    };
  2. Register middleware with priorities:

    1
    2
    3
    4
    5
    6
    const middlewares = new Map();

    middlewares.set(MiddlewarePriority.Logging, loggingMiddleware);
    middlewares.set(MiddlewarePriority.BodyParser, bodyParserMiddleware);
    middlewares.set(MiddlewarePriority.Helmet, helmetMiddleware);
    middlewares.set(MiddlewarePriority.ErrorHandler, errorHandlerMiddleware);
  3. Install middleware in priority order:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function installMiddlewares(middlewaresMap) {
    const sortedMiddlewares = [...middlewaresMap]
    .sort((a, b) => a[0] - b[0])
    .map((m) => m[1]);

    sortedMiddlewares.forEach((middleware) => {
    app.use(middleware);
    });
    }

Drawbacks:

  • Hard to add new middleware between priorities (e.g. CORS after BodyParser). No unused priority values.
  • Priorities could become out of sync if new middleware added.
  • Still need to register middleware in code.

Practice 3: Topological Sorting for Middleware Ordering

While defining middleware priorities allows more control over ordering than installling sequentially, it still has limitations when new middleware needs to be added between existing priorities. This leads us to a more flexible approach - modeling middleware as a directed graph based on before/after dependencies and topologically sorting to resolve the order. This avoids priority conflicts altogether by letting us explicitly declare dependencies separately from the installation code. Now middleware can be added without worrying about priorities or order - the graph handles it automatically. Transitioning to this dependency graph approach allows our middleware configuration to be much more extensible.

This approach defines middleware dependencies and installs them in a valid order using topological sorting:

  1. Define middleware and dependencies:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    const middlewares = [
    {
    id: "logging",
    before: [],
    after: ["bodyParser"],
    mw: loggingMiddleware,
    },
    {
    id: "bodyParser",
    before: ["logging"],
    after: ["helmet"],
    mw: bodyParserMiddleware,
    },
    {
    id: "helmet",
    before: ["bodyParser"],
    after: ["routes"],
    mw: helmetMiddleware,
    },
    ];
  2. Build dependency graph:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    function getMw(id) {
    return middlewares.find((mw) => mw.id === id);
    }

    const depGraph = new Graph();
    middlewares.forEach((mw) => {
    depGraph.addVertex(mw);

    mw.after.forEach((afterId) => {
    const afterMw = getMw(afterId);
    depGraph.addEdge(afterMw, mw);
    });

    mw.before.forEach((beforeId) => {
    const beforeMw = getMw(beforeId);
    depGraph.addEdge(mw, beforeMw);
    });
    });
  3. Topologically sort graph and install middleware:

    1
    2
    3
    4
    5
    const sortedMws = depGraph.topologicalSort();

    sortedMws.forEach((mw) => {
    app.use(mw.middleware);
    });

This allows complete control over middleware ordering while avoiding priority conflicts. Middleware and dependencies are declared separately from app code.

The key is implementing the Graph class with addVertex, addEdge and topologicalSort methods.

There is an example of the directed graph implementation for the reference:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
interface IVertex {
id: string;
}

class Graph {
vertices: IVertex[];
adjList: Map<IVertex, IVertex[]>;

constructor() {
this.vertices = [];
this.adjList = new Map();
}

addVertex(v: IVertex): void {
this.vertices.push(v);
this.adjList.set(v, []);
}

addEdge(v: IVertex, w: IVertex): void {
this.adjList.get(v)!.push(w);
}

topologicalSort(): IVertex[] {
const visited = new Set<IVertex>();
const stack: IVertex[] = [];

for (let v of this.vertices) {
if (!visited.has(v)) {
this.dfs(v, visited, stack);
}
}

return stack.reverse();
}

dfs(v: IVertex, visited: Set<IVertex>, stack: IVertex[]) {
visited.add(v);

const neighbors = this.adjList.get(v)!;
for (let w of neighbors) {
if (!visited.has(w)) {
this.dfs(w, visited, stack);
}
}

stack.push(v);
}
}

Summary

In summary, there are a few different ways to handle middleware installation. Doing it directly in code is quick but can get messy. Defining priorities helps control order but still has conflicts. Modeling dependencies as a graph and topology sorting gives the most flexibility as apps grow larger. The key is separating the middleware declarations from the installation logic. This allows new middleware to be added without fussing with order. Hope this gives some ideas on managing middleware setup!

本文标题:Best Practices for Express Middleware Management

文章作者:Pylon, Syncher

发布时间:2023年10月12日 - 23:10

最后更新:2023年10月12日 - 23:10

原始链接:https://0x400.com/experience/practice/middleware-installation-in-practice/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。