设计一个框架

大部分时候,我们需要实现一个 Web 应用,第一反应是应该使用哪个框架。不同的框架设计理念和提供的功能有很大的差别。比如 Python 语言的 djangoflask,前者大而全,后者小而美。Go语言/golang 也是如此,新框架层出不穷,比如BeegoGinIris等。那为什么不直接使用标准库,而必须使用框架呢?在设计一个框架之前,我们需要回答框架核心为我们解决了什么问题。只有理解了这一点,才能想明白我们需要在框架中实现什么功能。

我们先看看标准库 net/http 如何处理一个请求

package main

import (
"fmt"
"log"
"net/http"
) func main() {
http.HandleFunc("/",handler)
//http.HandlerFunc("/count",counter)
log.Fatal(http.ListenAndServe("localhost:8000",nil))
} func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w,"URL.Path = %q\n",r.URL.Path)
fmt.Println("1234")
}

net/http提供了基础的Web功能,即监听端口,映射静态路由,解析HTTP报文。一些Web开发中简单的需求并不支持,需要手工实现。

  • 动态路由:例如hello/:namehello/*这类的规则。
  • 鉴权:没有分组/统一鉴权的能力,需要在每个路由映射的handler中实现。
  • 模板:没有统一简化的HTML机制。

当我们离开框架,使用基础库时,需要频繁手工处理的地方,就是框架的价值所在。但并不是每一个频繁处理的地方都适合在框架中完成。Python有一个很著名的Web框架,名叫bottle,整个框架由bottle.py一个文件构成,共4400行,可以说是一个微框架。那么理解这个微框架提供的特性,可以帮助我们理解框架的核心能力。

  • 路由(Routing):将请求映射到函数,支持动态路由。例如'/hello/:name
  • 模板(Templates):使用内置模板引擎提供模板渲染机制。
  • 工具集(Utilites):提供对 cookies,headers 等处理机制。
  • 插件(Plugin):Bottle本身功能有限,但提供了插件机制。可以选择安装到全局,也可以只针对某几个路由生效。

Gee框架

这个教程将使用 Go 语言实现一个简单的 Web 框架,起名叫做Geegeektutu.com的前三个字母。我第一次接触的 Go 语言的 Web 框架是GinGin的代码总共是14K,其中测试代码9K,也就是说实际代码量只有5KGin也是我非常喜欢的一个框架,与Python中的Flask很像,小而美。

7天实现Gee框架这个教程的很多设计,包括源码,参考了Gin,大家可以看到很多Gin的影子。

时间关系,同时为了尽可能地简洁明了,这个框架中的很多部分实现的功能都很简单,但是尽可能地体现一个框架核心的设计原则。例如Router的设计,虽然支持的动态路由规则有限,但为了性能考虑匹配算法是用Trie树实现的,Router最重要的指标之一便是性能。

标准库启动Web服务

Go语言内置了 net/http库,封装了HTTP网络编程的基础的接口,我们实现的Gee Web 框架便是基于net/http的。我们接下来通过一个例子,简单介绍下这个库的使用。

package main

import (
"fmt"
"log"
"net/http"
) func main() {
http.HandleFunc("/",indexHandler)
http.HandleFunc("/hello",helloHandler)
log.Fatal(http.ListenAndServe(":8000",nil))
} // handler echoes r.URL.Path
func indexHandler(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w,"URL.Path = %q\n",req.URL.Path)
} func helloHandler(w http.ResponseWriter, req *http.Request) {
for k,v := range req.Header {
fmt.Fprintf(w, "Header[%q] = %q\n",k,v)
}
}

我们设置了2个路由,//hello,分别绑定 indexHandlerhelloHandler , 根据不同的HTTP请求会调用不同的处理函数。访问/,响应是URL.Path = /,而/hello的响应则是请求头(header)中的键值对信息。

用 curl 这个工具测试一下,将会得到如下的结果

$ curl localhost:8000/
URL.Path = "/" $ curl localhost:8000/hello
Header["User-Agent"] = ["curl/7.64.1"]
Header["Accept"] = ["*/*"]

main 函数的最后一行,是用来启动 Web 服务的,第一个参数是地址,:8000表示在 8000 端口监听。而第二个参数则代表处理所有的HTTP请求的实例,nil 代表使用标准库中的实例处理。第二个参数,则是我们基于net/http标准库实现Web框架的入口。

