Go语言开发的命令行MP3播放器

Go语言开发的命令行MP3播放器

1. 项目概述

本案例是一个基于Go语言开发的命令行MP3播放器,支持批量播放MP3文件、键盘控制切换歌曲、实时进度条显示等功能。项目使用了github.com/hajimehoshi/go-mp3进行MP3解码,github.com/hajimehoshi/oto/v2进行音频播放,以及github.com/eiannone/keyboard进行键盘事件监听。

2. 开发流程图

graph TD A[启动程序] --> B[解析命令行参数] B --> C{参数类型} C -->|单个文件| D[加载单个MP3文件] C -->|目录| E[遍历目录加载MP3文件列表] E --> F[根据-n参数限制加载数量] D --> G[初始化播放控制循环] F --> G G --> H[启动键盘监听协程] H --> I[播放当前文件] I --> J[显示播放列表和控制提示] J --> K[播放MP3文件并显示进度条] K --> L{键盘事件} L -->|上一首| M[切换到上一首] L -->|下一首| N[切换到下一首] L -->|退出| O[退出程序] L -->|其他| P[忽略无效命令] M --> G N --> G P --> K

运行效果

不带-n参数 默认播放10首歌曲

go run test_goMp3All_stdin.go -dir="mp3"

按下+号键,切换一下首歌曲

带-n 20参数 播放20首歌曲

go run test_goMp3All_stdin.go -dir="mp3" -n 20

带-n 0 参数播放目录中所有歌曲

go run test_goMp3All_stdin.go -dir="mp3" -n 0

3. 功能实现步骤与源代码

3.1 项目结构与依赖

go 复制代码
package main

import (
    "bufio"
    "flag"
    "fmt"
    "io/fs"
    "log"
    "os"
    "path/filepath"
    "strings"
    "time"

    keyboard "github.com/eiannone/keyboard"
    mp3 "github.com/hajimehoshi/go-mp3"
    oto "github.com/hajimehoshi/oto/v2"
)

// 进度条宽度
const progressBarWidth = 50

3.2 主函数与命令行参数解析

主函数负责解析命令行参数、加载MP3文件列表并启动播放控制循环。

go 复制代码
func main() {
    // 1. 解析命令行参数
    filePath := flag.String("file", "", "单个MP3音频文件路径")
    dirPath := flag.String("dir", "", "MP3音频文件目录路径")
    limit := flag.Int("n", 10, "加载歌曲列表的数量,默认为10")
    flag.Parse()

    // 存储待播放的MP3文件列表
    var mp3Files []string

    // 2. 处理参数逻辑
    if *dirPath != "" {
        // 读取目录下的MP3文件
        files, err := listMP3Files(*dirPath, *limit)
        if err != nil {
            log.Fatalf("读取目录失败:%v", err)
        }
        if len(files) == 0 {
            log.Fatal("指定目录下未找到MP3文件")
        }
        mp3Files = files
        fmt.Printf("📋 待播放MP3列表(共 %d 个):\n", len(mp3Files))
        printMP3List(mp3Files, -1) // 初始无播放文件,索引为-1
    } else if *filePath != "" {
        mp3Files = append(mp3Files, *filePath)
    } else {
        log.Fatal("请使用 -dir 指定目录 或 -file 指定单个MP3文件路径")
    }

    // 3. 播放控制循环
    currentIdx := 0
    totalFiles := len(mp3Files)

    // 启动键盘输入监听
    inputCh := make(chan string, 10)
    go listenKeyboard(inputCh)

    for {
        // 播放当前文件
        fmt.Printf("\n=====================================\n")
        fmt.Printf("🎯 开始播放第 %d 个文件:%s\n", currentIdx+1, mp3Files[currentIdx])
        printMP3List(mp3Files, currentIdx) // 标记当前播放的文件
        fmt.Println("🎮 按 '+' 或 ↓ 切换到下一首,按 '-' 或 ↑ 切换到上一首,按 'q' 退出程序")

        // 播放单个MP3文件并处理控制事件
        nextIdx, err := playMP3FileWithControlEvents(mp3Files, currentIdx, totalFiles, inputCh)
        if err != nil {
            log.Printf("播放文件失败:%v", err)
            currentIdx++
        } else {
            // 根据播放函数返回的索引更新当前播放索引
            currentIdx = nextIdx
        }

        // 确保索引在有效范围内
        if currentIdx >= totalFiles {
            currentIdx = 0 // 循环到第一个
        }
        if currentIdx < 0 {
            currentIdx = totalFiles - 1 // 循环到最后一个
        }
    }
}

