一个web框架--less


项目地址

一,背景

实现一个go web框架,是我从java转go的第一个项目,当时团队人手紧缺,业务线原有的java项目重构需求非常紧急,对于为这些项目提供支撑的web框架,给的开发时间更是少得可怜,更让人抓狂的是,这么紧急的框架开发任务,人员配置就我一个,哈哈哈哈。 当时,看了下httprouter的源码,还没来得及学习下gin beego等知名web框架,就直接上手干了。
现在回想起来,虽然该有的功能都有了,但是从细节设计和代码结构上看,还有不少可以优化的地方,并且当时很多涉及到的知识点还没有来得及由点到面的展开,也就没有来得及应用到项目中去。所以决定从零开始,再写一个web框架,同时把一些比较值得记录的地方,以文字的形式做一个输出和总结,希望能够对看到这篇文章的你起到一定的帮助。

1,当时为什么不用现有的ginbeegokitechogorillaweb框架,而是要重复造轮子?

  • 第一,也是最重要的原因–练手。了解一个领域/知识点,最好的办法就是实现它,当时刚从java转go,急需一个项目练手。
  • 第二,拿web框架作为练手项目,除了需要扎实的基础之外,难度方面,不高不低,非常合适。
  • 第三,团队内部关于现有轮子的争论无法达成一致,最终决定自己开发满足定制化需求的轻量级框架。
  • 第四,有plan B,也就是兜底方案。如果开发失败,就直接上现有的轮子,对于业务线,几乎没有切换成本。

2,哪个框架更好?

开源框架很多,不知道应该学习选择什么框架。相信大多数gopher都有遇到过这种困扰。 我的建议是不要被框架绑架,搞清楚事情的本质,才是解决问题的王道。 现在技术更新是日新月异的,框架更是如此,隔一段时间换一个,不同的公司很大可能采用不同的框架,所以你换新工作之后,一般都得重新学习一门新框架,如果新公司用的框架你不会,那如何做到快速上手将会是一个比学习框架更大的挑战。
其实这些表面现象的本质只有一个,那就是:框架只是一个工具而已!不止框架是工具,我们的编程语言也可以是工具。所以框架可以一个个的换,编程语言也可以,现在很多服务已经使用 Go 语言替代 Java、Python 了。
那么基于这个本质,我该怎么做呢?

  • 打牢基础。编程语言本身的功能、概念、语法、模式等,它是编程的地基,不会随着框架的改变而改变。
  • 精通原理。就拿web框架来说,主要负责的就是三件事:接收请求、找到用户处理逻辑调用一下、再把响应写回去。简单点就是:接受请求、分发请求、写回响应。同一类框架,底层原理都是差不多的,区别主要表现在实现方式、用户体验(开发者是用户)、周边生态等方面。
  • 多实践。实践是检验真理的唯一标准,积极参与公司新项目,多向大佬学习。

3,名字less的由来?

less is more

4,理解less,需要了解哪些前置知识?

4.1,认真读懂net/http标准库,其中大致的逻辑如下:

  • 标准库创建 HTTP 服务是通过创建一个 Server 数据结构完成的
  • Server 数据结构在 for 循环中不断监听每一个连接
  • 每个连接默认开启一个 Goroutine 为其服务
  • serverHandler 结构代表请求对应的处理逻辑,并且通过这个结构进行具体业务逻辑处理
  • Server 数据结构如果没有设置处理函数 Handler,默认使用DefaultServerMux 处理请求
  • DefaultServerMux 是使用 map 结构来存储和查找路由规则

很显然,聪明而又熟悉RESTful的你肯定已经发现,最后一步”DefaultServerMux 是使用 map 结构来存储和查找路由规则”是一个可优化的点,这也是众多go web框架需要自己实现的一个地方,当然less也不例外。

4.2,理解Context的设计思路,在less中,我们将设计一个实现Context接口的custom context,并将被应用于以下方面:

  • 日常开发中我们大概率会遇到超时控制的场景,比如一个批量耗时任务、网络请求等;一个良好的超时控制可以有效的避免一些问题(比如 goroutine 泄露、资源不释放等)
  • 一个请求,从接受到返回过程中的信息传递和共享
  • 超时控制groutine和业务逻辑groutine并发写ResponseWriter问题的处理
  • 超时控制groutine,触发超时,写入ResponseWriter超时响应之后,业务逻辑groutine重复写ResponseWriter的问题处理

Context设计思想及使用实践,可以参考我的这篇Context设计思想及使用实践