实现http.Handler接口

package http

type Handler interface {
ServeHTPP(w ResponseWriter, r *Request)
} func ListenAndServe(address string, h Handler) error { }

第二个参数的类型是什么呢?通过查看net/http的源码可以发现,Handler是一个接口,需要实现方法 ServeHTTP ,也就是说,只要传入任何实现了 ServerHTTP 接口的实例,所有的HTTP请求,就都交给了该实例处理了。

main.go

**

package main

import (
"fmt"
"log"
"net/http"
) type Engine struct{} func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/":
fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
case "/hello":
for k, v := range req.Header {
fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
}
default:
fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
}
} func main() {
engine := new(Engine)
log.Fatal(http.ListenAndServe(":8000", engine))
}
  • 我们定义了一个空的结构体Engine,实现了方法ServeHTTP。这个方法有2个参数,第二个参数是 Request ,该对象包含了该HTTP请求的所有的信息,比如请求地址、Header和Body等信息;第一个参数是 ResponseWriter ,利用 ResponseWriter 可以构造针对该请求的响应。

  • main 函数中,我们给 ListenAndServe 方法的第二个参数传入了刚才创建的engine实例。至此,我们走出了实现Web框架的第一步,即,将所有的HTTP请求转向了我们自己的处理逻辑。还记得吗,在实现Engine之前,我们调用 http.HandleFunc 实现了路由和Handler的映射,也就是只能针对具体的路由写处理逻辑。比如/hello。但是在实现Engine之后,我们拦截了所有的HTTP请求,拥有了统一的控制入口。在这里我们可以自由定义路由映射的规则,也可以统一添加一些处理逻辑,例如日志、异常处理等。

  • 代码的运行结果与之前的是一致的。

Gee框架的雏形

代码结构

tree gee_demo1
gee_demo1
├── gee
│   ├── gee.go
│   └── go.mod
├── go.mod
└── main.go 1 directory, 4 files

go.mod

module gee_demo1

go 1.13

require gee v0.0.0

replace gee => ./gee
  • go.mod 中使用 replace 将 gee 指向 ./gee

从 go 1.11 版本开始,引用相对路径的 package 需要使用上述方式。

main.go

package main

import (
"fmt"
"gee"
"net/http"
) func main() {
r := gee.New()
r.GET("/", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
}) r.GET("/hello", func(w http.ResponseWriter, req *http.Request) {
for k, v := range req.Header {
fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
}
}) r.Run(":8000")
}

看到这里,如果你使用过gin框架的话,肯定会觉得无比的亲切。gee框架的设计以及API均参考了gin。使用New()创建 gee 的实例,使用 GET()方法添加路由,最后使用Run()启动Web服务。这里的路由,只是静态路由,不支持/hello/:name这样的动态路由,动态路由我们将在下一次实现。

gee.go

package gee

import (
"fmt"
"log"
"net/http"
) // HandlerFunc defines the request handler used by gee
type HandlerFunc func(http.ResponseWriter, *http.Request) // Engine implement the interface of ServeHTTP
type Engine struct {
router map[string]HandlerFunc
} // New is the constructor of gee.Engine
func New() *Engine {
return &Engine{router: make(map[string]HandlerFunc)}
} func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
key := method + "-" + pattern
log.Printf("Route %4s - %s", method, pattern)
engine.router[key] = handler
} // GET defines the method to add GET request
func (engine *Engine) GET(pattern string, handler HandlerFunc) {
engine.addRoute("GET", pattern, handler)
} // POST defines the method to add POST request
func (engine *Engine) POST(pattern string, handler HandlerFunc) {
engine.addRoute("POST", pattern, handler)
} // Run defines the method to start a http server
func (engine *Engine) Run(addr string) (err error) {
return http.ListenAndServe(addr, engine)
} func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
key := req.Method + "-" + req.URL.Path
if handler, ok := engine.router[key]; ok {
handler(w, req)
} else {
fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
}
}

