本文将继续分享如何借助当下流行的 AI 工具,一步步完成一个开源项目的开发。
写在前面
在上一篇文章《使用 AI 辅助开发一个开源 IP 信息查询工具:一》中,我们已经将初版代码推送到了开源项目 soulteary/ip-helper。
目前项目已完成了核心功能逻辑、界面设计和数据联动,但还有一些待优化的部分:
- 需要支持更多的查询协议(参考工具原本支持但目前尚未实现)
- 代码结构需要优化:目前所有代码都在同一个文件中,便于快速原型开发,但不利于后期维护,缺少模块拆分和单元测试覆盖
- 需要完善项目文档,包括项目介绍,基础使用和配置参数等内容
- 以及针对接近完成品的项目,进行一些性能调优
接下来,让我们一步步完善这些内容。
为应用添加 TELNET 支持
我们继续通过和模型对话的方式,为应用添加 TELNET 协议支持:“使用 Golang 实现最简单的 telnet 服务端,当用户连接时,输出 Hello World,输出完毕后断开链接。”
将获得的代码整合到现有程序中,我们的应用就具备了同时提供 Web 服务和 TELNET 服务的能力了。
// ...
// Telnet 服务器
func TelnetServer() {
// telnet默认端口是 23
listener, err := net.Listen("tcp", ":23")
if err != nil {
fmt.Printf("无法启动telnet服务器: %v\n", err)
return
}
defer listener.Close()
fmt.Println("Telnet服务器已启动,监听端口 23...")
// 持续接受新的连接
for {
conn, err := listener.Accept()
if err != nil {
fmt.Printf("接受连接时发生错误: %v\n", err)
continue
}
// 为每个连接创建一个新的goroutine
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer conn.Close()
// 获取客户端 IP 地址
clientIP := conn.RemoteAddr().String()
// 发送消息给客户端
message := fmt.Sprintf("Hello World\n您的 IP 地址是: %s\n", clientIP)
_, err := conn.Write([]byte(message))
if err != nil {
fmt.Printf("发送消息时发生错误: %v\n", err)
return
}
}
func main() {
// ...
// 启动 Telnet 服务器
go TelnetServer()
serverAddr := fmt.Sprintf(":%s", config.Port)
log.Printf("启动服务器于 %s:%s\n", config.Domain, config.Port)
if err := r.Run(serverAddr); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
r.Run(":8080")
}
再次运行程序后,程序会在日志中打印出连接的客户端 IP,并向用户发送 “Hello World” 问候语。
为了让 TELNET 服务也能像 Web 服务一样返回客户端 IP 的详细信息,我们需要对 IP 数据库查询模块做一些封装,将 IP 信息获取抽象为一个通用函数。这里我们实现了一个名为 getClientIPInfo
的工具函数,它可以查询指定 IP 的信息,也可以直接使用会话中的客户端 IP 进行查询。
// ...
type IPDB struct {
IPIP *ipdb.City
}
// 初始化 IPDB 数据库实例
func initIPDB() IPDB {
db, err := ipdb.NewCity("./data/ipipfree.ipdb")
if err != nil {
log.Fatal(err)
}
return IPDB{IPIP: db}
}
// 根据 IP 地址查询信息(IPIP 数据库)
func (db IPDB) FindByIPIP(ip string) []string {
info, err := db.IPIP.Find(ip, "CN")
if err != nil {
info = []string{"未找到 IP 地址信息"}
}
return removeDuplicates(info)
}
func main() {
// ...
// 初始化 IPDB 数据库
ipdb := initIPDB()
// ...
getClientIPInfo := func(c *gin.Context, ipaddr string) (resultIP string, resultDBInfo []string, err error) {
if ipaddr == "" {
ipInfo, exists := c.Get("ip_info")
if !exists {
return resultIP, resultDBInfo, fmt.Errorf("IP info not found")
}
ipaddr = ipInfo.(IPInfo).RealIP
}
// 简化 IP 地址查询
dbInfo := ipdb.FindByIPIP(ipaddr)
return ipaddr, dbInfo, nil
}
// ...
}
现在,我们将这两部分代码结合起来,当用户通过 TELNET 协议访问服务时,程序会查询并返回访问者 IP 的详细信息。
// ...
// 从包含端口的地址中,获取客户端 IP 地址
func getBaseIP(addrWithPort string) string {
host, _, err := net.SplitHostPort(addrWithPort)
if err != nil {
return ""
}
return host
}
// 生成 JSON 数据
func renderJSON(ipaddr string, dbInfo []string) map[string]any {
return map[string]any{"ip": ipaddr, "info": dbInfo}
}
// 添加 IPDB 参数
func TelnetServer(ipdb *IPDB) {
listener, err := net.Listen("tcp", ":23")
if err != nil {
fmt.Printf("无法启动telnet服务器: %v\n", err)
return
}
defer listener.Close()
fmt.Println("Telnet服务器已启动,监听端口 23...")
for {
conn, err := listener.Accept()
if err != nil {
fmt.Printf("接受连接时发生错误: %v\n", err)
continue
}
// 将 IPDB 参数传入处理函数
go handleConnection(ipdb, conn)
}
}
// 添加 IPDB 参数
func handleConnection(ipdb *IPDB, conn net.Conn) {
defer conn.Close()
// 去除端口号的 IP 地址
clientIP := getBaseIP(conn.RemoteAddr().String())
// 去除端口号,查询详细信息
info := ipdb.FindByIPIP(clientIP)
// 发送消息给客户端
sendBuf := [][]byte{}
message, err := json.Marshal(renderJSON(clientIP, info))
// 发生错误时,打印错误信息
if err != nil {
fmt.Println("序列化 JSON 数据时发生错误: ", err)
return
}
// 添加消息到发送缓冲区,确保消息以 CRLF 结尾
sendBuf = append(sendBuf, message)
sendBuf = append(sendBuf, []byte("\r\n"))
_, err = conn.Write(bytes.Join(sendBuf, []byte("")))
if err != nil {
fmt.Printf("发送消息时发生错误: %v\n", err)
return
}
}
type IPDB struct {
IPIP *ipdb.City
}
// 初始化 IPDB 数据库实例
func initIPDB() IPDB {
db, err := ipdb.NewCity("./data/ipipfree.ipdb")
if err != nil {
log.Fatal(err)
}
return IPDB{IPIP: db}
}
// 根据 IP 地址查询信息(IPIP 数据库)
func (db IPDB) FindByIPIP(ip string) []string {
info, err := db.IPIP.Find(ip, "CN")
if err != nil {
info = []string{"未找到 IP 地址信息"}
}
return removeDuplicates(info)
}
func main() {
// ...
// 将 IPDB 参数传入 TelnetServer
go TelnetServer(&ipdb)
serverAddr := fmt.Sprintf(":%s", config.Port)
log.Printf("启动服务器于 %s:%s\n", config.Domain, config.Port)
if err := r.Run(serverAddr); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
r.Run(":8080")
}
完成这些工作后,执行 go run main.go
启动程序,我们会看到如下日志:
Telnet服务器已启动,监听端口 23...
此时使用 telnet
命令访问服务,就能获取到基础信息了:
# telnet 127.0.0.1
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
{"info":["本机地址",""],"ip":"127.0.0.1"}
Connection closed by foreign host.
同时,服务端日志也会记录下这次访问:
2024/12/22 09:41:35 Client connected from 127.0.0.1:64008
2024/12/22 09:41:35 Connection closed for 127.0.0.1:64008
2024/12/22 09:41:38 Client connected from 127.0.0.1:64011
2024/12/22 09:41:38 Connection closed for 127.0.0.1:64011
为应用添加 FTP 支持
在完成了 TELNET 协议的功能实现后,让我们继续为这个程序添加 FTP 协议支持。我们先来开发一个最简单的 FTP 服务,当用户连接时,只显示欢迎信息和用户 IP,然后关闭连接。
依旧是和模型对话,指定编程任务:“使用 golang实现最简单的FTP服务,用户连接的时候,输出 Hello World 和用户IP,然后结束连接。”
上面这段代码实现了一个基础的 FTP 服务器,我们将得到的代码和原始程序进行结合:
// ...
// FTPServer 启动一个简单的 FTP 服务器
func FTPServer() {
// 创建监听地址,默认使用 21 端口
listener, err := net.Listen("tcp", ":21")
if err != nil {
log.Fatalf("Error creating server: %v", err)
}
defer listener.Close()
log.Println("FTP Server listening on :21")
// 持续监听连接
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Error accepting connection: %v", err)
continue
}
// 为每个连接创建一个新的 goroutine
go handleFTPConnection(conn)
}
}
// 处理 FTP 连接
func handleFTPConnection(conn net.Conn) {
defer conn.Close()
// 获取客户端IP地址
remoteAddr := conn.RemoteAddr().String()
// 发送欢迎消息
welcomeMsg := fmt.Sprintf("220 Hello World! Your IP is: %s\r\n", remoteAddr)
_, err := conn.Write([]byte(welcomeMsg))
if err != nil {
log.Printf("Error sending welcome message: %v", err)
return
}
log.Printf("Client connected from %s", remoteAddr)
// 强制关闭连接
conn.Close()
log.Printf("Connection closed for %s", remoteAddr)
}
func main() {
// ...
// 添加 FTP 服务器
go FTPServer()
serverAddr := fmt.Sprintf(":%s", config.Port)
log.Printf("启动服务器于 %s:%s\n", config.Domain, config.Port)
if err := r.Run(serverAddr); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
r.Run(":8080")
}
当用户连接时,服务器会显示欢迎信息和用户的 IP 地址。如果执行 go run main.go
,将看到以下输出:
2024/12/22 09:48:15 FTP Server listening on :21
接下来,我们需要像之前处理 TELNET 协议一样,对这个基础程序进行改造,添加 IP 信息查询功能。
// ...
// 增加 IPDB 参数
func FTPServer(ipdb *IPDB) {
listener, err := net.Listen("tcp", ":21")
if err != nil {
log.Fatalf("Error creating server: %v", err)
}
defer listener.Close()
log.Println("FTP Server listening on :21")
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Error accepting connection: %v", err)
continue
}
go handleFTPConnection(ipdb, conn)
}
}
// 增加 IPDB 参数
func handleFTPConnection(ipdb *IPDB, conn net.Conn) {
defer conn.Close()
// 获取客户端IP地址
clientIP := getBaseIP(conn.RemoteAddr().String())
info := ipdb.FindByIPIP(clientIP)
// 将 IP 地址信息发送给客户端
sendBuf := [][]byte{}
message, err := json.Marshal(renderJSON(clientIP, info))
if err != nil {
fmt.Println("序列化 JSON 数据时发生错误: ", err)
return
}
sendBuf = append(sendBuf, []byte("220"))
sendBuf = append(sendBuf, message)
sendBuf = append(sendBuf, []byte("\r\n"))
_, err = conn.Write(bytes.Join(sendBuf, []byte(" ")))
if err != nil {
log.Println("发送消息时发生错误: ", err)
return
}
conn.Close()
}
func main() {
// ...
// 将 IPDB 实例传递给 FTP 服务器
go FTPServer(&ipdb)
serverAddr := fmt.Sprintf(":%s", config.Port)
log.Printf("启动服务器于 %s:%s\n", config.Domain, config.Port)
if err := r.Run(serverAddr); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
r.Run(":8080")
}
要测试这个服务,我们需要一个 FTP 客户端。如果你的系统环境中有 ftp
客户端或者命令行工具,可以直接使用 ftp
。如果你使用的是 macOS,可以通过 Homebrew 安装 ncftp
:
brew install ncftp
安装完成后,可以使用下面的命令连接并测试服务:
ncftp <服务器IP地址>
当我们执行命令后,在程序日志输出中,能够观察到程序执行结果是符合预期的:
# ncftp 127.0.0.1
NcFTP 3.2.7 (Jan 01, 2024) by Mike Gleason (http://www.NcFTP.com/contact/).
Connecting to 127.0.0.1...
{"info":["本机地址",""],"ip":"127.0.0.1"}
Remote host has closed the connection.
^Ceeping 20 seconds...
完善界面呈现
在完成基础功能后,我们需要优化界面展示,使其能够根据不同场景(Web、TELNET、FTP)正确展示 IP 信息获取方式。让我们来看看具体要做哪些改进。
为了提升维护性,我们首先需要根据不同使用场景对 DOMAIN 占位符进行细分。
对于 Web 场景,我们需要支持 http(s)://
协议,可以隐藏端口号并允许携带路径,这样可以更好地配合 Nginx 和负载均衡工具使用。而在命令行场景下(比如使用 CURL),我们允许省略网络协议,同时支持显式声明端口。对于 TELNET 和 FTP 这样的场景,我们去掉协议头,并支持使用默认端口。
接下来,让我们借助模型来实现域名处理函数。我们的需求是:“使用 Golang 实现函数,能够从类似 http://lab.com:8012
的字符串中提取两种格式,仅域名(lab.com
) 和域名带端口 (lab.com:8012
) ”。
关键代码如下:
// ...
// GetDomainOnly 返回 URL 中的域名部分,不包含端口号和路径
func GetDomainOnly(urlStr string) string {
// 如果 URL 不包含协议,添加临时协议以便解析
if !strings.Contains(urlStr, "://") {
urlStr = "http://" + urlStr
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
// 返回 Host 中的域名部分(去掉端口号)
host := parsedURL.Hostname()
return host
}
// GetDomainWithPort 返回URL中的域名和端口号(如果存在),不包含路径
func GetDomainWithPort(urlStr string) string {
// 如果 URL 不包含协议,添加临时协议以便解析
if !strings.Contains(urlStr, "://") {
urlStr = "http://" + urlStr
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
// 返回完整的 Host 部分(包含域名和端口号)
return parsedURL.Host
}
func main() {
// ...
renderTemplate := func(c *gin.Context, globalTemplate []byte, ipaddr string, dbInfo []string) []byte {
template := bytes.ReplaceAll(globalTemplate, []byte("%IP_ADDR%"), []byte(ipaddr))
template = bytes.ReplaceAll(template, []byte("%DOMAIN%"), []byte(config.Domain))
template = bytes.ReplaceAll(template, []byte("%DATA_1_INFO%"), []byte(strings.Join(removeDuplicates(dbInfo), " ")))
template = bytes.ReplaceAll(template, []byte("%DOCUMENT_PATH%"), []byte(c.Request.URL.Path))
// 更新模板中的占位符
template = bytes.ReplaceAll(template, []byte("%ONLY_DOMAIN%"), []byte(GetDomainOnly(config.Domain)))
template = bytes.ReplaceAll(template, []byte("%ONLY_DOMAIN_WITH_PORT%"), []byte(GetDomainWithPort(config.Domain)))
return template
}
// ...
}
将生成的代码整合到现有程序中后,我们可以通过设置启动参数或者环境变量,来验证功能是否正常:
SERVER_DOMAIN=https://lab.com:8080 go run main.go
程序运行后,我们在浏览器中可以看到界面中的域名部分已经能够根据不同的使用场景进行优化展示了。
代码模块化和单元测试
虽然目前我们的程序只有 500 多行代码,看起来并不复杂,但考虑到功能模块之间存在交互依赖,以及未来可能的功能扩展和长期维护需求,我们还是需要对代码进行模块拆分,并为核心功能添加单元测试。让我们从配置参数解析模块开始,你可以选择根据个人偏好手动拆分,或者将代码提供给模型,获取一个基础的参数配置模块实现。
让我们跟着程序的执行逻辑出发,从解析配置参数模块开始吧,你可以选择根据自己的喜好,或者将 500 行代码都扔给模型,得到一个最简的参数配置模块的代码。
package configParser
import (
"flag"
"log"
"os"
"strings"
"github.com/soulteary/ip-helper/model/define"
)
func Parse() *define.Config {
config := &define.Config{}
// 先读取环境变量
debug := strings.ToLower(os.Getenv("DEBUG"))
port := os.Getenv("SERVER_PORT")
domain := os.Getenv("SERVER_DOMAIN")
token := os.Getenv("TOKEN")
// 设置命令行参数默认值,如果环境变量存在则使用环境变量值
defaultDebug := debug == "true"
defaultPort := "8080"
if port != "" {
defaultPort = port
}
defaultDomain := "http://localhost:8080"
if domain != "" {
defaultDomain = domain
}
defaultToken := token
// 解析命令行参数,会覆盖环境变量的值
flag.BoolVar(&config.Debug, "debug", defaultDebug, "调试模式")
flag.StringVar(&config.Port, "port", defaultPort, "服务器端口")
flag.StringVar(&config.Domain, "domain", defaultDomain, "服务器域名")
flag.StringVar(&config.Token, "token", defaultToken, "API 访问令牌")
flag.Parse()
// 处理特殊的空值情况
if config.Port == "" {
config.Port = "8080"
}
if config.Domain == "" {
config.Domain = "http://localhost:8080"
}
// 输出相关日志
if config.Debug {
log.Println("调试模式已开启")
}
if config.Token == "" {
log.Println("提醒:为了提高安全性,可以设置 `TOKEN` 环境变量或 `token` 命令行参数")
}
return config
}
相比较原始代码 500 来行,上面的代码只有 58 行,在维护的时候,一两屏内的代码需要的心智算力是非常低的。
为了进一步降低心智负担,我们可以和模型继续对话,完成绝大多数工程师都懒得折腾、但是十分有价值的代码组成部分:单元测试。和模型对话,继续提任务需求,获取代码:“完成下面 Golang 程序的单元测试,package configParser_test
”
单元测试代码如下:
package configParser_test
import (
"bytes"
"flag"
"log"
"os"
"strings"
"testing"
configParser "github.com/soulteary/ip-helper/model/parse-config"
)
var originalArgs []string
var originalFlagCommandLine *flag.FlagSet
func init() {
originalArgs = os.Args
originalFlagCommandLine = flag.CommandLine
}
func resetFlags() {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
// 清除所有已定义的标志
flag.CommandLine.Init("", flag.ExitOnError)
}
func clearEnv() {
os.Unsetenv("DEBUG")
os.Unsetenv("SERVER_PORT")
os.Unsetenv("SERVER_DOMAIN")
os.Unsetenv("TOKEN")
}
func captureLog(f func()) string {
var buf bytes.Buffer
log.SetOutput(&buf)
f()
log.SetOutput(os.Stderr)
return buf.String()
}
// ...
这段由AI生成的完整测试代码接近 300 行,而且仅用了十几秒就完成了。比起人工编写,这确实为我们节省了大量时间。我们先把这段代码保存在原始模块的相邻位置。
完成代码存放后,我们需要进行单元测试来验证功能是否符合预期,并找出潜在问题。
go test -v ./... -covermode=atomic -coverprofile=coverage.out && go tool cover -html=coverage.out -o coverage.html
在执行测试时,由于模型上下文限制以及不同编程语言、编译器和运行时版本的差异,我们可能会遇到一些错误提示,这都是很常见的情况。
# go test -v ./... -covermode=atomic -coverprofile=coverage.out && go tool cover -html=coverage.out -o coverage.html
? github.com/soulteary/ip-helper/model/define [no test files]
github.com/soulteary/ip-helper coverage: 0.0% of statements
=== RUN TestParseDefault
...
--- PASS: TestParseDefault (0.00s)
=== RUN TestParseCommandLine
/var/folders/kt/2wy0pmy562j4dwzscd6wp_v00000gn/T/go-build3158635208/b248/parse-config.test flag redefined: port
--- FAIL: TestParseCommandLine (0.00s)
panic: /var/folders/kt/2wy0pmy562j4dwzscd6wp_v00000gn/T/go-build3158635208/b248/parse-config.test flag redefined: port [recovered]
panic: /var/folders/kt/2wy0pmy562j4dwzscd6wp_v00000gn/T/go-build3158635208/b248/parse-config.test flag redefined: port
遇到错误也不用担心,只要把关键的错误信息提供给AI模型,它就能分析问题并给出修正建议。
按照AI的建议更新测试代码后重新执行,这次不仅测试全部通过,而且直接达到了100% 的测试覆盖率,这个成果令人欣喜。不过,如果你经常使用 AI,慢慢你就会习惯这件事。
# go test -v ./... -covermode=atomic -coverprofile=coverage.out && go tool cover -html=coverage.out -o coverage.html
? github.com/soulteary/ip-helper/model/define [no test files]
github.com/soulteary/ip-helper coverage: 0.0% of statements
=== RUN TestParseDefault
2024/12/22 10:36:37 提醒:为了提高安全性,可以设置 `TOKEN` 环境变量或 `token` 命令行参数
--- PASS: TestParseDefault (0.00s)
=== RUN TestParseCommandLine
--- PASS: TestParseCommandLine (0.00s)
=== RUN TestParseEnvironment
--- PASS: TestParseEnvironment (0.00s)
=== RUN TestParsePriority
--- PASS: TestParsePriority (0.00s)
PASS
coverage: 100.0% of statements
ok github.com/soulteary/ip-helper/model/parse-config 1.022s coverage: 100.0% of statements
测试执行完成后,详细的覆盖率报告会保存在 coverage.html
文件中。我们可以在浏览器中打开它查看具体的覆盖信息。
通过重复这个流程,我们可以逐步完成整个项目的模块化重构,并为每个模块补充单元测试。这个过程不仅能显著提升程序的健壮性,还能帮助我们发现之前的设计疏漏,顺便实现一些计划中的新功能。
完成重构后的项目结构如图所示。如果你想了解更多细节,可以访问项目仓库 soulteary/ip-helper 查看所有模块的代码实现。
完成基础文档编写
对于一个开源项目来说,完善的基础文档是必不可少的。虽然我们可以像之前介绍的那样,用AI模型直接处理全部代码,或者通过构建 Dify AI App 来简化流程、展示功能(关于Dify的使用技巧,我打算另写一篇专门介绍,如果你等不及,可以看之前的 Dify 系列文章)。但今天要分享的是另一个更实用的方案。
在 AI IDE 领域,有一款叫 Cursor 的异军突起者。它刚推出时并不被看好,但经过不断迭代,现在的使用体验已经能与 VSCode + Copilot 组合相媲美(各有千秋)。如果你想高效地完成项目分析和文档编写,不妨试试它。对VSCode用户来说,Cursor的界面很容易上手,因为几乎和 VSCode 一样(如果你使用 Copilot,相似度就更高了),它甚至还能自动继承你本地 VSCode 的插件和配置。
让我们看看如何用Cursor快速生成项目文档。
启动Cursor后,跟AI说出你的需求:“完成项目README.md文档”,并点击“Codebase”按钮。
Cursor会自动扫描和分析项目相关文件,然后交给AI模型处理。
十几秒钟后,一份文档初稿就生成好了。
有了初稿,我们还能让AI进一步完善。选中刚才生成的内容,告诉AI:“完警项目 README 文档,调整结构和格式,让内容更清晰”。
看着内容差不多之后,我们输入“应用文档改变”,Cursor 会生成可以应用到项目中的“代码块”,我们只需要点击 “Apply”,就能享受 Cursor 的劳动成果啦。
性能优化
在项目即将收尾的时候,我们还有一个重要环节,性能优化。
说起这个,我之前写过两篇相关文章:《从零开始搭建个人书签导航应用:Flare》介绍了软件的使用体验,以及《Flare 制作记录:应用前后端性能优化》讲述了具体的优化过程。不过现在情况有了很大改善,在我们前面拆分模块的时候,后端代码的性能优化已经可以让 AI 模型来协助完成了。
至于前端性能优化,我们主要借助 Chrome 浏览器内置的 “Lighthouse” 工具来检测和分析。
具体操作很简单:打开我们的应用页面,右键点击选择 “Lighthouse” 标签,就能开始性能分析了。往下滑动报告,你会看到具体哪些地方需要改进,每个优化建议都配有最佳实践指南和详细文档。就像我们之前处理单元测试那样,你可以把这些问题和相关代码一起提供给AI模型,让它帮你找到解决方案。
经过简单的一轮优化后,我们的应用性能在意料之中的得到了满分。
最后
这篇文章本计划昨天就发出来,但因为一些临时的事情耽误了一天。不过好在到这里,我们已经完成了程序的主体设计。接下来就只剩下构建和发布相关的内容了,我们下篇文章再详细说说这部分。
先写到这里啦。
–EOF