手把手带你写一个小工具

​ 这篇文章带大家动手实现一个小工具,能够将一个 Go 文件中的注释。

​ 起因是这样的,我在写文章的时候,最后需要附上项目的源码,然后就发现我在写代码的时候加了很多注释,然后需要自己手动注释很麻烦,于是就想着写这样一个工具,去代替手动删除注释这一项工作。

​ 当然我知道可以用 AI 来做这件事,但我就是想写一个工具,别烦!

需求分析

​ 首先我们要清楚,Go 语言中的注释分为两种:单行注释和多行注释。

​ 单行注释,是采用 // .... 的形式,将注释写在 // 的后面;

​ 而多行注释,是采用 /* ... */ 的方式,将注释写在 /**/ 之间的。

​ 我们的目的是将一个 Go 文件的注释删除,并出到另一个文件中。为什么这里不直接修改源文件?因为避免程序出错,导致源文件的代码丢失。

​ 那这个过程中就会涉及到文件的打开关闭以及写入、如何正确识别单行注释和多行注释、如何处理一些特殊情况等问题,下面我们会一一展开说明。

代码实现

​ 由于这只是一个小工具,我们就把所有的代码全部写在一个 main.go 文件就足够了。

​ 我们的实现过程,大概是这样的:

  1. 通过命令行指定需要处理的 Go 文件

  2. 根据文件名调用对应的功能函数去进行处理

    a. 打开文件

    b. 删除注释和空行

    c. 重新构建一个新的文件,并将处理后的结果写入文件中

    d. 控制台输出打印成功

  3. 如果出现错误,就在控制台中打印信息

​ 下面我们就根据上面的步骤一步一步实现我们的小工具。

获取文件

​ 首先,我们在项目根目录下创建一个 main.go 文件和 todo.go 文件。可以提前在 todo.go 文件中放一段带有注释的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"fmt"
"os"
)

// saveToFile 保存数据到文件
// 参数:
// filePath: 要保存的文件路径
// data: 要保存的数据
// 返回值:
// error: 如果保存成功,则为nil;否则为保存失败的错误信息
func saveToFile(filePath string, data string) error {
// 使用 os.WriteFile 函数将数据写入文件
err := os.WriteFile(filePath, []byte(data), 0644)
if err != nil {
// 如果写入文件出错,返回错误信息
return fmt.Errorf("保存文件失败:%v", err)
}

// 文件保存成功,返回nil表示没有错误
return nil
}

func main() {
// 要保存的数据
dataToSave := "Hello, this is some data to be saved to a file."

// 保存数据到文件
err := saveToFile("output.txt", dataToSave)
if err != nil {
// 如果保存失败,打印错误信息
fmt.Println(err)
return
}

// 文件保存成功
fmt.Println("文件保存成功!")
}

​ 然后就可以写我们的主函数代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
if len(os.Args) < 2 {
fmt.Println("请提供要处理的Go文件")
return
}

filePath := os.Args[1]

err := removeComments(filePath)
if err != nil {
fmt.Printf("错误: %v\n", err)
}
}
  1. 首先判断是否指定了待处理的文件:这里直接通过判断命令行参数的个数判断即可
  2. 然后将文件的路径取出,传入 removeComments 函数即可
  3. 如果出错了,就打印对应的信息

​ 然后我们来看看 removeComments 函数的实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func removeComments(filePath string) error {
// 打开文件
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()

// 删除注释 和 空行 并保存
output, err := removeNote(file)
if err != nil {
return err
}

// 将结果保存到新文件中
outputFilePath := "output.txt"
err = os.WriteFile(outputFilePath, []byte(output), 0644)
if err != nil {
return err
}

// 打印操作成功的消息
fmt.Printf("注释已删除,并已保存到 %s 文件中。\n", outputFilePath)

return err
}