那么gee.go就是重头戏了。我们重点介绍一下这部分的实现。

  • 首先定义了类型HandlerFunc,这是提供给框架用户的,用来定义路由映射的处理方法。我们在Engine中,添加了一张路由映射表router,key 由请求方法和静态路由地址构成,例如GET-/GET-/helloPOST-/hello,这样针对相同的路由,如果请求方法不同,可以映射不同的处理方法(Handler),value 是用户映射的处理方法。

  • 当用户调用(*Engine).GET()方法时,会将路由和处理方法注册到映射表 router 中,(*Engine).Run()方法,是 ListenAndServe 的包装。

  • Engine实现的 ServeHTTP 方法的作用就是,解析请求的路径,查找路由映射表,如果查到,就执行注册的处理方法。如果查不到,就返回 404 NOT FOUND

执行go run main.go,再用 curl 工具访问,结果与最开始的一致

测试

$ curl localhost:8000
URL.Path = "/" $ curl localhost:8000/hello
Header["Accept"] = ["*/*"]
Header["User-Agent"] = ["curl/7.64.1"]

至此,整个Gee框架的原型已经出来了。实现了路由映射表,提供了用户注册静态路由的方法,包装了启动服务的函数。当然,到目前为止,我们还没有实现比net/http标准库更强大的能力,不用担心,很快就可以将动态路由、中间件等功能添加上去了。

上下文Context

  • 路由(router)独立出来,方便之后增强。
  • 设计上下文(Context),封装 Request 和 Response ,提供对 JSON、HTML 等返回类型的支持.

使用效果

package main

import (
"fmt"
"gee"
"net/http"
) func main() {
r := gee.New()
r.GET("/", func(c *gee.Context) {
c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
})
r.GET("/hello", func(c *gee.Context) {
// expect /hello?name=geektutu
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
}) r.POST("/login", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
}) r.Run(":9999")
}
  • Handler的参数变成成了gee.Context,提供了查询Query/PostForm参数的功能。
  • gee.Context封装了HTML/String/JSON函数,能够快速构造HTTP响应。

设计Context

必要性

  1. 对Web服务来说,无非是根据请求*http.Request,构造响应http.ResponseWriter。但是这两个对象提供的接口粒度太细,比如我们要构造一个完整的响应,需要考虑消息头(Header)和消息体(Body),而 Header 包含了状态码(StatusCode),消息类型(ContentType)等几乎每次请求都需要设置的信息。因此,如果不进行有效的封装,那么框架的用户将需要写大量重复,繁杂的代码,而且容易出错。针对常用场景,能够高效地构造出 HTTP 响应是一个好的框架必须考虑的点。

用返回JSON数据作比较, 感受下封装前后的差距

封装前

obj = map[string]interface{}{
"name": "geektutu",
"password": "1234",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(obj); err != nil {
http.Error(w, err.Error(), 500)
}

封装后

c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
  1. 针对使用场景,封装*http.Requesthttp.ResponseWriter的方法,简化相关接口的调用,只是设计 Context 的原因之一。对于框架来说,还需要支撑额外的功能。例如,将来解析动态路由/hello/:name,参数:name的值放在哪呢?再比如,框架需要支持中间件,那中间件产生的信息放在哪呢?Context 随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由 Context 承载。因此,设计 Context 结构,扩展性和复杂性留在了内部,而对外简化了接口。路由的处理函数,以及将要实现的中间件,参数都统一使用 Context 实例, Context 就像一次会话的百宝箱,可以找到任何东西。

Context具体实现

gee/context.go

type H map[string]interface{}

type Context struct {
// origin objects
Writer http.ResponseWriter
Req *http.Request
// request info
Path string
Method string
// response info
StatusCode int
} func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Writer: w,
Req: req,
Path: req.URL.Path,
Method: req.Method,
}
} func (c *Context) PostForm(key string) string {
return c.Req.FormValue(key)
} func (c *Context) Query(key string) string {
return c.Req.URL.Query().Get(key)
} func (c *Context) Status(code int) {
c.StatusCode = code
c.Writer.WriteHeader(code)
} func (c *Context) SetHeader(key string, value string) {
c.Writer.Header().Set(key, value)
} func (c *Context) String(code int, format string, values ...interface{}) {
c.SetHeader("Content-Type", "text/plain")
c.Status(code)
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
} func (c *Context) JSON(code int, obj interface{}) {
c.SetHeader("Content-Type", "application/json")
c.Status(code)
encoder := json.NewEncoder(c.Writer)
if err := encoder.Encode(obj); err != nil {
http.Error(c.Writer, err.Error(), 500)
}
} func (c *Context) Data(code int, data []byte) {
c.Status(code)
c.Writer.Write(data)
} func (c *Context) HTML(code int, html string) {
c.SetHeader("Content-Type", "text/html")
c.Status(code)
c.Writer.Write([]byte(html))
}
  • 代码最开头,给map[string]interface{}起了一个别名gee.H,构建JSON数据时,显得更简洁。
  • Context目前只包含了http.ResponseWriter*http.Request,另外提供了对 Method 和 Path 这两个常用属性的直接访问。
  • 提供了访问Query和PostForm参数的方法。
  • 提供了快速构造String/Data/JSON/HTML响应的方法。

