编写一个简单的 go 项目
Go 简介
Go(又称 Golang)是由 Google 开源的一门编译型、静态类型、并发友好的编程语言。它旨在提高软件开发效率,特别是在大规模系统和基础设施软件中表现优异。
Go 的语言特点如下:
- 编译速度快,接近于脚本开发体验。
- 语法简洁清晰,仅有 25 个关键字,平易上手。
- 内建并发支持,通过 goroutine 和 channel 就可以实现并发编程。
- 支持跨平台编译,一次编写,多平台部署。
- 标准库丰富,涵盖大多数常用功能。
- 支持模块化管理,让版本管理和依赖更加稳定可靠。
Go 的应用场景:
- Web 应用和 API 服务。
- 命令行工具。
- 云原生和微服务。
- 网络服务与中间件。
Go 鼓励通过构建包,来组织代码和模块复用,你可以编写自己的工具库,算法模块或通用逻辑,并在多个项目中复用,并开源给他人使用。
接下来,我们将一步步创建一个简单的 Go 包,涵盖从项目初始化到测试、发布的完整流程,帮助你快速上手 Go 的项目开发。
Go 语言快速上手
安装环境
在官网下载 Go 环境。
安装完毕后,在控制台执行 go version
,正常打印版本号就是安装成功了。
$ go version
go version go1.24.3 windows/386
Go 示例
Go 语言的代码文件采用的是 .go
后缀名。
以下是一个简单的 Go 语言示例。
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
package main
将这份文件声明为main
包。每个 Go 程序都必须有一个main
包。import "fmt"
导入 Go 标准库fmt
。func main()
声明一个名为main
的函数,main
函数是 Go 程序的入口。fmt.Println("Hello, World!")
调用fmt
模块的Println
方法打印输出。
新建一份名为 main.go
的文件,将示例代码复制进去,随后打开命令行,执行 go run main.go
就能够执行这段 Go 代码。
$ go run main.go
Hello, World!
基本语法
变量声明
变量,用于存储数据并使用。
// Go 语言中,使用 var 声明变量,变量类型后置
var name string = "ZhangSan"
// 使用海象运算符可以简略声明并自动推导变量类型
age := 18
// 使用 const 声明一个常量
const Pi = 3.14
函数
用于封装一段代码,让代码可复用而不需要重写,代码将根据参数的不同得出不同的结果。
// 使用 func 定义一个函数
// Go 语言中的函数没有默认参数
func add(num1 int, num2 int) int {
return num1 + num2
}
// 函数可以返回另一个函数
// 返回的函数会使用到外部函数的变量
// 通常这个函数被称为闭包
func create_queue() func(num int) []int {
queue := []int{}
return func(num int) []int {
queue = append(queue, num)
return queue
}
}
条件语句
Go 语言的 else
必须紧跟在 if
的花括号后面,不允许换行,否则编译器会报错。
if age > 20 {
fmt.Println("条件一通过")
} else if age > 18 {
fmt.Println("条件二通过")
} else {
fmt.Println("条件都不通过")
}
switch
语句会自上往下逐个匹配 case
,直到匹配成功。
case
默认自带 break
无需手动增加,如果需要执行下一个 case
则使用 fallthrough
关键字。
num := 3
switch num {
case 1:
fmt.Println("One")
case 2:
fmt.Println("Two")
case 3:
fmt.Println("Three")
default:
fmt.Println("Unknown")
}
switch {
case num > 0:
fmt.Println("num > 0")
case num < 0:
fmt.Println("num < 0")
}
循环
Go 语言中,循环只有 for
关键字。使用 break
,continue
分别终止循环或跳过本次循环。
for i := 0; i < 5; i++ {
fmt.Println(i)
}
for {
fmt.Println("死循环")
}
数组和切片
// 数组 长度固定 值类型 复制时会复制整份数据,所有副本互不干扰
arr := [3]int { 1, 2, 3 }
// 切片 长度不固定 引用类型 复制时只会复制指针,所有副本共享同一份数据
slice := []int { 1, 2, 3, 4 }
字典
dict := map[string]int { "a": 1, "b": 2 }
a := dict["a"]
结构体
type User struct {
// json:"name" 是结构体标签
// 可以理解为其他语言中的元数据
// 多个标签直接以空格分割, 例如 `json:"name" description:"名称"`
Name string `json:"name"`
Age int `json:"age"`
}
u := User {
Name: "ZhangSan",
Age: 18
}
name := u.Name
age := u.Age
遍历
使用 range
关键字可以对字典,数组等进行遍历取值。
func main() {
array := []int{1, 2, 3, 4, 5}
dict := map[string]int{
"one": 1,
"two": 2,
"three": 3,
"four": 4,
}
channel := make(chan int)
go func() {
channel <- 1
channel <- 2
channel <- 3
channel <- 4
channel <- 5
close(channel)
}()
for index, value := range array {
println(index, "=", value)
}
for key, value := range dict {
println(key, "=", value)
}
for value := range channel {
println(value)
}
}
接口
type People interface {
Eat(food string) string
Drink(drink string) string
}
接口不用手动继承,只要实现了某个接口的方法签名,那么 Go 就会认为实现了该接口。
type People interface {
Eat(food string) string
Drink(drink string) string
}
type ZhangSan struct{}
func (p ZhangSan) Eat(food string) string {
return "eat " + food
}
func (p ZhangSan) Drink(drink string) string {
return "drink " + drink
}
错误处理
Go 语言中没有异常机制,而是采用显式返回错误的方式。
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("除数不能为 0")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("错误:", err)
} else {
fmt.Println("结果:", result)
}
}
并发
Go 原生支持并发,采用 go
关键字开启线程。通过创建 channel
在线程之间传递消息。
通过操作符 <-
为通道输入输出消息。
通道在左侧,则为写入数据:ch <- 1
。
通道在右侧,则为输出数据:num := <- ch
。
package main
import (
"fmt" // 用于输入输出
"strconv" // 用于字符串转数字
"time" // 用于时间相关操作
)
const EXIT_NUM = -127 // 退出数字
const EXIT_MSG = "exit" // 退出消息
// 计算函数
// input: 输入数字
// output: 输出数字
// 功能: 从输入通道读取数字, 乘以2, 写入输出通道
// 如果等待 3 秒没有输入, 写入退出数字, 退出程序
func compute(input chan int, output chan int) {
for {
select {
case n := <-input:
output <- n * 2
case <-time.After(3 * time.Second):
output <- EXIT_NUM
}
}
}
// 读取函数
// label: 提示信息
// input: 输入通道
// 功能: 从标准输入读取一行, 写入输入通道
// 如果读取失败, 打印错误信息
func readline(label string, input chan string) {
fmt.Println(label)
var line string
_, err := fmt.Scanln(&line)
if err != nil {
fmt.Println(err)
}
input <- line
}
func main() {
input := make(chan int) // 输入通道
output := make(chan int) // 输出通道
user_input := make(chan string) // 用户输入通道
fmt.Println("程序开始")
fmt.Println("输入数字, 程序会将数字乘以2")
fmt.Println(fmt.Sprintf("输入 %s 退出程序", EXIT_MSG))
// 用于判断是否有输入
has_input := false
// 启动计算函数线程
go compute(input, output)
for {
// 如果没有输入, 启动读取函数线程
if !has_input {
go readline("请输入数字", user_input)
}
select {
// 如果有输入, 从用户输入通道读取输入, 写入输入通道
case content := <-user_input:
fmt.Println("输入: ", content)
if content == EXIT_MSG {
fmt.Println("退出程序")
return
}
// 尝试转换用户输入为数字
num, err := strconv.Atoi(content)
if err != nil {
fmt.Println("输入错误")
continue
}
// 如果转换成功, 写入输入通道
input <- num
// 标记有输入
has_input = true
// 如果有输出, 从输出通道读取输出, 打印输出
case result := <-output:
// 如果输出为退出数字, 打印退出信息, 退出程序
if result == EXIT_NUM {
fmt.Println("等待输入超时")
return
}
fmt.Println("输出: ", result)
// 标记没有输入
has_input = false
}
}
}
引用传递
在 Go 中,参数默认是值拷贝的,在一些参数庞大的情况下,会导致巨大开销。
使用 &
用来引用数据,使用 *
可以解引用数据。
// 会复制一份新的数据, 不会修改原数据
func change_user_info_copy(info User) User {
info.Name = "changed"
info.Age = 18
return info
}
// 会修改原数据
func change_user_info(info *User) {
info.Name = "changed"
info.Age = 18
}
func main() {
raw_user := User{
Name: "test",
Age: 12,
}
fmt.Println("原始数据: ", raw_user)
changed_user := change_user_info_copy(raw_user)
fmt.Println("复制后的数据: ", changed_user)
fmt.Println("原始数据: ", raw_user)
change_user_info(&raw_user)
fmt.Println("修改后的数据: ", raw_user)
fmt.Println("原始数据: ", raw_user)
}
忽略值
使用下划线 _
可以忽略值。
常用于返回多个值但只需要一个的情况下。
可以重复使用,不会冲突。
func info() (string, int) {
return "John", 30
}
func main() {
username1, _ := info()
username2, _ := info()
username3, _ := info()
username4, _ := info()
println(username1, username2, username3, username4)
}
延迟
使用 defer
关键字可以将操作推迟到函数结束时。
常用于资源操作,防止没有及时释放资源。
func read_file(path string) (string, error) {
// 打开文件
file, err := os.Open(path)
if err != nil {
return "", err
}
// 将文件关闭操作延迟到函数结束时执行
defer file.Close()
// 读取文件内容
buffer := make([]byte, 1024)
n, err := file.Read(buffer)
if err != nil {
return "", err
}
return string(buffer[:n]), nil
}
继承
Go 语言中没有传统 OOP 语言的继承,取而代之的是组合。
package main
type Item struct {
Name string
}
type Action interface {
Move()
}
type Car struct {
Item
}
type Plane struct {
Item
}
func (item Item) Move() {
println(item.Name, "move")
}
func main() {
car := Car{Item{"Car"}}
plane := Plane{Item{"Plane"}}
car.Move()
plane.Move()
}
虽然以上代码看着很像继承,但实际上并不是。
Car
和 Plane
本质上是嵌合了 Item
,这被成为隐式组合。
两者本质上其实是多了一个名为 Item
的匿名属性。
.Move()
在编译时会被编译器转换为 .Item.Move()
。
模块化
Go 语言中,使用 package
关键字声明一个包。
一个包可以有多个文件。
Go 语言中没有 public
,private
等访问修饰符。
取而代之的是首字母的大小写。
通过控制首字母的大小写来决定是否可被外部访问。
- 首字母大写代表可以在包外部被访问。
- 首字母小写则只能在包内部进行访问。
需要注意,同一个文件夹下的 .go
文件只能是同一个 package
。
// /m/m1.go
package m
var (
name1 = "m1"
)
func M1() string {
return name1
}
// /m/m2.go
package m
var (
name2 = "m2"
)
func M2() string {
return name2
}
// /m/m3.go
package m
var (
name3 = "m3"
)
func M3() string {
return name3
}
// /main.go
package main
import (
"github.com/username/repo/m"
)
func main() {
m.M1()
m.M2()
m.M3()
}
初始化项目
执行 go mod init <模块路径>
,即可初始化一个 Go 项目。
其中,<模块路径>
通常为类似 github.com/username/repo
的格式。这种命名方式源于 Go 的模块管理机制,它要求使用唯一的模块路径,并推荐使用代码托管平台(如 GitHub)上的完整仓库地址,以便于模块的依赖管理和版本控制。
例如 Go 的后端框架 Gin
,它的模块路径就是 github.com/gin-gonic/gin
,安装命令为 go get -u github.com/gin-gonic/gin
。
初始化之后,会在执行命令的路径下生成一个 go.mod
文件。它相当于前端项目的 package.json
,Rust 项目的 Cargo.toml
。
下面是 Gin
的 go.mod
的例子:
module github.com/gin-gonic/gin
go 1.23.0
require (
github.com/bytedance/sonic v1.13.2
github.com/gin-contrib/sse v1.1.0
github.com/go-playground/validator/v10 v10.26.0
github.com/goccy/go-json v0.10.2
github.com/json-iterator/go v1.1.12
github.com/mattn/go-isatty v0.0.20
github.com/pelletier/go-toml/v2 v2.2.4
github.com/quic-go/quic-go v0.51.0
github.com/stretchr/testify v1.10.0
github.com/ugorji/go/codec v1.2.12
golang.org/x/net v0.40.0
google.golang.org/protobuf v1.36.6
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.22.0 // indirect
)
module
字段记录这个模块的模块路径。
go
字段则是表示这个模块所需要的 Go 版本。
require
字段内容项是项目依赖,带有 // indirect
后缀的则是依赖的依赖。
需要注意的是,模块路径并非必须为 github.com/username/repo
格式,只要是唯一的标识符就可以。
如果没有发布到网络供其他人使用的打算,那么使用什么名称都可以。
以下是一个简单的例子:
module example
go 1.24.3
编写模块
首先初始化一个项目。
随便编写一些内容。
// /math/add.go
package math
func Add(a int, b int) int {
sum := a + b
return sum
}
// /print/print.go
package print
import "fmt"
func ExamplePrint(word string, n int) {
for i := 0; i < n; i++ {
fmt.Println(word)
}
}
// /main.go
package main
import (
math "github.com/username/repo/math"
print "github.com/username/repo/print"
)
func main() {
print.ExamplePrint("hello world", 3)
math.Add(1, 2)
}
编写测试文件
Go 语言自带测试功能。
测试文件的命名要求以 _test.go
结尾。
且测试函数也有要求:
- 单元测试函数命名要求符合
TestXxx
格式。参数必须是*testing.T
类型。 - 基准测试函数命名要求符合
BenchmarkXxx
格式。参数必须是*testing.B
类型。 - 示例函数命名要求符合
ExampleXxxx
其中,Xxxx
必须是对应示例函数的函数名称。且没有函数参数。
// /math/add_test.go
package math
import "testing"
func TestOneAddTwo(t *testing.T) {
num1 := 1
num2 := 2
expected := 3
actual := Add(num1, num2)
if actual != expected {
t.Errorf("Add(%d, %d) = %d; expected %d", num1, num2, actual, expected)
}
}
// /print/print_test.go
package print
import (
"bytes"
"io"
"os"
"testing"
)
func TestPrintThreeHelloWorld(t *testing.T) {
old := os.Stdout
defer func() { os.Stdout = old }()
// 创建一个 bytes.Buffer 来捕获输出
var buf bytes.Buffer
r, w, _ := os.Pipe()
os.Stdout = w
ExamplePrint("hello world", 3)
// 关闭写入端并读取输出
w.Close()
io.Copy(&buf, r)
output := buf.String()
expected := "hello world\nhello world\nhello world\n"
if output != expected {
t.Errorf("输出不一致,期望: %q,实际: %q", expected, output)
}
}
func ExampleExamplePrint() {
ExamplePrint("hello world", 3)
// Output:
// hello world
// hello world
// hello world
}
编写完毕后,执行 go test ./...
即可进行测试。
./...
是 Go 的路径通配符,表示当前目录与其所有子目录。
执行命令时,Go 会遍历当前目录和所有子目录,找到所有 _test.go
结尾文件。
编写命令行命令
新建 /cmd/xxx
目录,其中,xxx
就是你希望在命令行使用的命令名称。
以下是一个简单的示例:
// /cmd/cwd/main.go
package main
import (
"fmt"
"log"
"os"
)
func main() {
dir, err := os.Getwd()
if err != nil {
log.Fatalf("get working directory failed: %v", err)
}
fmt.Println("current working directory: ", dir)
}
在发布包之后,运行 go install github.com/username/repo
后,就能够安装命令行命令了。
发布包
在 GitHub (或其他平台)上创建于 go.mod
中 module
字段对应的 repo
后,将项目上传即可供他人使用。
Go 语言通过语义化版本(Semantic Versioning,格式为 vX.Y.Z
)来管理模块版本。
执行 go get gitub.com/username/repo
后:
如果仓库具有 vX.Y.Z
的 Tag,那么将获取最新的那个 Tag 版本。
否则将使用默认分支的最新提交,并生成一个伪版本来进行管理。