开发简单 web 服务程序 cloudgo
任务目标
- 熟悉 go 服务器工作原理
- 基于现有 web 库,编写一个简单 web 应用类似 cloudgo
- 使用 curl 工具访问 web 程序
- 对 web 执行压力测试
任务要求
基本要求
- 编程 web 服务程序 类似 cloudgo 应用
- 支持静态文件服务
- 支持简单 js 访问
- 提交表单,并输出一个表格(必须使用模板)
- 使用 curl 测试,将测试结果写入 README.md
- 使用 ab 测试,将测试结果写入 README.md。并解释重要参数
扩展要求
选择简单的库,如 mux 等,通过源码分析、解释它是如何实现扩展的原理,包括一些 golang 程序设计技巧。
cloudgo实现
项目结构如下:
一. 第三方库引用
-
Negroni
在 Go 语言里,Negroni 是一个很地道的 Web 中间件,它是一个具备微型、非嵌入式、鼓励使用原生 net/http 库特征的中间件。如果你喜欢用 Martini ,但又觉得它太魔幻,那么 Negroni 就是你很好的选择了。
安装:go get github.com/codegangsta/negroni
-
mux
Mux(数据选择器 / multiplexer):在多路数据传送过程中,能够根据需要将其中任意一路选出来的电路,叫做数据选择器,也称多路选择器或多路开关。
安装:go get github.com/gorilla/mux
-
render
Render is a package that provides functionality for easily rendering JSON, XML, text, binary data, and HTML templates. This package is based on the Martini render work.
安装:go get github.com/unrolled/render
二. 静态文件访问
首先编写启动服务器的main函数。将默认端口设置为8080,如果有指定监听端口号的命令行参数,则使用pflag包函数进行解析并设置,然后开启服务器。
package main
import (
"os"
"github.com/lp/cloudgo/service"
flag "github.com/spf13/pflag"
)
const (
PORT string = "8080"
)
func main() {
port := os.Getenv("PORT")
if len(port) == 0 {
port = PORT
}
pPort := flag.StringP("port", "p", PORT, "PORT for httpd listening")
flag.Parse()
if len(*pPort) != 0 {
port = *pPort
}
server := service.NewServer()
server.Run(":" + port)
}
然后在server.go中实现具体功能。Newserver函数新建一个render并初始化,使其能够在目录“templates”下寻找后缀为html的模板文件。initRoutes函数调用函数 func FileServer(root FileSystem) Handler 来完成文件服务,一条语句就实现了 mx.PathPrefix("/").Handler(http.FileServer(http.Dir(webRoot + "/assets/")))
静态文件服务。它的含义是将 path 以 “/” 前缀的 URL 都定位到 webRoot + “/assets/” 为虚拟根目录的文件系统。
// NewServer configures and returns a Server.
func NewServer() *negroni.Negroni {
formatter := render.New(render.Options{
Directory: "templates",
Extensions: []string{".html"},
IndentJSON: true,
})
n := negroni.Classic()
mx := mux.NewRouter()
initRoutes(mx, formatter)
n.UseHandler(mx)
return n
}
func initRoutes(mx *mux.Router, formatter *render.Render) {
webRoot := os.Getenv("WEBROOT")
if len(webRoot) == 0 {
if root, err := os.Getwd(); err != nil {
panic("Could not retrive working directory")
} else {
webRoot = root
}
}
mx.PathPrefix("/").Handler(http.FileServer(http.Dir(webRoot + "/assets/")))
}
go run main.go -p 10000在10000端口运行服务器效果如下:
访问image下的5t5.jpg如下:
三. 主页访问
新增homeHandler函数,配合NewServer函数完成网页输出。要点如下:
- formatter 构建,指定了模板的目录为templates,模板文件的扩展名为.html
- homeHandler 使用了模板
- 将ID和Content字段信息作为结构体参数传递给index.html
func homeHandler(formatter *render.Render) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
formatter.HTML(w, http.StatusOK, "index", struct {
ID string `json:"id"`
Content string `json:"content"`
}{ID: "18342066", Content: "This is LP in SYSU!"})
}
}
index.html设计如下:
<html>
<head>
<link rel="stylesheet" href="css/index.css"/>
</head>
<body>
<div class="container">
<p>A simple web server program</p>
<p>The ID is {{.ID}}</p>
<p>The content is {{.Content}}</p>
</div>
</body>
</html>
index.css设计如下:
body {
background-image: url(https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1606140725338&di=3b664098aa009b242b393b99c51e03b1&imgtype=0&src=http%3A%2F%2Fpic1.win4000.com%2Fwallpaper%2F7%2F58e745a879945.jpg);
}
.container {
width: 40%;
height: 20%;
position: absolute;
left:30%; top:20%;
text-align: center;
}
P {
font-size: 20px;
}
在initRoutes函数中新增语句:mx.HandleFunc("/", homeHandler(formatter)).Methods("GET")
访问效果如下:
四. 支持简单js访问
修改index.html如下:
<html>
<head>
<link rel="stylesheet" href="css/index.css"/>
<script type="text/javascript" src="js/jquery-3.4.1.js"></script>
<script type="text/javascript" src="js/hello.js"></script>
</head>
<body>
<div class="container">
<p>A simple web server program</p>
<p class="greeting-id">The ID is </p>
<p class="greeting-content">The content is </p>
</div>
</body>
</html>
hello.js通过ajax向服务器请求/api/test:
$(document).ready(function() {
$.ajax({
url: "/api/test"
}).then(function(data) {
console.log(data)
$('.greeting-id').append(data.id);
$('.greeting-content').append(data.content);
});
});
在server.go中新增函数apiTestHandler,这段代码非常简单,输出了一个 匿名结构 ,并 JSON (JavaScript Object Notation) 序列化输出:
func apiTestHandler(formatter *render.Render) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
formatter.JSON(w, http.StatusOK, struct {
ID string `json:"id"`
Content string `json:"content"`
}{ID: "18342066", Content: "This is LP in SYSU!"})
}
}
在initRoutes函数中新增语句:mx.HandleFunc("/api/test", apiTestHandler(formatter))
五. 提交表单,并输出一个表格(必须使用模板)
新建两个html文件,分别是登陆前和登陆后,点击登陆后由前者跳转至后者:
login.html:
<html>
<head>
<title></title>
</head>
<body>
<form action="/after_login" method="post">
用户名:<input type="text" name="username">
密码:<input type="password" name="password">
<input type="submit" value="登录">
</form>
</body>
</html>
after_login.html:
<html>
<head>
<title></title>
</head>
<body>
<table>
<tr>
<th>Username</th>
<th>Password</th>
</tr>
<tr>
<td>{{.Username}}</td>
<td>{{.Password}}</td>
</tr>
</body>
</html>
在serer.go中新增login函数,根据请求对应的方法来渲染不同的页面:
func login(formatter *render.Render) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
formatter.HTML(w, http.StatusOK, "login", struct{}{})
} else {
req.ParseForm()
formatter.HTML(w, http.StatusOK, "after_login", struct {
Username string
Password string
}{
Username: req.Form["username"][0],
Password: req.Form["password"][0],
})
}
}
}
在initRoutes函数中新增语句:
mx.HandleFunc("/login", login(formatter))
mx.HandleFunc("/after_login", login(formatter))
访问 http://localhost:10000/login 效果如下:
输入用户名和密码后,点击登录:
六. curl测试
访问主页:
访问静态文件:
访问登录页面:
七. ab测试
对主页进行10000次请求,100个并发请求:
aaa@qq.com:~$ ab -n 10000 -c 100 http://localhost:10000/
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
Benchmarking localhost (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: localhost
Server Port: 10000
Document Path: /
Document Length: 387 bytes
Concurrency Level: 100
Time taken for tests: 1.645 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 5040000 bytes
HTML transferred: 3870000 bytes
Requests per second: 6079.50 [#/sec] (mean)
Time per request: 16.449 [ms] (mean)
Time per request: 0.164 [ms] (mean, across all concurrent requests)
Transfer rate: 2992.26 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 2 9.3 1 94
Processing: 0 14 13.4 10 114
Waiting: 0 12 12.7 9 113
Total: 0 16 17.2 11 137
Percentage of the requests served within a certain time (ms)
50% 11
66% 15
75% 18
80% 20
90% 30
95% 50
98% 79
99% 109
100% 137 (longest request)
对image/5t5.jpg进行10000次请求,10个并发请求:
aaa@qq.com:~$ ab -n 10000 -c 100 http://localhost:10000/image/5t5.jpg
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
Benchmarking localhost (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: localhost
Server Port: 10000
Document Path: /image/5t5.jpg
Document Length: 307565 bytes
Concurrency Level: 100
Time taken for tests: 3.454 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 3077390000 bytes
HTML transferred: 3075650000 bytes
Requests per second: 2895.08 [#/sec] (mean)
Time per request: 34.541 [ms] (mean)
Time per request: 0.345 [ms] (mean, across all concurrent requests)
Transfer rate: 870047.87 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.6 0 28
Processing: 0 34 33.4 26 294
Waiting: 0 25 31.2 16 264
Total: 0 34 33.4 26 294
Percentage of the requests served within a certain time (ms)
50% 26
66% 36
75% 42
80% 48
90% 68
95% 98
98% 136
99% 194
100% 294 (longest request)
八. 源代码阅读—Negroni中间件原理与实现
首先看一下HTTP Server 的处理逻辑:
在这个逻辑处理的环节中,Negroni充当一个HTTP Handler的角色,并对于所有的HTTP Request的处理都会通过Negroni被转交到其内部的子中间件。
negroni包是go的一个第三方库,是为了方便使用 net/http 而设计的一个库,由于该包设计优雅,易于扩展,被广泛使用。
中文文档
negroni包源码在github仓库中,可以从github上克隆下来或者通过go get命令获得。
negroni包的源码结构如下(使用tree命令获得):
.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── doc.go
├── go.mod
├── logger.go
├── logger_test.go
├── negroni.go
├── negroni_bench_test.go
├── negroni_test.go
├── recovery.go
├── recovery_test.go
├── response_writer.go
├── response_writer_pusher.go
├── response_writer_pusher_test
├── response_writer_test.go
├── static.go
├── static_test.go
└── translations
├── README_de_de.md
├── README_fr_FR.md
├── README_ja_JP.md
├── README_ko_KR.md
├── README_pt_br.md
├── README_zh_CN.md
└── README_zh_tw.md
1 directory, 25 files
源码中真正起作用的只有logger.go、negroni.go、recovery.go、response_writer.go、response_writer_pusher.go、static.go共六个文件,其余都是文档和测试代码。我们在此主要分析negroni.go文件。
首先是对Handler接口的定义,Handler接口要求实现ServeHTTP函数,参数类型是回复的写出流http.ResponseWriter、请求http.Request、回调函数http.HandlerFunc,
然后是对HandlerFunc的函数类型的定义,HandlerFunc的函数参数和Handler.ServeHTTP一致,都是回复、请求和回调函数三个参数。HandlerFunc是一个适配器,用于将外界的函数转化为能够被Negroni处理的类型。
然后定义HandlerFunc实现了ServeHTTP的虚函数,所以HandlerFunc可以转为Handler类型。
type Handler interface {
ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc)
}
type HandlerFunc func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc)
func (h HandlerFunc) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
h(rw, r, next)
}
接下来是中间件middleware的类型定义,可见中间件类型包括了一个Handler和一个回调函数,middleware包含回调函数的目的是减少内存的分配。middleware实现了net/http里定义的ServeHTTP接口,通过调用middleware的handler的ServeHTTP来实现,这里形成了Negroni与原生的net/http包的无缝衔接。
type middleware struct {
handler Handler
// nextfn stores the next.ServeHTTP to reduce memory allocate
nextfn func(rw http.ResponseWriter, r *http.Request)
}
func newMiddleware(handler Handler, next *middleware) middleware {
return middleware{
handler: handler,
nextfn: next.ServeHTTP,
}
}
func (m middleware) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
m.handler.ServeHTTP(rw, r, m.nextfn)
}
接下来是定义装饰器,Wrap将http.Handler转换为一个negroni.Handler,WrapFunc将http.HandlerFunc转换为一个negroni.Handler,这里也是为了实现Negroni与原生的net/http包的无缝衔接。
func Wrap(handler http.Handler) Handler {
return HandlerFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
handler.ServeHTTP(rw, r)
next(rw, r)
})
}
func WrapFunc(handlerFunc http.HandlerFunc) Handler {
return HandlerFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
handlerFunc(rw, r)
next(rw, r)
})
}
最后是Negroni结构的定义以及构造函数,Negroni定义为一个middleware和一个handlers数组组成的结构体,其构造方法有三个:
- New,通过给出一系列的handler来构造Negroni,不定参数handlers组成Negroni的handlers数组,middleware通过辅助函数build来通过这一系列handler构造
- With,With通过一个已有的Negroni,并给出不定参数handlers,将给出的handlers附加到已有的Negroni的handlers后即可得到新的Negroni。
- Classic, Classic通过在栈中的默认的中间件构造,使用了Recovery、Logger、Static来给出保存的信息。
type Negroni struct {
middleware middleware
handlers []Handler
}
func New(handlers ...Handler) *Negroni {
return &Negroni{
handlers: handlers,
middleware: build(handlers),
}
}
func (n *Negroni) With(handlers ...Handler) *Negroni {
currentHandlers := make([]Handler, len(n.handlers))
copy(currentHandlers, n.handlers)
return New(
append(currentHandlers, handlers...)...,
)
}
func Classic() *Negroni {
return New(NewRecovery(), NewLogger(), NewStatic(http.Dir("public")))
}
推荐阅读