4.3,熟悉http协议,推荐阅读《HTTP权威指南》,如果时间充裕还可以读一下《TCP/IP详解》系列。

4.4,熟悉http.ResponseWriter*http.Request这两大信息载体的结构和方法,不然的话,连从哪里读取query valuesform data以及往哪里写response都不知道,那就尴尬了。

4.5,熟悉设计模式。

巧妙而又优雅,几乎是所有架构设计的基本准则,less也不例外,如果你来不及认真研读23种设计模式,可以认真阅读我的这篇面向对象设计六大原则,如果能够做到融会贯通,那么由此衍化而来的设计模式,你也可以做到信手拈来。

4.6,one more thing

为了降低less框架的阅读难度,建议先看懂这个示例级别的小框架, 源码在此
为什么要强调上面链接里的框架是示例级别?原因在于,这个示例框架只能用于个人开发,不能用于生产,而less的目标是打造成一个生产级别的框架。
那么生产级别和示例级别有哪些明显的差异呢?
答:细节和生态
具体体现在一下几点:

  • 核心模块。服务启动方式、路由分发机制、context封装性和中间件机制等的设计
  • 功能完备性。日志模块、命令行工具和缓存机制等功能是否提供
  • 框架扩展性。是否支持扩展,扩展方式是否友好
  • 框架性能。qps等性能
  • 文档/社区。

很显然,示例级别的框架,并不满足以上几点。除此之外,一个人完成完美契合以上几点的生产级别的框架也是不可能的,所以需要学会站在巨人的肩膀上思考,向巨人借力,因此,less在关键代码的设计上,会借鉴已经经过千锤百炼、反复迭代升级过的gin的做法,只有这样,才能做出功能完备、性能优良的生产级框架。

4.7,Last but not least – 了解包括但不限于以下开源库,这些开源库将会被引用到less

二,重要结构(web框架三大件:context,router,middleware)

框架中的某个模块,比如说路由,实现的方法不止一种,每一个模块要实现的功能也各有不同,所以用哪一种方法来实现,以及要实现哪些功能,都是一种选择。 而每种选择的背后,其实都是方向问题,因为这些选择共同构成了一个框架的倾向性,也就是设计感。设计感非常考验开发者的综合技能和项目经验,也是考核框架质量的一个重要维度。

核心模块

一个框架最核心的模块:context,router,middleware,它们的设计感会影响到整个框架的性能和表现,也最能直接体现出框架作者的开发水平。

1.context

关于Context设计思想及使用实践,我在这片文章里已经讲得很详细了。在less框架里,我们主要要了解的是context的应用和自定义context的最佳实践。less框架里的自定义context结构如下:

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

type Context struct {
// 服务容器
container less.Container

writermem responseWriter
Request *http.Request
Writer ResponseWriter

Params Params
handlers HandlersChain
index int8
fullPath string

engine *Engine
params *Params

// This mutex protect Keys map
mu sync.RWMutex

// Keys is a key/value pair exclusively for the context of each request.
Keys map[string]interface{}

// Errors is a list of errors attached to all the handlers/middlewares who used this context.
Errors errorMsgs

// Accepted defines a list of manually accepted formats for content negotiation.
Accepted []string

// queryCache use url.ParseQuery cached the param query result from c.Request.URL.Query()
queryCache url.Values

// formCache use url.ParseQuery cached PostForm contains the parsed form data from POST, PATCH,
// or PUT body parameters.
formCache url.Values

// SameSite allows a server to define a cookie attribute making it impossible for
// the browser to send this cookie along with cross-site requests.
sameSite http.SameSite
}

2.router

首选,对于一个web框架来说,router必须要满足以下需求:

  • http method 匹配
  • 静态路由匹配
  • 路由批量通用前缀
  • 动态路由匹配

所以在路由存储结构的设计选型上,需要花些时间去思考的,毕竟该结构除了要满足上述需求外,还要有比较高的查找效率。常见的数据结构:

数据结构 查找时间复杂度 是否满足路由匹配4点要求
数据组 O(n) 满足,匹配都需要遍历,时间复杂度O(n)
哈希表 O(1) 满足,动态路由匹配需要遍历,时间复杂度O(n)
队列 O(n) 满足,匹配都需要遍历,时间复杂度O(n)
O(n) 满足,匹配都需要遍历,时间复杂度O(n)
链表 O(n) 满足,匹配都需要遍历,时间复杂度O(n)
O(n) 满足,匹配都需要遍历,时间复杂度O(n)
O(n^2) 满足,匹配都需要遍历,时间复杂度O(n)
O(logn) 满足,匹配不需要遍历