3.3 键盘监听功能

listenKeyboard函数负责监听键盘输入,并将输入转换为控制命令发送到通道。

go 复制代码
// listenKeyboard 监听键盘输入
func listenKeyboard(inputCh chan<- string) {
    // 尝试打开键盘监听
    if err := keyboard.Open(); err != nil {
        fmt.Printf("❌ 无法打开键盘监听: %v\n", err)
        // 回退到标准输入
        scanner := bufio.NewScanner(os.Stdin)
        for scanner.Scan() {
            inputCh <- strings.TrimSpace(scanner.Text())
        }
        return
    }
    defer keyboard.Close()

    // 监听键盘事件
    for {
        // 读取键盘按键事件(旧版本返回rune类型)
        r, key, err := keyboard.GetKey()
        if err != nil {
            fmt.Printf("❌ 键盘监听错误: %v\n", err)
            break
        }

        // 发送按键到通道
        if r != 0 {
            // 处理普通字符
            inputCh <- string(r)
        } else {
            // 处理特殊键
            switch key {
            case keyboard.KeyArrowUp:
                inputCh <- "-" // 上箭头相当于"-"(上一首)
            case keyboard.KeyArrowDown:
                inputCh <- "+" // 下箭头相当于"+"(下一首)
            case keyboard.KeyArrowLeft, keyboard.KeyArrowRight:
                // 忽略左右箭头
            case keyboard.KeyEnter, keyboard.KeyTab, keyboard.KeySpace:
                // 忽略这些键
            }
        }
    }
}

3.4 MP3文件列表加载

listMP3Files函数负责遍历指定目录,筛选出MP3文件并返回文件路径列表。

go 复制代码
// listMP3Files 读取指定目录下的MP3文件,返回前n个
func listMP3Files(dir string, limit int) ([]string, error) {
    var mp3Files []string

    // 遍历目录
    err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            return err
        }
        // 跳过目录,只处理文件
        if d.IsDir() {
            return nil
        }
        // 筛选MP3文件(不区分大小写)
        ext := strings.ToLower(filepath.Ext(path))
        if ext == ".mp3" {
            mp3Files = append(mp3Files, path)
            // 达到数量限制则停止遍历
            if limit > 0 && len(mp3Files) >= limit {
                return fs.SkipAll
            }
        }
        return nil
    })

    if err != nil {
        return nil, err
    }

    return mp3Files, nil
}

3.5 MP3列表打印

printMP3List函数负责打印MP3文件列表,并标记当前正在播放的文件。

go 复制代码
// printMP3List 打印MP3列表,标记当前播放的文件索引
func printMP3List(files []string, playingIdx int) {
    for i, file := range files {
        // 简化文件名显示(只显示文件名,不显示完整路径)
        filename := filepath.Base(file)
        if i == playingIdx {
            fmt.Printf("  [%d] 🎶 %s (正在播放)\n", i+1, filename)
        } else {
            fmt.Printf("  [%d]     %s\n", i+1, filename)
        }
    }
}

3.6 MP3文件播放与控制

playMP3FileWithControlEvents函数负责播放单个MP3文件,显示播放进度条,并处理键盘控制事件。

