Go语言实战案例(上)
实践记录 · 2023/7/30 · 玉米哥
目录
猜数游戏
在线词典
SOCKS5代理
前言
在前两天,我在掘金平台上陆续发布了两篇文章Go语言入门指南:基础语法(上)和Go语言入门指南:基础语法(下),总字数超过一万字,本以为这两篇文章会是自我感动之作,默默无闻石沉大海无人问津,但是没想到会收到大家的评论和点赞,还有几个小伙伴表示文章很清晰易懂,这让我备受鼓舞。与此同时,也感到诚惶诚恐。
当然,我目前能够做的,就是持续输出自己对知识点的理解和思考,我写文章的目的很明确:就是给别人看的,因此评价指标也很简单,小伙伴认可的文章,就是好文章;小伙伴看了一头雾水的文章,就得回炉重造。
废话不多说,开始讲今天的东西。
以下内容主要总结于
- 字节跳动青训营后端入门 - Go语言原理与实践
- Go语言官方文档教程
- Go语言圣经
猜数游戏
课程中的第一个例子是猜数游戏,这个例子麻雀虽小,但是五脏俱全,包含了以下知识点。
- 输入的获取
- 官方库函数的使用
- 循环与选择语句结合的控制流
在这篇文章中,我假设小伙伴有其他语言的学习基础,因此不会讲解整个程序的实现过程,而是将重点放在输入的获取上。
从控制台读取输入
一个程序,可以从很多渠道获取输入,对于刚学习编程的小伙伴,接触最多的可能就是从命令行敲下自己的输入,按下回车键,然后将输入发送给程序。
输入除了来自于键盘,还可以来自本地磁盘中保存的文件,以及从网络接受到的数据。
在视频中,王克纯老师使用bufio
包中的函数来从控制台获取输入,起初,我很疑惑,为什么处理输入的方式这么麻烦。
老式处理输入的方式
在C++
中,当我想从键盘获取输入,并保存 到整型变量guess
中,我可以这样做。
C++
int guess;
cin >> guess;
我相信绝大多数小伙伴,此前也是通过这样的方式来处理的,可是示例却是这样的。
go
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
input = strings.TrimSuffix(input, "\n")
guess, err := strconv.Atoi(input)
从键盘获取输入的数字,并保存到guess
变量中,经过了两个中间变量reader
、input
。
自己难为自己,何苦呢?
直到我查阅了Go语言中关于处理输入的函数,才发现,如果还在用之前学习C
、C++
的思维去处理输入,才是真的难为自己。
Go语言中与处理输入有关的库函数。
新式处理输入的方式
如此之多的函数,对输入的处理各有不同,什么时候保留\n
,什么时候丢弃\n
,读取字符串时什么规则,读取数字时又是什么规则。纷繁复杂,无法记忆。而且记了恐怕也难以用得上,最终只会为难自己。因此,我们可以制定一种读取输入的规范,来简化输入处理的流程。
- 明确输入源
- 一次读取一整行输入
- 将读取到的输入按需处理
遵从这个规范,到现在,我们可以好好介绍下示例中的bufio
了。
明确输入源
在上文我们提到,输入可以来源于键盘、网络、文件等。在示例中,我们需要从键盘中读取数据。因此,我们创建了一个reader
,并使用os.Stdin
进行初始化。os.Stdin
指标准输入,一般情况下,标准输入与键盘相关联。这下子,我们就明确了目的:从键盘读取输入。
go
reader := bufio.NewReader(os.Stdin)
什么是reader?
Go语言的
io
包中,定义了一个接口io.Reader
,该接口中包含了一个Read
函数。如果你不懂什么是接口,这并不要紧。目前,你只需要将自己看作一名水管工人,数据就是水流。
io.Reader
是一种特殊的工具,你使用它连接到不同的水源。当你连接到一种水源后,便可以打开水龙头(调用Read
函数或类似的函数)获取水源,水流会一点点流入你的水桶,直至水桶装满或者水流完。
处理输入并存入变量
go
input, err := reader.ReadString('\n')
明确输入的来源后,就可以正式读取输入了,执行上面代码时,会按照顺序发生下面的事情。
-
程序的运行会发生阻塞,将控制权交给键盘。
-
在键盘上敲击
50<Enter>
,程序此时接收到了来自键盘的输入流50\n
,该输入流的本质是由5
0
\n
组成的一连串字节序列。 -
Reader.readString
函数对该字符串进行处理。ReadString reads until the first occurrence of delim in the input, returning a string containing the data up to and including the delimiter.
Go语言官网对该函数的介绍说,该函数读取输入的字符串,直至遇到指定的分隔符,然后返回该字符串和紧随其后的分隔符。
在示例中,我们使用变量input
接受该函数返回的结果,显而易见,input
变量中包含了字符串50\n
。
按需处理保存的字符串
然而我们想要的是数字50,需要使用strconv.Atoi
函数将字符串转换为数字,在这之前,还需要去掉字符串的后缀\n
,再做转换。
go
input = strings.TrimSuffix(input, "\n")
guess, err := strconv.Atoi(input)
大功告成!我们终于得到了魂牵梦索的guess
变量,后续就是与猜数游戏控制流相关的逻辑,这里不再赘述了。
从字符串读取输入
为什么要浪费大把的墨水来写仅仅一个guess
变量的读取呢,如果你认真读完了上面的内容,现在就到了触类旁通的时候了。我们按照一样的规定从字符串中读取输入。
明确输入源
我们很明确,需要从字符串中读取输入,因此,我们应该创建一个从字符串读取输入的reader
。
go
greeting := "你好,字节跳动"
reader := strings.NewReader(greeting)
strings.NewReader
函数接受一个字符串,返回一个reader。
使用Read
函数读取字符串
当我们从输入源获取数据后,现在是时候将数据保存在一些地方了,需要一个类似guess
的变量来充当水桶接水。
go
// bottle是一个字节切片,大小为3字节
bottle := make([]byte, 3)
注意,UTF-8编码的中文字符使用3个字节表示。
使用Read
函数(打开水龙头),将输入源的数据保存到变量bottle
中(用指定大小的瓶子装水)。
第一次调用Read
函数
go
// 从reader中读取3个字节的数据,填充满bottle变量
// bottle == "你"
// bytesRead == 3,表示本次从reader读取了3个字节的数据
// err == nil,表示读取正常
bytesRead, err := reader.Read(bottle)
到目前为止,reader
中的数据已经被取走了3个字节,被保存到bottle
中。仔细一想,reader中的数据并没有被取完(水源中还有水)。
我们可以再次调用Read
函数来读取输入源中剩下的数据。
第二次调用Read
函数
go
// 从reader继续读取3个字节的数据,填充满bottle变量
// bottle == "好"
// bytesRead == 3,表示本次从reader读取了3个字节的数据
// err == nil,表示读取正常
bytesRead, err = reader.Read(bottle)
有心的小伙伴想一想,总共需要调用几次Read
函数才能将reader
中的数据读取完毕呢?
答案就是7
次。因为我们的输入源,也就是字符串你好,字节跳动
一共有7
个中文字符,总计21
个字节。而我们的bottle
变量每次只能存储3
个字节的数据,因此一共需要读取7
次。
使用for循环读取
为了省时省力省心,我们可以使用for循环来读取reader中的数据。基本原理同上。
go
for {
bytesRead, err := reader.Read(bottle)
if err != nil {
break;
}
fmt.Printf("本次读取的字节数 %v 本次读取的内容 %v 本次读取的状态 %v\n", bytesRead, bottle, err)
}
请尝试运行,你会得到下面的结果。
cmd
本次读取的字节数 3 本次读取的内容 [228 189 160] 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 [229 165 189] 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 [239 188 140] 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 [229 173 151] 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 [232 138 130] 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 [232 183 179] 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 [229 138 168] 本次读取的状态 <nil>
奇了怪了,为什么读取的内容是一个字节切片呢?
请回头看看,我们的
bottle
变量就是大小为3个字节的字节切片。
有伙伴可能会问,会什么我们不直接将bottle定义为string
类型呢?
因为
Read
函数的参数必须使用[]bytes
,也就是字节切片。
那我们该如何得到正常的字符串呢,答案也很简单,使用%s
替换%v
即可。
cmd
本次读取的字节数 3 本次读取的内容 你 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 好 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 , 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 字 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 节 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 跳 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 动 本次读取的状态 <nil>
最后一个问题,是什么原因使得这个死循环停下来了呢?从结果来看,当字符串读取完毕时,循环就停下来了。其实原因就在err
身上。第七次调用Read
函数后,数据已经被取完了,第八次调用Read
函数时,已经没有数据了,Read
函数会返回io.EOF
,表示读取状态为已经读取完毕。很显然这个值不等于nil
,于是控制流跳出了循环。
从文件读取输入
如果你耐心看到了这里,那么你对reader
应该有了更为深刻的认识了。现在我们按照同样的准则读取文件。
在go源文件目录下新建文本文件sample.txt
。
txt
你好,字节跳动。字节和心脏,只能有一个跳动。先进团队,先用飞书。抖音,记录美好生活。
- 创建一个
reader
,同时指明输入源是文件。
go
fileReader, err := os.Open("sample.txt")
if err != nil {
log.Fatal(err)
}
- 使用
Read
函数读取。
go
bottle := make([]byte, 3)
for {
bytesRead, err := fileReader.Read(bottle)
if err != nil {
break
}
fmt.Printf("本次读取的字节数 %v 本次读取的内容 %s 本次读取的状态 %v\n", bytesRead, bottle, err)
}
结果呈现。
cmd
本次读取的字节数 3 本次读取的内容 你 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 好 本次读取的状态 <nil>
......(省略中间的输出)......
本次读取的字节数 3 本次读取的内容 生 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 活 本次读取的状态 <nil>
本次读取的字节数 3 本次读取的内容 。 本次读取的状态 <nil>
更进一步
写到这里,小伙伴应该对输入的读取没有问题了,可是,我想一次性读取整个文件,而不是每次都读取这三瓜两枣,我该怎么办?
回到对reader
的讨论,在本篇文章的例子中,我们使用了这些方法创建一个reader
。
reader := bufio.NewReader(os.Stdin)
reader := strings.NewReader(greeting)
fileReader, err := os.Open("sample.txt")
我们使用了这些方法读取对应的reader
。
-
input, err := reader.ReadString('\n')
-
bytesRead, err := reader.Read(bottle)
-
bytesRead, err := fileReader.Read(bottle)
很明显,第二、三个reader
使用了同样的读取函数Read
,也是所有reader
都具有的共同函数。你可以自己试一试,第一个reader
也可以使用该Read
函数。
那第一个reader
使用的ReadString
函数是怎么回事呢,其实,这是bufio
对reader
做了一层包装,添加了一些其他的函数,方便对输入源进行不同的处理。
比如,ReadString
函数就可以接受一个参数作为分割符,将分隔符之前的输入包括分隔符返回到一个变量中。
那输入源是字符串和文件的reader
就注定没办法使用ReadString
函数了吗?
其实不是,我们只需要使用bufio
对它俩的reader
做一个包装即可。
go
strReader, err := strings.NewReader(greeting)
bufReader, err := bufio.NewReader(strReader)
go
fileReader, err := os.Open("sample.txt")
bufReader, err := bufio.NewReader(fileReader)
bufio
可以接受一个reader
参数并对其包装,返回一个新的reader
。
现在我们的bufReader
可以接受来自键盘、字符串、文件的输入源,并使用ReadString
函数读取了。
到这里,我们还是没有找到一次性读取整个文件的办法。小伙伴们可以尝试一下,在上文创建的三种reader
中,找不到一个类似reader.ReadAll
可以读取全部字符串的函数。
幸运的是,io.ReadAll
函数可以接受reader
参数,并以字节切片的形式返回reader
中的所有数据。
go
fileReader, err := os.Open("sample.txt")
if err != nil {
log.Fatal(err)
}
allBytes, err := io.ReadAll(fileReader)
if err != nil {
return
}
fmt.Printf("%s", allBytes)
下一步
本篇文章到这里就结束了,由于篇幅的限制,我只讲解了猜数游戏相关的话题,并将重点放在了输入的获取上。下一篇,我将讲解客户端与服务器通信的过程,会涉及到HTTP协议,如果你没有学过计算机网络,那也不要紧,在这里,我推荐这本书《图解HTTP》。了解了HTTP相关的知识后,学习Web开发也会变得更加容易,如果你无法找到这本书的电子版本,出于学习或者研究的目的,可以向我咨询。
码字不易,如果您看到了这里,听我说谢谢你😀
如果您觉得本文还不错,请留下小小的赞😀
如果您有感而发,请留下宝贵的评论😀