所以,从对比可以得出我们的选型结果,那就是–树。了解树这个数据结构的人都应该知道,树的查找时间复杂度和树高是负相关的,树高越高,查找的效率越低,当树的左右子树极度不平衡的时候,就会退化为链表,时间复杂度为O(n)。所以,还需要对树的结构和节点上存储的数据做一些设计和优化,来提高查找性能。我们可以参考下gin所采取的方案–httprouter。我的这篇博客httprouter源码解析分析了httprouter的源码,可以移步查阅,在此就不做重复叙述了。
我们的less也会采用httprouter作为路由管理的解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type methodTrees []methodTree

type methodTree struct {
method string
root *node
}

type node struct {
path string
indices string
wildChild bool
nType nodeType
priority uint32
children []*node // child nodes, at most 1 :param style node at the end of the array
handlers HandlersChain
fullPath string
}

设计思想

1,一切皆服务

理解这种设计,需要对面向对象、面向接口有一定的了解,同时,如果真正应用的业务开发中,又需要你对DDD能够做到融会贯通。
在less框架里面,我们几乎所有的模块(配置、缓存、日志和web服务等)都会被设计为一种服务,包括使用方,在做业务开发的时候,我们也建议按功能把业务划分为不同的服务来设计,然后把这些服务注册到我们框架定义的服务容器中就可以。 就像堆积积木,只要想好了一个服务的接口,我们逐步实现服务之后,这一个服务就是一块积木,之后可以用相同的思路实现各种服务的积木块,用它们来拼出我们需要的业务逻辑。这就是“一切皆服务”思想带来的便利。

框架方面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// NewInstance 定义了如何创建一个新实例,所有服务容器的创建服务
type NewInstance func(...interface{}) (interface{}, error)

// ServiceProvider 定义一个服务提供者需要实现的接口
type ServiceProvider interface {
// Register 在服务容器中注册了一个实例化服务的方法,是否在注册的时候就实例化这个服务,需要参考IsDefer接口。
Register(Container) NewInstance
// Boot 在调用实例化服务的时候会调用,可以把一些准备工作:基础配置,初始化参数的操作放在这个里面。
// 如果Boot返回error,整个服务实例化就会实例化失败,返回错误
Boot(Container) error
// IsDefer 决定是否在注册的时候实例化这个服务,如果不是注册的时候实例化,那就是在第一次make的时候进行实例化操作
// false表示不需要延迟实例化,在注册的时候就实例化。true表示延迟实例化
IsDefer() bool
// Params params定义传递给NewInstance的参数,可以自定义多个,建议将container作为第一个参数
Params(Container) []interface{}
// Name 代表了这个服务提供者的凭证
Name() string
}

日志provider,实现provider接口提供各种log service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// LogServiceProvider 服务提供者
type LogServiceProvider struct {
framework.ServiceProvider

Driver string // Driver

// 日志级别
Level contract.LogLevel
// 日志输出格式方法
Formatter contract.Formatter
// 日志context上下文信息获取函数
CtxFielder contract.CtxFielder
// 日志输出信息
Output io.Writer
}

业务方面,实现provider接口提供各种业务service:

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
type DemoProvider struct {
framework.ServiceProvider

c framework.Container
}

func (sp *DemoProvider) Name() string {
return DemoKey
}

func (sp *DemoProvider) Register(c framework.Container) framework.NewInstance {
return NewService
}

func (sp *DemoProvider) IsDefer() bool {
return false
}

func (sp *DemoProvider) Params(c framework.Container) []interface{} {
return []interface{}{sp.c}
}

func (sp *DemoProvider) Boot(c framework.Container) error {
sp.c = c
return nil
}

2,服务容器

设计详见注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Container 是一个服务容器,提供绑定服务和获取服务的功能
type Container interface {
// Bind 绑定一个服务提供者,如果关键字凭证已经存在,会进行替换操作,返回error
Bind(provider ServiceProvider) error
// IsBind 关键字凭证是否已经绑定服务提供者
IsBind(key string) bool

// Make 根据关键字凭证获取一个服务,
Make(key string) (interface{}, error)
// MustMake 根据关键字凭证获取一个服务,如果这个关键字凭证未绑定服务提供者,那么会panic。
// 所以在使用这个接口的时候请保证服务容器已经为这个关键字凭证绑定了服务提供者。
MustMake(key string) interface{}
// MakeNew 根据关键字凭证获取一个服务,只是这个服务并不是单例模式的
// 它是根据服务提供者注册的启动函数和传递的params参数实例化出来的
// 这个函数在需要为不同参数启动不同实例的时候非常有用
MakeNew(key string, params []interface{}) (interface{}, error)
}

