Go语言音乐播放器

结合前面所学的知识,本节我们将设计并实现了一个音乐播放器程序。这个程序只是用于演示 Go语言的特性,因此大家就不要期望能看到华丽的播放界面,听到优美的音乐。接下来我们会示范以下的关键流程:
  • 音乐库功能,使用者可以查看、添加和删除里面的音乐曲目;
  • 播放音乐;
  • 支持 MP3 和 WAV,但也能随时扩展以支持更多的音乐类型;
  • 退出程序。

由于 Go语言初始定位为高并发的服务器端程序,尚未在 GUI 的支持上花费大量的精力,而当前版本的 Go语言标准库中没有提供 GUI 相关的功能,也没有成熟的第三方界面库,因此不太适合开发 GUI 程序。

因此,这个程序仍然会是一个命令行程序,我们将其命名为 Simple Media Player(SMP)。该程序在运行后进入一个循环,用于监听命令输入的状态。该程序将接受以下命令。
  • 音乐库管理命令:lib,包括 list/add/remove 命令。
  • 播放管理:play 命令,play 后带歌曲名参数。
  • 退出程序:q 命令。

音乐库

我们先来实现音乐库的管理模块,它管理的对象为音乐。每首音乐都包含以下信息:
  • 唯一的 ID;
  • 音乐名;
  • 艺术家名;
  • 音乐位置;
  • 音乐文件类型(MP3 和 WAV 等)。

下面我们先定义音乐的结构体,具体如下所示:
type Music struct {
    Id string
    Name string
    Artist string
    Source string
    Type string
}
然后开始实现这个音乐库管理类型,其中我们使用了一个数组切片作为基础存储结构,其他的操作其实都只是对这个数组切片的包装,代码如下所示。
//manager.go
package library
import "errors"
type MusicManager struct {
    musics []MusicEntry
}
func NewMusicManager() *MusicManager {
    return &MusicManager{make([]MusicEntry, 0)}
}
func (m *MusicManager) Len() int {
    return len(m.musics)
}
func (m *MusicManager) Get(index int) (music *MusicEntry, err error) {
    if index < 0 || index >= len(m.musics) {
        return nil, errors.New("Index out of range.")
    }
    return &m.musics[index], nil
}
func (m *MusicManager) Find(name string) *MusicEntry {
    if len(m.musics) == 0 {
        return nil
    }
    for _, m := range m.musics {
        if m.Name == name {
            return &m
        }
    }
    return nil
}
func (m *MusicManager) Add(music *MusicEntry) {
    m.musics = append(m.musics, *music)
}
func (m *MusicManager) Remove(index int) *MusicEntry {
    if index < 0 || index >= len(m.musics) {
        return nil
    }
    removedMusic := &m.musics[index]
    // 从数组切片中删除元素
    if index < len(m.musics)-1 { // 中间元素
        m.musics = append(m.musics[:index-1], m.musics[index+1:]...)
    } elseif index == 0 { // 删除仅有的一个元素
        m.musics = make([]MusicEntry, 0)
    } else { // 删除的是最后一个元素
        m.musics = m.musics[:index-1]
    }
    return removedMusic
}
实现了这么重要的一个基础数据管理模块后,我们应该马上编写单元测试,而不是给自己借口说等将来有空的时候再补上。下面的代码实现了 MusicManager 类型的单元测试。
//manager_test.go
package library
import (
    "testing"
)
func TestOps(t *testing.T) {
    mm := NewMusicManager()
    if mm == nil {
        t.Error("NewMusicManager failed.")
    }
    if mm.Len() != 0 {
        t.Error("NewMusicManager failed, not empty.")
    }
    m0 := &MusicEntry{
        "1", "My Heart Will Go On", "Celion Dion", Pop,
        "http://qbox.me/24501234", MP3}
    mm.Add(m0)
    if mm.Len() != 1 {
        t.Error("MusicManager.Add() failed.")
    }
    m := mm.Find(m0.Name)
    if m == nil {
        t.Error("MusicManager.Find() failed.")
    }
    if m.Id != m0.Id || m.Artist != m0.Artist ||
        m.Name != m0.Name || m.Genre != m0.Genre ||
        m.Source != m0.Source || m.Type != m0.Type {
        t.Error("MusicManager.Find() failed. Found item mismatch.")
    }
    m, err := mm.Get(0)
    if m == nil {
        t.Error("MusicManager.Get() failed.", err)
    }
    m = mm.Remove(0)
    if m == nil || mm.Len() != 0 {
        t.Error("MusicManager.Remove() failed.", err)
    }
}
这个单元测试看起来似乎有些偷懒,但它基本上已经覆盖了 MusicManager 的所有功能,实际上也确实测出了 MusicManager 实现过程中的几个问题。因此,养成良好的单元测试习惯还是非常有价值的。

音乐播放

我们接下来设计和实现音乐播放模块。按我们之前设置的目标,音乐播放模块应该是很容易扩展的,不应该在每次增加一种新音乐文件类型支持时都就需要大幅调整代码。我们来设计一个简单但又足够通用的播放函数:

func Play(source, mtype string)

这里没有直接将 MusicEntry 作为参数传入,这是因为 MusicEntry 包含了一些多余的信息。本着最小原则,我们只需要将真正需要的信息传入即可,即音乐文件的位置以及音乐的类型。

下面我们设计一个简单的接口:

type Player interface {
    Play(source string)
}