go 复制代码
// playMP3FileWithControlEvents 播放单个MP3文件,显示进度条,支持键盘控制切换歌曲
// 返回值:下一个要播放的索引,错误信息
func playMP3FileWithControlEvents(mp3Files []string, currentIdx, totalFiles int, inputCh <-chan string) (int, error) {
    // 打开MP3文件
    f, err := os.Open(mp3Files[currentIdx])
    if err != nil {
        return currentIdx + 1, fmt.Errorf("打开文件失败:%v", err)
    }
    defer func() {
        if err := f.Close(); err != nil {
            log.Printf("关闭文件失败:%v", err)
        }
    }()

    // 解码MP3文件
    decoder, err := mp3.NewDecoder(f)
    if err != nil {
        return currentIdx + 1, fmt.Errorf("解码MP3失败:%v", err)
    }

    // 获取音频总长度(字节)
    totalBytes := decoder.Length()
    if totalBytes == 0 {
        return currentIdx + 1, fmt.Errorf("音频文件长度为0")
    }

    // 初始化音频上下文
    otoCtx, ready, err := oto.NewContext(decoder.SampleRate(), 2, 2)
    if err != nil {
        return currentIdx + 1, fmt.Errorf("初始化音频上下文失败:%v", err)
    }
    <-ready

    // 创建播放器
    player := otoCtx.NewPlayer(decoder)
    defer func() {
        if err := player.Close(); err != nil {
            log.Printf("关闭播放器失败:%v", err)
        }
    }()

    // 启动播放
    player.Play()

    // 计算音频总时长(秒)
    // 总字节数 / (采样率 * 声道数 * 每个样本的字节数)
    totalDuration := float64(totalBytes) / float64(decoder.SampleRate()*2*2)

    // 记录开始播放时间
    startTime := time.Now()

    // 后台监控播放状态并显示进度条
    ticker := time.NewTicker(100 * time.Millisecond)
    defer ticker.Stop()

    // 持续监控播放状态和键盘事件
    for player.IsPlaying() {
        select {
        case input := <-inputCh:
            player.Close() // 立即关闭当前播放器
            switch input {
            case "-": // 上一首
                newIdx := currentIdx - 1
                if newIdx < 0 {
                    newIdx = totalFiles - 1 // 循环到最后一个
                    fmt.Println("\n🔄 已到达第一首,循环到最后一首")
                } else {
                    fmt.Printf("\n🔼 切换到上一首,当前播放第 %d 首\n", newIdx+1)
                }
                return newIdx, nil
            case "+": // 下一首
                newIdx := currentIdx + 1
                if newIdx >= totalFiles {
                    newIdx = 0 // 循环到第一个
                    fmt.Println("\n🔄 已到达最后一首,循环到第一首")
                } else {
                    fmt.Printf("\n🔽 切换到下一首,当前播放第 %d 首\n", newIdx+1)
                }
                return newIdx, nil
            case "q": // 退出程序
                fmt.Println("\n👋 正在退出程序...")
                os.Exit(0)
            default:
                // 如果输入不是控制命令,继续播放
                fmt.Printf("⚠️  无效命令: %s (使用 '+'下一首, '-'上一首, 'q'退出)\n", input)
            }
        case <-ticker.C:
            // 计算已播放时间
            elapsedTime := time.Since(startTime).Seconds()
            // 计算进度百分比,确保不超过100%
            progress := elapsedTime / totalDuration
            if progress > 1.0 {
                progress = 1.0
            }
            // 绘制进度条
            drawProgressBar(progress, currentIdx+1, totalFiles)
        }
    }

    // 播放完成
    drawProgressBar(1.0, currentIdx+1, totalFiles)
    fmt.Println("\n✅ 播放完成!")
    return currentIdx + 1, nil // 播放自然结束,返回下一个索引
}

3.7 进度条绘制

drawProgressBar函数负责绘制实时播放进度条。

go 复制代码
// drawProgressBar 绘制播放进度条
func drawProgressBar(progress float64, currentFile, totalFiles int) {
    // 计算进度条填充长度
    filled := int(progress * progressBarWidth)
    // 构建进度条字符串
    bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled)
    // 计算百分比
    percent := int(progress * 100)

    // 格式化输出(覆盖当前行)
    fmt.Printf("\r[%d/%d] 播放进度: |%s| %d%%",
        currentFile, totalFiles, bar, percent)
}

4. 功能特点与使用说明