​ 这里相当于一个代理层,并没有将核心的删注释逻辑写在这里,这个函数只是做一个文件的打开关闭,以及将处理后的文件写入新文件的操作:

  1. 打开文件,并在检查是否发生错误
  2. 调用 removeNote 函数删除注释和空行,并返回一个字符串
  3. 将字符串转换为字节数组,写入新文件
  4. 最后打印操作成功的提示信息
核心逻辑

​ 我们的核心处理逻辑,就在 removeNote 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func removeNote(file *os.File) (string, error) {
// 创建一个新的扫描器,用于逐行读取文件内容
scanner := bufio.NewScanner(file)
// 用于跟踪是否在多行注释中
inMultilineComment := false
var output string

// 逐行扫描文件内容
for scanner.Scan() {
line := scanner.Text()
// 处理多行注释
if inMultilineComment {
// 查找多行注释结束标记 "*/"
endIndex := regexp.MustCompile("\\*/").FindStringIndex(line)
if endIndex != nil {
// 多行注释结束,更新标志并截取剩余部分
inMultilineComment = false
//isDelete = true
line = line[endIndex[1]:]
} else {
// 如果当前行还在多行注释中,跳过处理并继续下一行
continue
}
}

// 处理单行注释和多行注释的开始
lineWithoutComments, _ := processLine(line, &inMultilineComment)

if hasNonSpaceCharacters(lineWithoutComments) {
// 将处理后的行添加到结果字符串
output += lineWithoutComments + "\n"
}
}

// 检查扫描文件时是否发生错误
if err := scanner.Err(); err != nil {
return "", err
}
return output, nil
}
  1. 首先我们创建一个扫描器,从传入的文件指针开始逐行读取文件

  2. 创建一个标志位,用于跟踪是否存在多行注释

  3. 开始逐行扫描内容

    a. 先对多行注释进行处理,因为可能在本行的前面几行就已经开启了多行注释,所以需要先判断多行注释的结尾。判断的逻辑就是利用正则表达式寻找本行是否存在 */,存在的话,就通过下标截取*/之前的部分,并更新标志位

    b. 然后再对单行注释和多行注释的处理

    c. 处理完成之后,将非空行添加到输出output

    d. 最后再检查一下扫描文件时,是否发生错误即可

​ 最后我们再来看看 processLine 函数,这里就是对单行注释和多行注释的判断,其实本质都是一样的,都是通过正则表达式找到对应的符合 ///* ,然后再进行内容的截取,最后将截取的内容返回即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func processLine(line string, inMultilineComment *bool) (string, bool) {
// 该行是否需要删除:单行注释 或者 多行注释的时候
// 处理单行注释
index := regexp.MustCompile("//").FindStringIndex(line)
if index != nil {
// 截取注释之前的部分
if index[0] != 0 && line[index[0]-1] == '"' {

} else {
line = line[:index[0]]
}
}

// 处理多行注释的开始
startIndex := regexp.MustCompile("/\\*").FindStringIndex(line)
if startIndex != nil {
// 进入多行注释状态,并截取注释之前的部分
*inMultilineComment = true
// 查找本行是不是就结束了
isEnd := false
endIndex := regexp.MustCompile("\\*/").FindStringIndex(line)
if endIndex != nil {
// 多行注释结束,更新标志并截取剩余部分
*inMultilineComment = false
isEnd = true
}
if isEnd {
line = line[:startIndex[0]] + line[endIndex[1]:]
} else {
line = line[:startIndex[0]]
}
}

// 返回处理后的行和更新后的多行注释状态
return line, *inMultilineComment
}
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
package main

import (
"bufio"
"fmt"
"os"
"regexp"
"strings"
)

func main() {
if len(os.Args) < 2 {
fmt.Println("请提供要处理的Go文件")
return
}

filePath := os.Args[1]

err := removeComments(filePath)
if err != nil {
fmt.Printf("错误: %v\n", err)
}
}

func removeComments(filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()

output, err := removeNote(file)
if err != nil {
return err
}

outputFilePath := "output.txt"
err = os.WriteFile(outputFilePath, []byte(output), 0644)
if err != nil {
return err
}

fmt.Printf("注释已删除,并已保存到 %s 文件中。\n", outputFilePath)

return err
}

