Golang学习笔记(二):第一个可用于生产环境下的真正Go程序
1. 缘起
在前一篇文章《Golang学习笔记(一):缘起及一个不一样的HelloWorld》中我们对Go开发环境有了一个最初步的认识,HolloWorld是编程语言共同默认的第一个可执行代码,然并卵!我们老祖宗教育我们要“学以致用”!因此,我们一开始就要考虑怎么在实际生产环境下的用Go。大家编程的主要目的开发网络应用提供网络服务,刚好,Go有一个功能强大使用简单的net/http包,简单的几行代码就能构建一个性能超越用Apache、Tomcat构建的网站。这么比有点奇怪,怎么把Go语言和Web server一起比较了:),原因是net/http包提供HTTP客户端和服务器实现。下面我们就开始吧……
2. 再来一个Hello World,但这次是个Web站点!
实现一个最简单HTTP server需要多少代码?Python、ruby只需要一行命令:python -m SimpleHTTPServer 8008或ruby -run -e httpd . -p 8009。对于Golang,需要多几行代码(注意是代码),但是能带来无与伦比的性能(咱不和Nginx比,这里也没说前端代码的事)。
2.1 编写代码
在%GOPATH%/src/Test目录下新建HelloWeb文件夹,再在该文件夹下创建helloweb.go的文本文件,输入如下代码:
package main
import (
"net/http"
"io"
)
func sayHello(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "{\"msg\": \"Hello," + r.URL.Query().Get("n") + "!\"}")
}
func main() {
http.Handle("/", http.FileServer(http.Dir("html")))
http.HandleFunc("/hello", sayHello)
http.ListenAndServe(":8001", nil)
}
在%GOPATH%/src/Test/HelloWeb/目录下新建html文件夹,再在html/文件夹下新建index.html文件,写入一些html代码或干脆只写:Hello,This is a static html page.
2.2 执行代码
在cmd中执行:go run helloweb.go
打开浏览器,在地址栏输入:http://localhost:8001
在地址栏输入:http://localhost:8001/hello?n=LinBaolong
2.3 代码解析
- mian()中的第一行代码启用了一个静态站点的处理程序,如果我们在html目录下放置一个html静态页面站点,这就是一个标准的可在生产环境中使用的网站了,性能没Nginx好,但也可以吊打其他web server了(参考其他人的测试数据,自己没实测);
- mian()中的第二行代码启用了一个处理程序(API接口程序),处理来自url为/hello的请求,处理程序为sayHello()函数,在sayHello()函数中我们通过r.URL.Query().Get(“n”)来获取来自Get请求url中的参数n;
- mian()中的第三行代码启动了一个端口为8001的web服务。
3 真正的生产环境站点……
上面弄了个HelloWeb,已经可以做无数据库支持的带有简单交互的网站了,但真正的生产环境站点一般具有以下特点:
- 真正生产环境站点一般都有配置文件;
- 真正生产环境站点一般都要连接数据库;
- 真正生产环境站点一般都有API(前后端分离的情况),且用Ajax交互,交互数据用json;
- 真正生产环境站点一般都有错误处理和日志;
- 真正生产环境站点开发时一般由多个go文件组成。
下面我们就建立一个满足上面五点要求的站点(框架)。
3.1 项目代码及详细注释
3.1.1 项目目录结构
demoWeb
│ config.json
│ demoweb.go
│ go.mod
│
├─config
│ config.go
│ go.mod
│
├─database
│ go.mod
│ myDataApi.go
│
├─html
│ index.html
│
├─logger
│ go.mod
│ logger.go
│
└─template
├─news
│ newslist.html
│
└─user
login.html
每个模块的文件夹下都有一个go.mod文件,这将在后面3.2.1中解释。
3.1.2 主程序demoweb.go
在项目根目录下新建主程序demoweb.go 文件
package main
import (
"fmt"
"net/http"
"html/template"
dbApi "demoWeb/myDataApi"
"demoWeb/logger"
"demoWeb/config"
)
func main() {
//测试日志
logger.Info.Println("程序启动……")
//测试配置文件模块
fmt.Printf("测试配置文件读取,读取ServerPort为: %s\n", config.ServerPort())
//测试数据库读取
fmt.Printf("测试数据库读取,系统共有注册用户: %d 个\n", dbApi.GetRecordCount("user", "1=1"))
//注册静态页面
http.Handle("/", http.FileServer(http.Dir("html")))
http.HandleFunc("/hello", sayHello)
http.HandleFunc("/login", LoginHandler)
http.HandleFunc("/news", NewsHandler)
serverErr :=http.ListenAndServe(":"+config.ServerPort(), nil)
if nil != serverErr {
logger.Error.Panic(serverErr.Error())
}
}
func sayHello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello,"+r.URL.Query().Get("n")+"!")
}
//Login页面处理程序
func LoginHandler(w http.ResponseWriter, r *http.Request){
t, err := template.ParseFiles("template/user/login.html")
if err != nil {
logger.Error.Println(err)
}
var nam, pwd, msg string
nam = r.FormValue("name")
pwd = r.FormValue("pswd")
msg = "请输入用户名及密码"
if nam != "" {
res :=dbApi.GetRecordCount("user", "name='" + nam + "' and password='" + pwd + "'")
if res>0 {
http.Redirect(w, r, "/news", http.StatusTemporaryRedirect)
}else if res==0 {
msg = "用户名或密码错误!登录失败!"
}else{
msg = "数据库错误!登录失败!"
}
}
t.Execute(w,map[string]interface{}{"PageTitle": "登录页面", "Message": msg})
}
//News页面处理程序
func NewsHandler(w http.ResponseWriter, r *http.Request){
t, err := template.ParseFiles("template/news/newslist.html")
if err != nil {
logger.Error.Println(err)
}
news, err := dbApi.GetRecordData("SELECT title, content FROM `news`")
if err != nil {
logger.Error.Println(err)
}
t.Execute(w, news)
}
3.1.3 日志及错误处理模块logger.go
在项目根目录下新建logger文件夹(Go建议每个包要有独立的文件夹),在该文件夹下新建logger.go文件(即路径为./logger/logger.go)
package logger
import (
"io"
"log"
"os"
)
//包中对外公开的全局变量,首字母要大写
var (
Info *log.Logger
Warn *log.Logger
Error *log.Logger
)
//本包对Go标准库log包进行了简单封装,创建了三种级别的日志
func init() {
logFile, err := os.OpenFile("demoweb-out.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatalln("打开日志文件失败:", err)
}
//创建三种的日志,以不同的前缀区分
Info = log.New(io.MultiWriter(os.Stdout, logFile), "[Info]:", log.Ldate|log.Ltime|log.Lshortfile)
Warn = log.New(io.MultiWriter(os.Stdout, logFile), "[Warn]:", log.Ldate|log.Ltime|log.Lshortfile)
Error = log.New(io.MultiWriter(os.Stderr, logFile), "[Error]:", log.Ldate|log.Ltime|log.Lshortfile)
}
3.1.4 配置文件读取模块config.go
- 配置文件config.json
在项目根目录下新建配置文件config.json
{
"ServerPort": "8001",
"DatabaseSource": "TestUser:Pass123@tcp(127.0.0.1:3306)/test_go?charset=utf8"
}
- 配置文件读取模块config.go
在项目根目录下新建config文件夹(Go建议每个包要有独立的文件夹),在该文件夹下新建config.go文件(即路径为./config/config.go)
package config
import (
"encoding/json"
io "io/ioutil"
"demoWeb/logger"
)
//结构应与config.json文件一致
type configs struct {
ServerPort string
DatabaseSource string
}
var configFile string = "config.json"
//若不允许其他模块修改的全局变量,建议首字母小写(私有),再设置读取函数,返回有关参数值
var conf configs
//go初始化函数,在引用包时就执行,且先于main()执行,且一个项目中多处引用也只执行一次
//一个包中可以有多个init()函数,一个文件中也可以有多个init()函数
func init() {
data, err := io.ReadFile(configFile)
if err != nil {
logger.Error.Fatalln("读取配置文件失败!程序终止!")
} else {
datajson := []byte(data)
err = json.Unmarshal(datajson, &conf)
if err != nil {
logger.Error.Fatalln("配置文件已读取,但解析失败!程序终止!")
}
}
}
//返回配置参数ServerPort
func ServerPort() string {
return conf.ServerPort
}
//返回配置参数DatabaseSource
func DatabaseSource() string {
return conf.DatabaseSource
}
3.1.5 数据库操作模块
- 首先需要安装mysql驱动模块
在cmd中执行如下命令:
go get -u github.com/go-sql-driver/mysql
- 数据库操作模块myDataApi.go
在项目根目录下新建database文件夹(Go建议每个包要有独立的文件夹),在该文件夹下新建myDataApi.go文件(即路径为./database/myDataApi.go)。
package myDataApi
import (
"database/sql"
"demoWeb/config"
"demoWeb/logger"
_ "github.com/go-sql-driver/mysql"
)
var myDB *sql.DB
//功能:初始化数据库,创建MySQL连接池
func init() {
//使用database/sql包的sql.Open函数创建数据库对象。它的第一个参数是数据库驱动名,第二个参数是一个连接字串。
//使用sql.Open函数创建一个连接池对象,不是单个连接。在open的时候并没有去连接数据库,只有在执行query、exce方法的时候才会去实际连接数据库。在一个应用中同样的库连接只需要保存一个sql.Open之后的db对象就可以了,不需要多次open。
//有全局变量myDB的情况下,err应该单独声明,不能用 myDB, err := sql.Open("mysql", config.DatabaseSource()),否则myDB会被处理成局部变量
var err error
myDB, err = sql.Open("mysql", config.DatabaseSource())
if err != nil {
logger.Error.Fatalf("数据库连接错误!%v\n", err)
}
//设置打开数据库的最大连接数。包含正在使用的连接和连接池的连接。如果你的函数调用需要申请一个连接,并且连接池已经没有了连接或者连接数达到了最大连接数。此时的函数调用将会被block,直到有可用的连接才会返回。设置这个值可以避免并发太高导致连接mysql出现too many connections的错误。该函数的默认设置是0,表示无限制。
myDB.SetMaxOpenConns(2000)
//用于设置最大闲置的连接数。可以避免并发太高导致连接mysql出现too many connections的错误。设置闲置的连接数则当开启的一个连接使用完成后可以放在池里等候下一次使用。
myDB.SetMaxIdleConns(1000)
//调用了Ping之后,连接池会初始化一个数据库连接。
myDB.Ping()
}
//功能:获取当前数据库myDB中tabaleName表内满足whereCondiction条件的记录条数
func GetRecordCount(tabaleName string, whereCondiction string) int32 {
//使用database/sql包的sql.Open函数创建数据库对象。它的第一个参数是数据库驱动名,第二个参数是一个连接字串。
//使用sql.Open函数创建一个连接池对象,不是单个连接。在open的时候并没有去连接数据库,只有在执行query、exce方法的时候才会去实际连接数据库。在一个应用中同样的库连接只需要保存一个sql.Open之后的db对象就可以了,不需要多次open。
//myDB, err := sql.Open("mysql", config.DatabaseSource())
//调用sql.DB.Query()方法,执行SQL SELECT语句
row, err := myDB.Query("SELECT COUNT(*) FROM `" + tabaleName + "` WHERE " + whereCondiction)
if err != nil {
logger.Error.Printf("Query failed, err:%v", err)
return -1
}
//在使用query时需要管理连接,也就是把连接释放,归还连接池,通常采用defer db.Close();而exce在执行完对数据库的操作后会自动释放。
//defer关键字是延时调用的意思,row.Close()被放入延迟调用队列,当当前函数GetRecordCount()结束时才调研row.Close()
defer row.Close()
if row == nil {
logger.Info.Println("no data")
return 0
}
var cnt int32
for row.Next() {
//使用row.Scan()获取查询到的数据,在此之前必须调用row.Next(),虽然明知只有一行
row.Scan(&cnt)
}
return cnt
}
//本函数输入一条SELECT的SQL语句,并将结果数据组装成[]map[string]interface{}结构返回;
//函数返回类型[]map[string]interface{}是map[string]interface{}类型的切片(可认为是动态数组),可存储多条记录;
//map[string]interface{}是key-value形式存储一条记录,key为字段名(string类型),value为数据值(interface{}任意类型);
//interface{}是为实现多态功能,可以放入任意类型数据;
//想要完整理解[]map[string]interface{}结构,需要先了解Go的切片(Slice)、接口(interface)、映射(map)
func GetRecordData(strSQL string) ([]map[string]interface{}, error) {
rowData := make([]map[string]interface{}, 0)
//执行SQL,结果数据集存入rows
rows, err := myDB.Query(strSQL)
if err != nil {
return rowData, err
}
//延时调用rows.Close()(再本函数执行结束时调用),释放数据库链接
defer rows.Close()
//获取列集合
columns, err := rows.Columns()
if err != nil {
return rowData, err
}
cloCount := len(columns)
//下面代码需要先了解Go的切片(Slice)、接口(interface)、映射(map)再阅读
values := make([]interface{}, cloCount)
valuePtrs := make([]interface{}, cloCount)
for rows.Next() {
for i := 0; i < cloCount; i++ {
valuePtrs[i] = &values[i]
}
rows.Scan(valuePtrs...)
entry := make(map[string]interface{})
for i, col := range columns {
var v interface{}
val := values[i]
b, ok := val.([]byte)
if ok {
v = string(b)
} else {
v = val
}
entry[col] = v
}
rowData = append(rowData, entry)
}
return rowData, nil
}
3.1.6 前端Html代码
- 首页——纯静态页面
在项目根目录下新建html文件夹,在该文件夹下新建index.html文件(即路径为./html/index.html)。
<!DOCTYPE html>
<html lang="zh-ch">
<head>
<meta charset="utf-8">
<title>主页</title>
</head>
<body>
点击登录系统-><a href="login">登录</a>
</body>
</html>
- 登录页
<html>
<head>
<title>{{ .PageTitle }}</title>
</head>
<body>
<form action="" method="post">
用户名:<input type="text" name="name"> 密 码:<input type="text" name="pswd">
<input type="submit" value="登录">
</form>
<p><b> {{ .Message }}</b></P>
</body>
</html>
- 新闻列表页
本页还需要进一步将返回的记录处理展示,这里就不展开了……
<html>
<head>
<title>新闻列表</title>
</head>
<body>
<p><b> {{ . }}</b></P>
</body>
</html>
3.1.7 MySQL表结构
CREATE TABLE `news` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(45) DEFAULT NULL,
`content` varchar(45) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE `user` (
`name` varchar(45) NOT NULL,
`password` varchar(45) DEFAULT NULL,
PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
3.2 执行程序
3.2.1 go.mod
每个模块的文件夹下都有一个go.mod文件,从 Go1.11 开始,golang 官方支持了新的依赖管理工具go mod,在相应目录下使用 go mod init 命令即可在该目录下生成go.mod文件。但 Go1.13中对工程中其他模块的引用又有较大改动,对于非标准库的模块,默认的引用路径是要求以域名开始的,比如:“github.com/go-sql-driver/mysql”,若使用“demoWeb/config”引用当前项目中的包而没在go.mod中使用“replace demoWeb/config => ./config”重定向的话,运行将会报如下错误:
go: demoWeb/config@v0.0.0-00010101000000-000000000000: malformed module path "demoWeb/config": missing dot in first path element
因此需要在go.mod中手动添加replace,如以下主目录下的go.mod:
module demoWeb
replace demoWeb/config => ./config
replace demoWeb/myDataApi => ./database
replace demoWeb/logger => ./logger
go 1.13
require (
demoWeb/config v0.0.0-00010101000000-000000000000 // indirect
demoWeb/logger v0.0.0-00010101000000-000000000000 // indirect
demoWeb/myDataApi v0.0.0-00010101000000-000000000000 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
)
上面的require中的内容,是在go run 命令运行程序后自动生成的。
以下是config目录下的go.mod,注意“…/logger”是使用相对路径。
module config
replace demoWeb/logger => ../logger
go 1.13
以下是logger目录下的go.mod:
module logger
go 1.13
以下是database目录下的go.mod:
module myDataApi
replace demoWeb/config => ../config
replace demoWeb/logger => ../logger
go 1.13
3.2.2 执行
在cmd中执行go run demoWeb.go(需用cd命令定位到当前工程目录)
D:\Develop\GoPath\src\demoWeb>go run demoweb.go
go: finding github.com/go-sql-driver/mysql v1.5.0
go: downloading github.com/go-sql-driver/mysql v1.5.0
go: extracting github.com/go-sql-driver/mysql v1.5.0
[Info]:2020/01/13 22:41:11 demoweb.go:15: 程序启动……
测试配置文件读取,读取ServerPort为: 8001
测试数据库读取,系统共有注册用户: 1 个
打开浏览器,在地址栏输入:http://localhost:8001 ,如下:
点击【登录】,进入登录页:
随便输入一个不存在的账号、密码,会显示如下页面,
若是输入正确的密码即可跳转的新闻列表页面。
4 结语
至此,完成了第一个可用于生产环境下的真正Go程序,当然,还有很多不足,但一个简单的框架是有了。