4.1 功能特点

  1. 命令行参数支持

    • -file:指定单个MP3文件路径
    • -dir:指定MP3文件目录路径
    • -n:限制加载的歌曲数量,默认为10
  2. 键盘控制

    • +:切换到下一首
    • -:切换到上一首
    • q:退出程序
  3. 实时进度显示

    • 动态进度条显示当前播放进度
    • 显示当前播放文件在列表中的位置
  4. 错误处理

    • 播放失败时自动切换到下一首
    • 键盘监听失败时回退到标准输入

4.2 使用示例

bash 复制代码
# 播放单个MP3文件
go run test_goMp3All_stdin.go -file="mp3/歌曲.mp3"

# 播放目录下的前10个MP3文件
go run test_goMp3All_stdin.go -dir="mp3"

# 播放目录下的前20个MP3文件
go run test_goMp3All_stdin.go -dir="mp3" -n 20

# 播放目录下的所有MP3文件
go run test_goMp3All_stdin.go -dir="mp3" -n 0

4.4 完整源代码

go 复制代码
package main

import (
	"bufio"
	"flag"
	"fmt"
	"io/fs"
	"log"
	"os"
	"path/filepath"
	"strings"
	"time"

	keyboard "github.com/eiannone/keyboard"
	mp3 "github.com/hajimehoshi/go-mp3"
	oto "github.com/hajimehoshi/oto/v2"
)

//go run test_goMp3All_stdin.go -dir="G:\\MP3\\"

// 进度条宽度
const progressBarWidth = 50

func main() {
	// 1. 解析命令行参数
	filePath := flag.String("file", "", "单个MP3音频文件路径")
	dirPath := flag.String("dir", "", "MP3音频文件目录路径")
	limit := flag.Int("n", 10, "加载歌曲列表的数量,默认为10")
	flag.Parse()

	// 存储待播放的MP3文件列表
	var mp3Files []string

	// 2. 处理参数逻辑
	if *dirPath != "" {
		// 读取目录下的MP3文件
		files, err := listMP3Files(*dirPath, *limit)
		if err != nil {
			log.Fatalf("读取目录失败:%v", err)
		}
		if len(files) == 0 {
			log.Fatal("指定目录下未找到MP3文件")
		}
		mp3Files = files
		fmt.Printf("📋 待播放MP3列表(共 %d 个):\n", len(mp3Files))
		printMP3List(mp3Files, -1) // 初始无播放文件,索引为-1
	} else if *filePath != "" {
		mp3Files = append(mp3Files, *filePath)
	} else {
		log.Fatal("请使用 -dir 指定目录 或 -file 指定单个MP3文件路径")
	}

	// 3. 播放控制循环
	currentIdx := 0
	totalFiles := len(mp3Files)

	// 启动键盘输入监听
	inputCh := make(chan string, 10)
	go listenKeyboard(inputCh)

	for {
		// 播放当前文件
		fmt.Printf("\n=====================================\n")
		fmt.Printf("🎯 开始播放第 %d 个文件:%s\n", currentIdx+1, mp3Files[currentIdx])
		printMP3List(mp3Files, currentIdx) // 标记当前播放的文件
		fmt.Println("🎮 按 '+' 或 ↓ 切换到下一首,按 '-' 或 ↑ 切换到上一首,按 'q' 退出程序")

		// 播放单个MP3文件并处理控制事件
		nextIdx, err := playMP3FileWithControlEvents(mp3Files, currentIdx, totalFiles, inputCh)
		if err != nil {
			log.Printf("播放文件失败:%v", err)
			currentIdx++
		} else {
			// 根据播放函数返回的索引更新当前播放索引
			currentIdx = nextIdx
		}

		// 确保索引在有效范围内
		if currentIdx >= totalFiles {
			currentIdx = 0 // 循环到第一个
		}
		if currentIdx < 0 {
			currentIdx = totalFiles - 1 // 循环到最后一个
		}
	}
}