路由 (Router)

我们将和路由相关的方法和结构提取了出来,放到了一个新的文件中router.go,方便我们下一次对 router 的功能进行增强,例如提供动态路由的支持。 router 的 handle 方法作了一个细微的调整,即 handler 的参数,变成了 Context。

gee/router.go

type router struct {
handlers map[string]HandlerFunc
} func newRouter() *router {
return &router{handlers: make(map[string]HandlerFunc)}
} func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
log.Printf("Route %4s - %s", method, pattern)
key := method + "-" + pattern
r.handlers[key] = handler
} func (r *router) handle(c *Context) {
key := c.Method + "-" + c.Path
if handler, ok := r.handlers[key]; ok {
handler(c)
} else {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
}
}

框架入口

gee/gee.go

// HandlerFunc defines the request handler used by gee
type HandlerFunc func(*Context) // Engine implement the interface of ServeHTTP
type Engine struct {
router *router
} // New is the constructor of gee.Engine
func New() *Engine {
return &Engine{router: newRouter()}
} func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
engine.router.addRoute(method, pattern, handler)
} // GET defines the method to add GET request
func (engine *Engine) GET(pattern string, handler HandlerFunc) {
engine.addRoute("GET", pattern, handler)
} // POST defines the method to add POST request
func (engine *Engine) POST(pattern string, handler HandlerFunc) {
engine.addRoute("POST", pattern, handler)
} // Run defines the method to start a http server
func (engine *Engine) Run(addr string) (err error) {
return http.ListenAndServe(addr, engine)
} func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := newContext(w, req)
engine.router.handle(c)
}

router相关的代码独立后,gee.go简单了不少。最重要的还是通过实现了 ServeHTTP 接口,接管了所有的 HTTP 请求。相比第一天的代码,这个方法也有细微的调整,在调用 router.handle 之前,构造了一个 Context 对象。这个对象目前还非常简单,仅仅是包装了原来的两个参数,之后我们会慢慢地给Context插上翅膀。

如何使用,main.go一开始就已经亮相了。运行go run main.go,借助 curl ,一起看一看今天的成果吧。

curl -i http://localhost:9999/
HTTP/1.1 200 OK
Date: Mon, 12 Aug 2019 16:52:52 GMT
Content-Length: 18
Content-Type: text/html; charset=utf-8
<h1>Hello Gee</h1> $ curl "http://localhost:9999/hello?name=geektutu"
hello geektutu, you're at /hello $ curl "http://localhost:9999/login" -X POST -d 'username=geektutu&password=1234'
{"password":"1234","username":"geektutu"} $ curl "http://localhost:9999/xxx"
404 NOT FOUND: /xxx

感谢大佬 geektutu.com 分享

