A few months ago my Mentor aka @industry developed a new Node.js Web framework: Farrow.
This article is built around that framework. If you don’t already know Farrow, you can go to @industrialGather for an introduction to Farrow.
The framework’s characteristics can be summarized briefly: friendly TypeScript types and functional style.
As a fan of TypeScript and functional programming, I was an early adopter of the framework when it was completed.
I restructured my personal blog project based on this framework and took the opportunity to talk about Farrow’s advantages and disadvantages over existing Express/Koa/GraphQL.
Although I have some experience with Express/Koa/GraphQL, due to the length of my career, I still lack the breadth and depth involved in framework design and development experience. Therefore, errors and bias appear in the article, it is inevitable, I hope you see mercy.
Okay, without further ado, let’s get started.
Problems with the existing framework
The existing frameworks are already powerful enough to cover almost any scenario, so the problem is not power, but ease of use.
A common problem encountered when using Express/Koa/GraphQL is type closure, also known as type safety, or type safe as the @industry gathering article calls it.
Perhaps because Express/Koa/GraphQL was around before TypeScript was around, this problem was evident in all of them.
There are three main parts: shared Context, input type and Output type.
Shared Context
How do they implement variable sharing between requestHandler/resolver in different frameworks?
GraphQL is mounted on CTX
Koa is mounted on CTX
Express is mounted on reQ
As you can see, the current solution is to mount variables on an object that is passed to every requestHandle/resolver.
The problem with this scheme is that the type, a requestHandle/resolver, how does it know if any variables are mounted in the object passed to it?
It can’t be predicted, and in a real world scenario, the usual practice would be @ts-ignore or construct a CTX/REq type with that variable and use AS.
I don’t know what other people would say about either option, but I personally think they should be a backdoor and shouldn’t be used too often.
Interface input type
The interface input parameter is expected to be type-safe, which verifies that the input type is correct and reflected on the type.
GraphQL has done type verification, and Express/Koa and other frameworks need to introduce joI/AJV/Superstruct and other libraries to cooperate. GraphQL type validation is hard to translate to TypeScript types, and client development still requires the use of AS, which is inconvenient.
RESTful supports parameter transmission through urls. However, it is necessary to verify the type of parameters in urls and reflect the language type. Here’s a reference to Rust’s Web framework: Rocket.
Output type (output type)
In different frameworks, how do they set the return value of the interface?
Express by calling res.send
Koa 通过设置 ctx.body
GraphQL returns the value from the resolver function
None of these Settings can be reflected in the language-level type constraints. (GraphQL can do this, but it requires a lot of work.)
On the server side, return value types cannot be constrained at the linguistic level, only at the logical level. The client has no way of knowing the type of value returned by the interface, whether using Express, Koa, or GraphQL, the client can only assume.
When using GraphQL, I experimented with interface input parameters and interface return value type constraints. Convert the GraphQL Schema to TypeScript type on the server side and synchronize it to a different Query on the client side, but encounter the following problems:
- GraphQL Schema’s type system and TypeScript’s type system are not isomorphic, meaning they don’t translate perfectly into each other, resulting in poor user experience.
- Since GraphQL supports request merging and data slicing, there are other things that the client needs to do when writing a Query statement to get a full experience, which of course is possible (for example, facebook’s Relay framework uses compiler to extract it). But considering the first question, even if this part is done right, the user experience will be less than satisfactory.
GraphQL also increases the amount of code. Interface with the same function, except for the business logic processing part of the code GraphQL requires a different order of magnitude of code than RESTful, using GraphQL to complete a request needs to write two types (server side Schema, client side Query). With TypeScript, there are four copies (plus the input type on the server side, the input type on the client side, and the return value type), which is quite a burden.
Farrow offers a solution
Friendly variable sharing: Context
Farrow has a built-in tool like React Context, which creates the Context and then hooks the value of the Context in all middleware, without having to pass parameters. You do not have to mark the CTX parameter type twice.
Farrow Context can be used to share variables between middleware and requestHandler for the same request, as well as between different requests. For details on Farrow Context, see the Farrow documentation.
Interface input participates in return value verification (input type & Output Type)
As you can see from the above discussion, the Express and Koa schemes don’t have this validation built in, and GraphQL does, but it does have synchronization issues with TS.
Farrow’s approach is to implement a TypeScript type system by itself. Only one type is implemented per request, and the rest is done through derivation and introspection, thus avoiding the increase in GraphQL code.
In addition, Farrow implements the Rocket framework validation of PARAMETERS in urls and maps them to TypeScript Types based on the Template Literal Types feature released in TypeScript 4.1.
Farrow of actual combat
Now I’ll show you how to use the above solution in combination with a specific scenario of my personal blog project: ME.
The technology stack uses React – Torch (my simple SSR framework based on React and Webpack implementation), Farrow.
Variables are shared across multiple middleware using Farrow Context
The reason: Webpack-dev-middleware will mount stats on res.locals. Webpack after the webpack is complete, and this information will be used for SSR, which is not important. All you need to know now is that the next requestHandler needs to use a variable that needs to be set up in this middleware, that is, shared variables.
Create Context and Hook like React
import { createContext } from 'farrow-pipeline'
const WebpackContext = createContext<WebpackContextType | null> (null)
export const useWebpackCTX = (): WebpackContextType= > {
let ctx = WebpackContext.use()
if (ctx.value === null) {
throw new Error(`assest not found`)}return ctx.value
}
Copy the code
Write Farrow middleware to dynamically update the Context value
const ctx: WebpackContextType = {
assets: {},}export const webpackDevMiddleware = (
compiler: Compiler
): HttpMiddleware= > {
compile(compiler, ctx)
return async (_, next) => {
const userCtx = WebpackContext.use()
userCtx.value = ctx
return next()
}
}
const compile = (compiler: Compiler, context: WebpackContextType) = >{...function done(stats: Stats) {... context.assets = webpackStats.assets ... } compiler.hooks.done.tap('WebpackDevMiddleware', done)
...
}
Copy the code
Mount middleware to farROW-HTTP pipeline
import { Http } from 'farrow-http'
import webpack from 'webpack'
const http = Http()
const config = { ... }
constCompiler = Webpack (config) HTTP. use(webpackDevMiddleware(Compiler)) Then, in any middleware, access the Context value via hooks. You do not need to modify the parameters. You don't have to mark the type. http.use(async (req) => {
const webpackCTX = useWebpackCTX()
// Get the variable
constassets = webpackCTX.assets ... })...Copy the code
At this point we have fully implemented this function, and the implementation process is type safe. I’ve removed some of the irrelevant code for demonstration purposes, and the full implementation of this feature can be viewed at Webpackhook.ts.
Write the back-end interface using FarROW-API and generate code for use by the front end
Interface entry participation Return value: simple interface implementation
The content of my project is very simple, without dynamic data changes, it can be made into a static page, but I still made it into a SPA project supporting SSR. I hope you will ignore this and focus on farrow’s features.
1) Use farrow-schema to define the data type: model type
import { Int, List, ObjectType, Type, Literal, TypeOf } from 'farrow-schema'
export const Numbers = List(Number)
export class Note extends ObjectType {
id = {
description: `Note id`,
[Type]: Int,
}
title = {
description: `Note title`,
[Type]: String,}... tags = {description: `Note tags`,
[Type]: Numbers,
}
}
Copy the code
2) Define the interface input return value type (similar to GraphQL Schema) : input type and Output type
import { ObjectType, Type, Literal, Union } from 'farrow-schema'
// get notes does not need arguments, so it is left blank
export const GetNotesInput = {}
export const NoteList = List(Note)
export class GetNotesSuccess extends ObjectType {
type = Literal('GetNotesSuccess')
notes = {
description: 'Note list',
[Type]: NoteList,
}
}
export class SystemError extends ObjectType {
type = Literal('SystemError')
message = {
description: 'SystemError message',
[Type]: String,}}export const GetNotesOutput = Union(GetNotesSuccess, SystemError)
Copy the code
3) With input type and Output type, you can build API functions
import { Api } from 'farrow-api'
export const getNotes = Api({
description: 'get notes'.input: GetNotesInput,
output: GetNotesOutput,
})
Copy the code
4) Implement handler for getNotes API functions
getNotes.use(() = > {
try {
const notes = require(path.resolve(process.cwd(), './data/notes.json'))
return {
type: 'GetNotesSuccess',
notes,
}
} catch (err) {
return {
type: 'SystemError'.message: JSON.stringify(err),
}
}
})
Copy the code
5) Merge API as Service to mount to HTTP pipeline.
import { ApiService } from 'farrow-api-server'
export const entries = {
getNotes,
// Other apis can be added here
}
export const notesService = ApiService({ entries })
Copy the code
6) Mount Service
import { Http } from 'farrow-http'
const http = Http()
http.route('/api').use(notesService)
...
Copy the code
7) Configure client code generation rules
// Start the server, Import {createApiClients} from 'farrow/dist/api-client' export const syncClient = () => {const client = createApiClients({ services: [ { src: `http://localhost:3000/api`, dist: `${__dirname}/src/api/model.ts`, alias: '/api', }, ], }) return client.sync() }Copy the code
The code generated for the client is as follows:
import { apiPipeline } from 'farrow-api-client'
/ * * * {@label SystemError}
*/
export type SystemError = {
type: 'SystemError'
/ * * *@remarks SystemError message
*/
message: string
}
/ * * * {@label Note}
*/
export type Note = {
/ * * *@remarks Note id
*/
id: number./ * * *@remarks Note tags
*/
tags: number[]}/ * * * {@label GetNotesSuccess}
*/
export type GetNotesSuccess = {
type: 'GetNotesSuccess'
/ * * *@remarks Note list
*/
notes: Note[]
}
export const url = '/api'
export const api = {
/ * * *@remarks get notes
*/
getNotes: (input: {}) = >
apiPipeline.invoke(url, { path: ['getNotes'], input }) as Promise<
GetNotesSuccess | SystemError
>,
}
Copy the code
On the client side, we no longer have to write code from scratch on how to fetch interface data. Instead, import the previously generated code file directly. Direct interface call, as follows:
api.getNotes({}).then((res) = > {
switch (res.type) {
case 'GetNotesSuccess': {
store.dispatch({
type: 'SET_NOTES'.payload: res.notes,
})
break
}
case 'SystemError': {
store.dispatch({
type: 'SET_ERRORS'.payload: [res.message],
})
break}}})Copy the code
The detailed implementation of this section is open source, and you can click on the Server API or Client API to see the full implementation. The client-side synchronization code is implemented in syncclient.ts.
You can also visit the Farrow project to learn more.
Farrow uses summaries
- The most obvious feeling is that the type connection is really smooth,
as
,any
,@ts-ignore
It doesn’t exist. This is great news for the obsessive-compulsive TypeScript developer. - The type system is a lot better than GraphQL, there’s not that much to write.
- During the project refactoring, I also made a number of issues and PR requests to the Farrow project and refactored the Webpack-dev-Middleware used in the project.
After using Farrow-API to refactor my personal project, I also found that Farrow-API can batch multiple interface requests like GraphQL and merge them into one, thereby reducing the number of HTTP requests at the front and back end and improving performance. I’ll try to implement it later, verify it, and Pull pull-request.
Personal conclusion
Farrow’s advantages:
- Type safety. The type system and TypeScript Introspection have great advantages, and when combined with Sequelize, you should be able to achieve type safety from database to front-end applications.
Farrow’s weaknesses:
- The ecology is not sound. Being prepared to build your own wheels in practice adds to the workload and is a test for developers.
- Node.js stack only, there are no plans to support other languages.
A look at Farrow’s future
- There is room for improvement in the type system. If the type system is made into a language-independent DSL like GraphQL Schema, then both the server and the client generate parts of the code from this, hopefully supporting more languages.
- Support Deno.