// listenKeyboard 监听键盘输入
func listenKeyboard(inputCh chan<- string) {
	// 尝试打开键盘监听
	if err := keyboard.Open(); err != nil {
		fmt.Printf("❌ 无法打开键盘监听: %v\n", err)
		// 回退到标准输入
		scanner := bufio.NewScanner(os.Stdin)
		for scanner.Scan() {
			inputCh <- strings.TrimSpace(scanner.Text())
		}
		return
	}
	defer keyboard.Close()

	// 监听键盘事件
	for {
		// 读取键盘按键事件(旧版本返回rune类型)
		r, key, err := keyboard.GetKey()
		if err != nil {
			fmt.Printf("❌ 键盘监听错误: %v\n", err)
			break
		}

		// 发送按键到通道
		if r != 0 {
			// 处理普通字符
			inputCh <- string(r)
		} else {
			// 处理特殊键
			switch key {
			case keyboard.KeyArrowUp:
				inputCh <- "-" // 上箭头相当于"-"(上一首)
			case keyboard.KeyArrowDown:
				inputCh <- "+" // 下箭头相当于"+"(下一首)
			case keyboard.KeyArrowLeft, keyboard.KeyArrowRight:
				// 忽略左右箭头
			case keyboard.KeyEnter, keyboard.KeyTab, keyboard.KeySpace:
				// 忽略这些键
			}
		}
	}
}

// listMP3Files 读取指定目录下的MP3文件,返回前n个
func listMP3Files(dir string, limit int) ([]string, error) {
	var mp3Files []string

	// 遍历目录
	err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		// 跳过目录,只处理文件
		if d.IsDir() {
			return nil
		}
		// 筛选MP3文件(不区分大小写)
		ext := strings.ToLower(filepath.Ext(path))
		if ext == ".mp3" {
			mp3Files = append(mp3Files, path)
			// 达到数量限制则停止遍历
			if limit > 0 && len(mp3Files) >= limit {
				return fs.SkipAll
			}
		}
		return nil
	})

	if err != nil {
		return nil, err
	}

	return mp3Files, nil
}

// printMP3List 打印MP3列表,标记当前播放的文件索引
func printMP3List(files []string, playingIdx int) {
	for i, file := range files {
		// 简化文件名显示(只显示文件名,不显示完整路径)
		filename := filepath.Base(file)
		if i == playingIdx {
			fmt.Printf("  [%d] 🎶 %s (正在播放)\n", i+1, filename)
		} else {
			fmt.Printf("  [%d]     %s\n", i+1, filename)
		}
	}
}