func removeNote(file *os.File) (string, error) {
scanner := bufio.NewScanner(file)
inMultilineComment := false
var output string

for scanner.Scan() {
line := scanner.Text()

if inMultilineComment {
endIndex := regexp.MustCompile("\\*/").FindStringIndex(line)
if endIndex != nil {
inMultilineComment = false
line = line[endIndex[1]:]
} else {
continue
}
}

lineWithoutComments, _ := processLine(line, &inMultilineComment)

if hasNonSpaceCharacters(lineWithoutComments) {
output += lineWithoutComments + "\n"
}
}

if err := scanner.Err(); err != nil {
return "", err
}
return output, nil
}

func hasNonSpaceCharacters(line string) bool {
trimmed := strings.TrimSpace(line)
return trimmed != ""
}

func processLine(line string, inMultilineComment *bool) (string, bool) {
index := regexp.MustCompile("//").FindStringIndex(line)
if index != nil {
if index[0] != 0 && line[index[0]-1] == '"' {

} else {
line = line[:index[0]]
}
}

startIndex := regexp.MustCompile("/\\*").FindStringIndex(line)
if startIndex != nil {
*inMultilineComment = true
isEnd := false
endIndex := regexp.MustCompile("\\*/").FindStringIndex(line)
if endIndex != nil {
*inMultilineComment = false
isEnd = true
}
if isEnd {
line = line[:startIndex[0]] + line[endIndex[1]:]
} else {
line = line[:startIndex[0]]
}
}

return line, *inMultilineComment
}

代码执行

​ 我们利用提前创建好的 todo.go 文件,是不是使用 go run main.go todo.go 命令,就可以删除注释了?

​ 我们可以来试一下:

1
2
3
go run main.go todo.go
文件保存成功!
请提供要处理的Go文件

​ 出现了意外情况,这里并没有获取到 todo.go 文件,而是将 todo.go 文件当做需要运行的程序去运行了。

​ 解决的办法有两种:

  1. 一个是将需要删除注释的代码放在 .txt 文件中
  2. 先编译 main.go 文件,然后再将需要处理的文件当做参数运行 main.exe 文件

​ 两个办法都很容易理解,第一个的话,你把后缀名改掉,go run命令自然就不会去执行该文件了。第二个办法的话, 我们这里可以再引入一个 Makefile 文件,将多部操作进行一个合并,我们只需要使用 make 命令就可以执行我们预习定义好的命令了:

1
2
3
4
5
6
7
8
9
10
11
# Makefile

.PHONY: all

all: build run

build:
go build main.go

run:
./main.exe todo.go

​ 我们来解释一下这个文件:

  1. .PHONY: all:声明 all 是一个伪目标。伪目标通常是一些不产生实际文件的任务,而只是执行其他任务的别名。这里的 .PHONY 告诉 make 工具 all 是一个伪目标,不要去检查是否有一个文件名为 all
  2. all: build run:定义了一个名为 all 的目标,它依赖于 buildrun 两个目标。当执行 make all 时,它将首先执行 build,然后执行 run
  3. build::定义了一个名为 build 的目标。当执行 make build 时,它将执行后面的命令,即 go build main.go
  4. run::定义了一个名为 run 的目标。当执行 make run 时,它将执行后面的命令,即 ./main.exe todo.go

​ 然后我们直接使用 make allmake run 就能这个小工具了

小结

​ 这篇文章,我从实际出发,带大家手把手写了一个小工具,还涉及到 Makefile 文件的简单使用,希望对大家能够提供帮助。

​ 在平时的学习生活中,大家也可以像我这样,把遇到的一些问题,试着抽象出来,看看能不能实现一个工具,去便捷地完成它们。


手把手带你写一个小工具
http://example.com/2023/11/14/Go/项目实战/手把手带你写一个小工具/
作者
Feng Tao
发布于
2023年11月14日
更新于
2023年11月14日
许可协议