01 . Go之从零实现Web框架(框架雏形, 上下文Context,路由)的更多相关文章

  1. 01 . Go之从零实现Web框架(类似Gin)

    设计一个框架 大部分时候,我们需要实现一个 Web 应用,第一反应是应该使用哪个框架.不同的框架设计理念和提供的功能有很大的差别.比如 Python 语言的 django和flask,前者大而全,后者 ...

  2. Web开源框架大汇总

    Struts 项目简介信息 Struts是一个基于Sun J2EE平台的MVC框架,主要是采用Servlet和JSP技术来实现的.由于Struts能充分满足应用开发的需求,简单易用,敏捷迅速,在过去的 ...

  3. 7天用Go动手写/从零实现Web框架Gee

    设计一个框架 大部分时候,我们需要实现一个 Web 应用,第一反应是应该使用哪个框架.不同的框架设计理念和提供的功能有很大的差别.比如 Python 语言的 django和flask,前者大而全,后者 ...

  4. ASP.NET Web API框架揭秘:路由系统的几个核心类型

    ASP.NET Web API框架揭秘:路由系统的几个核心类型 虽然ASP.NET Web API框架采用与ASP.NET MVC框架类似的管道式设计,但是ASP.NET Web API管道的核心部分 ...

  5. 01、NetCore2.0优化之Web服务器 与 IIS解耦

    01.NetCore2.0优化之Web服务器 与 IIS解耦 在Asp.Net Core 2.0中,是如何实现跨平台的?不使用IIS了,在linux上的WebServer是什么? ---------- ...

  6. Node.js 从零开发 web server博客项目[项目介绍]

    web server博客项目 Node.js 从零开发 web server博客项目[项目介绍] Node.js 从零开发 web server博客项目[接口] Node.js 从零开发 web se ...

  7. Sapper:迈向理想的 Web 应用框架

    ​扎稳阵脚,再进一步. 注意:原文发表于2017-12-31,随着框架不断演进,部分内容可能已不适用. 给迫不及待的小伙伴们的快速入门:Sapper 文档 和快速模板 starter template ...

  8. Tornado WEB服务器框架 Epoll

    引言: 回想Django的部署方式 以Django为代表的python web应用部署时采用wsgi协议与服务器对接(被服务器托管),而这类服务器通常都是基于多线程的,也就是说每一个网络请求服务器都会 ...

  9. SpringMVC4零配置--web.xml

    servlet3.0+规范后,允许servlet,filter,listener不必声明在web.xml中,而是以硬编码的方式存在,实现容器的零配置. ServletContainerInitiali ...

随机推荐

  1. 大数据开发-Hive-常用日期函数&&日期连续题sql套路

    前面是常用日期函数总结,后面是一道连续日期的sql题目及其解法套路. 1.当前日期和时间 select current_timestamp -- 2020-12-05 19:16:29.284 2.获 ...

  2. Python中的文档字符串作用

    文档字符串是使用一对三个单引号 ''' 或者一对三个双引号 """来包围且没有赋值给变量的一段文字说明(如果是单行且本身不含引号,也可以是单引号和双引号), 它在代码执行 ...

  3. PyQt(Python+Qt)学习随笔:QTabWidget部件信号简介

    老猿Python博文目录 专栏:使用PyQt开发图形界面Python应用 老猿Python博客地址 QTabWidget自身提供的信号包括如下: currentChanged(int index):每 ...

  4. PyQt(Python+Qt)学习随笔:QListWidget的sortingEnabled属性

    老猿Python博文目录 专栏:使用PyQt开发图形界面Python应用 老猿Python博客地址 QListWidget的sortingEnabled属性用于控制列表部件中的项是否可以排序,如果为T ...

  5. Flask+MySQL+Redis的Docker配置

    Docker配置了好多天,昨天晚上终于把碎遮项目的Docker打包完成了,后面会继续完善项目代码,把稳定版本打包后推送到DockerHub上. 网上关于Docker配置的文章很多,但大部分都是复制粘贴 ...

  6. [GYCTF2020]Blacklist

    这题是用堆叠注入,同时也是借这题记录一下CTF中堆叠注入的一些骚操作 以下部分内容转载大佬的文章 show databases; 获取数据库名 show tables; 获取表名 show colum ...

  7. 移动端web网页meta设置

    <meta charset='utf-8'><!-- 声明文档使用的字符编码 --> <meta http-equiv="X-UA-Compatible&quo ...

  8. 串口数据监视 Serial Port Monitor

    串口数据监视工具 Serial Port Monitor可以在其它应用读写串口时监视串口数据, 很好用,但只有15天试用期.

  9. Java-静态关键字Static&静态代码块

    静态成员变量:(static修饰变量) 如果一个成员变量使用了static关键字,那么这个变量不再属于对象自己,而是属于所在的类.多个对象共享同一份数据. 静态方法:(static修饰方法) 一旦使用 ...

  10. sql注入之双查询注入

    双查询注入前需要了解什么是子查询 子查询可以理解在一个select语句中再插入一个select 里面的select语句就是子查询 例子:select concat((select database() ...