Go語言音樂播放器

2020-07-16 10:05:22
結合前面所學的知識,本節我們將設計並實現了一個音樂播放器程式。這個程式只是用於演示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 功能來實現。