// playMP3FileWithControlEvents 播放单个MP3文件,显示进度条,支持键盘控制切换歌曲
// 返回值:下一个要播放的索引,错误信息
func playMP3FileWithControlEvents(mp3Files []string, currentIdx, totalFiles int, inputCh <-chan string) (int, error) {
	// 打开MP3文件
	f, err := os.Open(mp3Files[currentIdx])
	if err != nil {
		return currentIdx + 1, fmt.Errorf("打开文件失败:%v", err)
	}
	defer func() {
		if err := f.Close(); err != nil {
			log.Printf("关闭文件失败:%v", err)
		}
	}()

	// 解码MP3文件
	decoder, err := mp3.NewDecoder(f)
	if err != nil {
		return currentIdx + 1, fmt.Errorf("解码MP3失败:%v", err)
	}

	// 获取音频总长度(字节)
	totalBytes := decoder.Length()
	if totalBytes == 0 {
		return currentIdx + 1, fmt.Errorf("音频文件长度为0")
	}

	// 初始化音频上下文
	otoCtx, ready, err := oto.NewContext(decoder.SampleRate(), 2, 2)
	if err != nil {
		return currentIdx + 1, fmt.Errorf("初始化音频上下文失败:%v", err)
	}
	<-ready

	// 创建播放器
	player := otoCtx.NewPlayer(decoder)
	defer func() {
		if err := player.Close(); err != nil {
			log.Printf("关闭播放器失败:%v", err)
		}
	}()

	// 启动播放
	player.Play()

	// 计算音频总时长(秒)
	// 总字节数 / (采样率 * 声道数 * 每个样本的字节数)
	totalDuration := float64(totalBytes) / float64(decoder.SampleRate()*2*2)

	// 记录开始播放时间
	startTime := time.Now()

	// 后台监控播放状态并显示进度条
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	// 持续监控播放状态和键盘事件
	for player.IsPlaying() {
		select {
		case input := <-inputCh:
			player.Close() // 立即关闭当前播放器
			switch input {
			case "-": // 上一首
				newIdx := currentIdx - 1
				if newIdx < 0 {
					newIdx = totalFiles - 1 // 循环到最后一个
					fmt.Println("\n🔄 已到达第一首,循环到最后一首")
				} else {
					fmt.Printf("\n🔼 切换到上一首,当前播放第 %d 首\n", newIdx+1)
				}
				return newIdx, nil
			case "+": // 下一首
				newIdx := currentIdx + 1
				if newIdx >= totalFiles {
					newIdx = 0 // 循环到第一个
					fmt.Println("\n🔄 已到达最后一首,循环到第一首")
				} else {
					fmt.Printf("\n🔽 切换到下一首,当前播放第 %d 首\n", newIdx+1)
				}
				return newIdx, nil
			case "q": // 退出程序
				fmt.Println("\n👋 正在退出程序...")
				os.Exit(0)
			default:
				// 如果输入不是控制命令,继续播放
				fmt.Printf("⚠️  无效命令: %s (使用 '+'下一首, '-'上一首, 'q'退出)\n", input)
			}
		case <-ticker.C:
			// 计算已播放时间
			elapsedTime := time.Since(startTime).Seconds()
			// 计算进度百分比,确保不超过100%
			progress := elapsedTime / totalDuration
			if progress > 1.0 {
				progress = 1.0
			}
			// 绘制进度条
			drawProgressBar(progress, currentIdx+1, totalFiles)
		}
	}

	// 播放完成
	drawProgressBar(1.0, currentIdx+1, totalFiles)
	fmt.Println("\n✅ 播放完成!")
	return currentIdx + 1, nil // 播放自然结束,返回下一个索引
}

// drawProgressBar 绘制播放进度条
func drawProgressBar(progress float64, currentFile, totalFiles int) {
	// 计算进度条填充长度
	filled := int(progress * progressBarWidth)
	// 构建进度条字符串
	bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled)
	// 计算百分比
	percent := int(progress * 100)

	// 格式化输出(覆盖当前行)
	fmt.Printf("\r[%d/%d] 播放进度: |%s| %d%%",
		currentFile, totalFiles, bar, percent)
}

5. 总结与后续功能扩展

5.1 项目总结

本项目成功实现了一个基于Go语言的命令行MP3播放器,具有以下特点:

  1. 简洁高效:使用Go语言的协程和通道实现异步键盘监听和播放控制
  2. 跨平台:使用纯Go语言依赖,理论上支持所有Go语言支持的平台
  3. 易于扩展:模块化设计,各功能模块独立,便于后续扩展
  4. 用户友好:清晰的命令行提示和实时进度显示

5.2 后续功能扩展建议

  1. 播放控制增强

    • 添加暂停/继续功能
    • 支持快进/快退
    • 音量控制
  2. 播放模式支持

    • 顺序播放
    • 随机播放
    • 单曲循环
    • 列表循环
  3. 元数据显示

    • 读取并显示MP3文件的ID3标签信息(歌名、歌手、专辑等)
  4. 可视化效果

    • 添加音频频谱可视化
    • 更丰富的进度条样式
  5. 配置文件支持

    • 支持通过配置文件保存播放历史和偏好设置
  6. 网络功能

    • 支持网络MP3文件播放
    • 在线音乐搜索和播放
  7. GUI界面

    • 开发图形用户界面,提升用户体验

通过这些扩展,可以将这个简单的命令行MP3播放器发展成为一个功能更加完善的音乐播放应用。

相关推荐
梦想很大很大8 小时前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰13 小时前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘17 小时前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤17 小时前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt111 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto4 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室5 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题5 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉7 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想