然后我们可以通过一批类型(比如 MP3Player 和 WAVPlayer 等)来实现这个接口,已达到尽量的架构灵活性。因此,我们可以实现如下代码所示的总入口函数。
//play.go
package mp
import "fmt"
type Player interface {
    Play(source string)
}
func Play(source, mtype string) {
    var p Player
    switch mtype {
        case "MP3":
            p = &MP3Player{}
        case "WAV":
            p = &WAVPlayer{}
        default:
            fmt.Println("Unsupported music type", mtype)
            return
    }
    p.Play(source)
}
因为我们这个例子并不会真正实现多媒体文件的解码和播放过程,所以对于 MP3Player 和 WAVPlayer,我们只实现其中一个作为示例,代码如下所示。
//mp3.go
package mp
import (
    "fmt"
    "time"
)
type MP3Player struct {
    stat int
    progress int
}
func (p *MP3Player)Play(source string) {
    fmt.Println("Playing MP3 music", source)
    p.progress = 0
    for p.progress < 100 {
        time.Sleep(100 * time.Millisecond) // 假装正在播放
        fmt.Print(".")
        p.progress += 10
    }
    fmt.Println("\nFinished playing", source)
}
当然,我们也应该对播放流程进行单元测试。因为单元测试比较简单,这里就不再列出完整的单元测试代码了。

主程序

核心模块已经设计和实现完毕,现在就该使用它们了。我们的主程序是一个命令行交互程序,用户可以通过输入命令来控制播放过程以及获取播放信息。因为主程序与面向对象关系不大,所以我们只是为了完整性而把源代码列在这里,但不作过多解释。

在这里,我们可以顺便了解一下命令行交互程序在 Go语言中的常规实现方式。下面的代码实现了音乐播放器的主程序。
//mplayer.go
package main
import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"

    "pkg/mplayer/mlib"
    "pkg/mplayer/mp"
)

var lib *library.MusicManager
var id int = 1
var ctrl, signal chan int

func handleLibCommands(tokens []string) {
    switch tokens[1] {
        case "list":
            for i := 0; i < lib.Len(); i++ {
                e, _ := lib.Get(i)
                fmt.Println(i+1, ":", e.Name, e.Artist, e.Source, e.Type)
            }
        case "add": {
            if len(tokens) == 6 {
                id++
                lib.Add(&library.MusicEntry{strconv.Itoa(id),
                    tokens[2], tokens[3], tokens[4], tokens[5]})
            } else {
                fmt.Println("USAGE: lib add <name><artist><source><type>")
            }
        }
        case "remove":
            if len(tokens) == 3 {
                lib.RemoveByName(tokens[2])
            } else {
                fmt.Println("USAGE: lib remove <name>")
            }
        default:
            fmt.Println("Unrecognized lib command:", tokens[1])
    }
}
func handlePlayCommand(tokens []string) {
    if len(tokens) != 2 {
        fmt.Println("USAGE: play <name>")
        return
    }
    e := lib.Find(tokens[1])
    if e == nil {
        fmt.Println("The music", tokens[1], "does not exist.")
        return
    }
    mp.Play(e.Source, e.Type, ctrl, signal)
}
func main() {
    fmt.Println(`
        Enter following commands to control the player:
        lib list -- View the existing music lib
        lib add <name><artist><source><type> -- Add a music to the music lib
        lib remove <name> -- Remove the specified music from the lib
        play <name> -- Play the specified music
    `)
    lib = library.NewMusicManager()
    r := bufio.NewReader(os.Stdin)
    for {
        fmt.Print("Enter command-> ")
        rawLine, _, _ := r.ReadLine()
        line := string(rawLine)
        if line == "q" || line == "e" {
            break
        }
        tokens := strings.Split(line, " ")
        if tokens[0] == "lib" {
            handleLibCommands(tokens)
        } elseif tokens[0] == "play" {
            handlePlayCommand(tokens)
        } else {
            fmt.Println("Unrecognized command:", tokens[0])
        }
    }
}

构建运行

所有代码已经写完,现在可以开始构建并运行程序了,具体如下所示:

$ go run mplayer.go
Enter following commands to control the player:
lib list -- View the existing music lib
lib add <name><artist><source><type> -- Add a music to the music lib
lib remove <name> -- Remove the specified music from the lib
play <name> -- Play the specified music

Enter command-> lib add HugeStone MJ ~/MusicLib/hs.mp3 MP3
Enter command-> play HugeStone
Playing MP3 music ~/MusicLib/hs.mp3
..........
Finished playing ~/MusicLib/hs.mp3
Enter command-> lib list
1 : HugeStone MJ ~/MusicLib/hs.mp3 MP3
Enter command-> lib view
Enter command-> q

遗留问题

这个程序虽然已经写好,但是很显然它离一个可实际使用的程序还相差很远,下面我们就来谈谈遗留问题以及对策。

1)多任务

当前,我们这个程序还只是单任务程序,即同时只能执行一个任务,比如音乐正在播放时,用户不能做其他任何事情。作为一个运行在现代多任务操作系统上的应用程序,这种做法肯定是无法被用户接受的。

音乐播放过程不应导致用户界面无法响应,因此播放应该在一个单独的线程中,并能够与主程序相互通信。而且像一般的媒体播放器一样,在播放音乐的同时,我们甚至也要支持一些视觉效果的播放,即至少需要这么几个线程:用户界面、音乐播放和视频播放。

考虑到这个需求,我们自然而然地想到了使用 Go语言的看家本领 goroutine,比如将上面的播放进行稍微修改后即可将 Play() 函数作为一个独立的 goroutine 运行。

2)控制播放

因为当前这个设计是单任务的,所以播放过程无法接受外部的输入。然而作为一个成熟的播放器,我们至少需要支持暂停和停止等功能,甚至包括设置当前播放位置等。假设我们已经将播放过程放到一个独立的 goroutine 中,那么现在就是如何对这个 goroutine 进行控制的问题,这可以使用 Go语言的 channel 功能来实现。