3,命令工具

less引入著名的cobra库来实现扩展命令行功能,Cron、CronSpecs和container使我们新加入的定制化功能。

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
type Command struct {
// Command支持cron,只在RootCommand中有这个值
Cron *cron.Cron
// 对应Cron命令的说明文档
CronSpecs []CronSpec

// 服务容器
container Container
// Use is the one-line usage message.
// Recommended syntax is as follow:
// [ ] identifies an optional argument. Arguments that are not enclosed in brackets are required.
// ... indicates that you can specify multiple values for the previous argument.
// | indicates mutually exclusive information. You can use the argument to the left of the separator or the
// argument to the right of the separator. You cannot use both arguments in a single use of the command.
// { } delimits a set of mutually exclusive arguments when one of the arguments is required. If the arguments are
// optional, they are enclosed in brackets ([ ]).
// Example: add [-F file | -D dir]... [-f format] profile
Use string

// Aliases is an array of aliases that can be used instead of the first word in Use.
Aliases []string

// SuggestFor is an array of command names for which this command will be suggested -
// similar to aliases but only suggests.
SuggestFor []string

// Short is the short description shown in the 'help' output.
Short string

// Long is the long message shown in the 'help <this-command>' output.
Long string

// Example is examples of how to use the command.
Example string

// ValidArgs is list of all valid non-flag arguments that are accepted in shell completions
ValidArgs []string
// ValidArgsFunction is an optional function that provides valid non-flag arguments for shell completion.
// It is a dynamic version of using ValidArgs.
// Only one of ValidArgs and ValidArgsFunction can be used for a command.
ValidArgsFunction func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective)

// Expected arguments
Args PositionalArgs

// ArgAliases is List of aliases for ValidArgs.
// These are not suggested to the user in the shell completion,
// but accepted if entered manually.
ArgAliases []string

// BashCompletionFunction is custom bash functions used by the legacy bash autocompletion generator.
// For portability with other shells, it is recommended to instead use ValidArgsFunction
BashCompletionFunction string

// Deprecated defines, if this command is deprecated and should print this string when used.
Deprecated string

// Annotations are key/value pairs that can be used by applications to identify or
// group commands.
Annotations map[string]string

// Version defines the version for this command. If this value is non-empty and the command does not
// define a "version" flag, a "version" boolean flag will be added to the command and, if specified,
// will print content of the "Version" variable. A shorthand "v" flag will also be added if the
// command does not define one.
Version string

// The *Run functions are executed in the following order:
// * PersistentPreRun()
// * PreRun()
// * Run()
// * PostRun()
// * PersistentPostRun()
// All functions get the same args, the arguments after the command name.
//
// PersistentPreRun: children of this command will inherit and execute.
PersistentPreRun func(cmd *Command, args []string)
// PersistentPreRunE: PersistentPreRun but returns an error.
PersistentPreRunE func(cmd *Command, args []string) error
// PreRun: children of this command will not inherit.
PreRun func(cmd *Command, args []string)
// PreRunE: PreRun but returns an error.
PreRunE func(cmd *Command, args []string) error
// Run: Typically the actual work function. Most commands will only implement this.
Run func(cmd *Command, args []string)
// RunE: Run but returns an error.
RunE func(cmd *Command, args []string) error
// PostRun: run after the Run command.
PostRun func(cmd *Command, args []string)
// PostRunE: PostRun but returns an error.
PostRunE func(cmd *Command, args []string) error
// PersistentPostRun: children of this command will inherit and execute after PostRun.
PersistentPostRun func(cmd *Command, args []string)
// PersistentPostRunE: PersistentPostRun but returns an error.
PersistentPostRunE func(cmd *Command, args []string) error

// args is actual args parsed from flags.
args []string
// flagErrorBuf contains all error messages from pflag.
flagErrorBuf *bytes.Buffer
// flags is full set of flags.
flags *flag.FlagSet
// pflags contains persistent flags.
pflags *flag.FlagSet
// lflags contains local flags.
lflags *flag.FlagSet
// iflags contains inherited flags.
iflags *flag.FlagSet
// parentsPflags is all persistent flags of cmd's parents.
parentsPflags *flag.FlagSet
// globNormFunc is the global normalization function
// that we can use on every pflag set and children commands
globNormFunc func(f *flag.FlagSet, name string) flag.NormalizedName

// usageFunc is usage func defined by user.
usageFunc func(*Command) error
// usageTemplate is usage template defined by user.
usageTemplate string
// flagErrorFunc is func defined by user and it's called when the parsing of
// flags returns an error.
flagErrorFunc func(*Command, error) error
// helpTemplate is help template defined by user.
helpTemplate string
// helpFunc is help func defined by user.
helpFunc func(*Command, []string)
// helpCommand is command with usage 'help'. If it's not defined by user,
// cobra uses default help command.
helpCommand *Command
// versionTemplate is the version template defined by user.
versionTemplate string

// inReader is a reader defined by the user that replaces stdin
inReader io.Reader
// outWriter is a writer defined by the user that replaces stdout
outWriter io.Writer
// errWriter is a writer defined by the user that replaces stderr
errWriter io.Writer

//FParseErrWhitelist flag parse errors to be ignored
FParseErrWhitelist FParseErrWhitelist

// CompletionOptions is a set of options to control the handling of shell completion
CompletionOptions CompletionOptions

// commandsAreSorted defines, if command slice are sorted or not.
commandsAreSorted bool
// commandCalledAs is the name or alias value used to call this command.
commandCalledAs struct {
name string
called bool
}

ctx context.Context

// commands is the list of commands supported by this program.
commands []*Command
// parent is a parent command for this command.
parent *Command
// Max lengths of commands' string lengths for use in padding.
commandsMaxUseLen int
commandsMaxCommandPathLen int
commandsMaxNameLen int

// TraverseChildren parses flags on all parents before executing child command.
TraverseChildren bool

// Hidden defines, if this command is hidden and should NOT show up in the list of available commands.
Hidden bool

// SilenceErrors is an option to quiet errors down stream.
SilenceErrors bool

// SilenceUsage is an option to silence usage when an error occurs.
SilenceUsage bool

// DisableFlagParsing disables the flag parsing.
// If this is true all flags will be passed to the command as arguments.
DisableFlagParsing bool

// DisableAutoGenTag defines, if gen tag ("Auto generated by spf13/cobra...")
// will be printed by generating docs for this command.
DisableAutoGenTag bool

// DisableFlagsInUseLine will disable the addition of [flags] to the usage
// line of a command when printing help or generating docs
DisableFlagsInUseLine bool

// DisableSuggestions disables the suggestions based on Levenshtein distance
// that go along with 'unknown command' messages.
DisableSuggestions bool

// SuggestionsMinimumDistance defines minimum levenshtein distance to display suggestions.
// Must be > 0.
SuggestionsMinimumDistance int
}

得益于一切皆服务的设计,在less中,结合命令工具,你可以通过命令操作一切服务,比如:启动、停止、查询和重启web。

4,自动化

don’t repeat yourself,把一切重复性的劳动自动化。

4.1,调试模式

调试模式下,监控文件修改,自动编译,自动运行。

4.2,兼容vue,前后端一体化

对于web框架来说,这一点并不是刚需,但是对于做偏运维/运营侧产品开发的同学来说,非常得必要。

4.3,发布自动化

这一点也不是刚需。有很多方式可以将一个服务进行自动化部署,比如现在比较流行的 Docker 化或者 CI/CD 流程。 但是一些比较个人比较小的项目,比如一个博客、一个官网网站,这些部署流程往往都太庞大了,更需要一个服务,能快速将在开发机器上写好、调试好的程序上传到目标服务器,并且更新应用程序。

4.4,其他一系列自动化工具
  • 自动化创建服务工具
  • 自动化创建命令行工具
  • 自动化gin中间件迁移工具
  • 自动化less脚手架工具

代码

talk is cheap,直接看代码吧,注释非常友好

quick start

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"github.com/BugKillerPro/less/app/console"
"github.com/BugKillerPro/less/app/http"
"github.com/BugKillerPro/less/framework"
"github.com/BugKillerPro/less/framework/provider/app"
"github.com/BugKillerPro/less/framework/provider/kernel"
)

func main() {
// 初始化服务容器
container := framework.NewlessContainer()
// 绑定App服务提供者
container.Bind(&app.LessAppProvider{})

// 将HTTP引擎初始化,并且作为服务提供者绑定到服务容器中
if engine, err := http.NewHttpEngine(container); err == nil {
container.Bind(&kernel.LessKernelProvider{HttpEngine: engine})
}

// 运行root命令
console.RunCommand(container)
}

end

参考

Context设计思想及使用实践

面向对象设计六大原则

httprouter源码解析