【Golang 1.19】Go! Go! 購物趣! 取得APP版本號

聽說Golang也開始支援泛型 (Generics)了,但聽說可能會使程式變慢?求不要更新了,老子學不動了… 這次呢,主要是要做一個App版本的設定後台,因為在學了Flutter之後,發現Apple真的是太佛心了,有提供iTunes Search API,來取得該AppAppStore上的相關訊息;反觀GooglePlay,全世界約佔80%以上的Android系統,居然沒有幫它出個SearchAPI,真的太說不過去了吧,後來發現,GooglePlay可以下載的APP版本是跟手機OS版本有關,也就是說,可以下載到舊的版本,也許是考慮到相容性的關係吧?

做一個長得像這樣的東西

作業環境

項目 版本
CPU Apple M1
macOS Big Sur 12.6 arm64
Golang 1.19 arm64
Visual Studio Code 1.71 arm64
Postman 9.0.9 arm64
Node.js 16.15 arm64
Yarn 1.22.18
Vue CLI 5.0.4
DB Browser for SQLite 3.12.1 x86_64

Golang - 資料端

建立資料庫

  • 這裡呢,我們使用Sqlite來做我們的資料庫,以方便測試…
  • 使用Gorm來簡化對資料庫的存取,可以參考舊文來了解它的使用方式,這裡就不細說了…
type StoreType int8

const (
	AppStore   StoreType = 0
	GooglePlay StoreType = 1
)

type AppVersion struct {
	gorm.Model
	AppStoreType      StoreType `json:"type"`
	AutoUpdate        bool      `json:"autoUpdate"`
	AppId             string    `json:"id" gorm:"index:idx_name,unique"`
	AppName           string    `json:"name"`
	AppIcon           *string   `json:"icon"`
	AppUrl            string    `json:"url" gorm:"-:all"`
	Lang              string    `json:"lang"`
	Version           string    `json:"version"`
	VersionProduction string    `json:"versionProd"`
	VersionDevelop    string    `json:"versionDev"`
}

欄位分析

  • 這邊來簡單說明一下,為什麼欄位要這樣設計
欄位 類型 功能 說明
AppStoreType int8 記錄是屬於哪個商店的 AppStore = 0 / GooglePlay = 1 / ApkPure = 2 / …
AutoUpdate bool 設定是否要自動更動版本 因為有的時候該Store進廠維修,API就會失效了,就可以關掉,然後手動去更新
AppId string 設定App的Id AppStore的網址是以App ID為主,而GooglePlay的網址是以Bundle ID為主,兩者不會一樣,所以設定成unique
AppName string 設定辨識名稱 這個是拿來排序用的,不是在Store上的名稱
AppIcon *string App圖示的網址 這也只是方便辨識之用,可以自動更新
AppUrl string Store的網址 方便連去該Store看看,但因為這個是及時產生出來的,所以不記在資料庫內
Lang string Store的語系或地區 有時候該APP只會在特定的地區或語系才有,所以多加入了此設定值
Version string App在架上的版本號 去Store取得的版本號,可以自動更新
VersionProduction string 測試用的版本號 外網使用的
VersionDevelop string 測試用的版本號 區網使用的

API設定

  • 重點,當然HTTP的方法倒是沒那麼細分,主要是教學之用…
    功能 API 參數 方式
    新增App版本訊息 http://localhost:12345/appVersion {"type":0,"id":"id443904275","name":"LINE"} POST
    搜尋App版本訊息 http://localhost:12345/appVersion/<AppID> GET
    搜尋App版本訊息列表 http://localhost:12345/appVersion GET
    更新APP版本號 http://localhost:12345/appVersion/<AppID>/<AppStoreType> {"version":{"store":"1.0.0","prod":"1.0.0","dev":"1.0.0"}} PATCH
    取得上架版的APP版本號 http://localhost:12345/appVersion/<AppID>/<AppStoreType> PUT
    刪除該APP資訊 http://localhost:12345/appVersion/<AppID> DELETE
    儲存成Excel http://localhost:12345/saveAppVersion POST
    上傳Excel http://localhost:12345/uploadExcel POST

新增App版本訊息

  • 很單純,就只要把AppId / App名稱 / Store的分類,這些必要的資訊加入即可…
http://localhost:12345/appVersion + {"type":0,"id":"id443904275","name":"LINE"}

搜尋App版本訊息

  • 搜尋單一App版本訊息,可以看到,其實是含有非常多的資訊的…
http://localhost:12345/appVersion/id443904275

搜尋App版本訊息列表

  • 搜尋所有在資料庫內的資訊…
http://localhost:12345/appVersion

刪除該APP資訊

  • 不開心就把它刪除吧…
http://localhost:12345/appVersion/id443904275

取得上架版的APP版本號

  • 這裡就是整個重頭戲啦
http://localhost:12345/appVersion/id443904275/0
https://itunes.apple.com/lookup?id=443904275

// 取得APP在架上的版本號 (AppStore)
// => https://itunes.apple.com/lookup?id=443904275
func (_appVersion *AppVersion) AppStoreAppVersion(id string, country string) (map[string]interface{}, error) {

	var list = map[string]interface{}{}

	_id := strings.Replace(id, "id", "", 1)
	number, error := utility.StringToInt(_id)

	if error != nil { return list, error }

	url := fmt.Sprintf("https://itunes.apple.com/lookup?id=%d", number)
	if len(country) > 0 { url = fmt.Sprintf("https://itunes.apple.com/lookup?id=%d&country=%v", number, country) }

	response, error := http.Get(url)

	if error != nil { return list, error }
	defer response.Body.Close()

	bytes, error := ioutil.ReadAll(response.Body)
	if error != nil { return list, error }

	dictionary := utility.RawBodyToMap(bytes)
	results := dictionary["results"].([]interface{})

	if len(results) == 0 { return list, error }
	result := results[0].(map[string]interface{})

	list["version"] = result["version"].(string)
	list["icon"] = result["artworkUrl512"].(string)

	return list, error
}
http://localhost:12345/appVersion/com.nexon.maplem.global/1

// 取得APP在架上的版本號 (GooglePlay)
// => https://play.google.com/store/apps/details?id=com.ubercab.driver&hl=zh_tw
func (_appVersion *AppVersion) GooglePlayAppVersion(id string, hl string) (map[string]interface{}, error) {

	var list = map[string]interface{}{}

	url := fmt.Sprintf("https://play.google.com/store/apps/details?id=%s", id)
	if len(hl) > 0 { url = fmt.Sprintf("https://play.google.com/store/apps/details?id=%s&hl=%v", id, hl) }

	utility.Println(url)

	response, error := http.Get(url)
	if error != nil { return list, error }

	defer response.Body.Close()

	bytes, error := ioutil.ReadAll(response.Body)
	if error != nil { return list, error }

	body := string(bytes)

	rule := `\[\["([\d.]{1,}\d)"]]`
	regular, _ := regexp.Compile(rule)
	version := regular.FindString(body)

	rule = `([\d.]{1,}\d)`
	regular, _ = regexp.Compile(rule)
	version = regular.FindString(version)

	rule = `(srcset="https://play-lh.googleusercontent.com/)(.+)(=w480-h960 2x" class=")`
	regular, _ = regexp.Compile(rule)
	icon := regular.FindString(body)

	rule = `(srcset="https://play-lh.googleusercontent.com/)(.+)(=w480-h960)(.+)(alt=)`
	regular, _ = regexp.Compile(rule)
	icon = regular.FindString(icon)

	rule = `(https://play-lh.googleusercontent.com/)(.+)(=w480-h960)`
	regular, _ = regexp.Compile(rule)
	icon = regular.FindString(icon)

	list["version"] = version
	list["icon"] = icon

	return list, error
}

定時更新 - Cron

  • 不得不說,這也是重點中的重點定時更新
  • 除了定時去取得Store上的資訊之外,同時也存在自己的資料庫中,減少官網的流量,也減少前端去解資料的時間…
  • 最重要的是,萬一Store在維護的話,也不至於取不到資料…
  • 這裡是取的auto_update = true的資料去做更新…
  • 這裡還加了一個紀錄Log的功能 - SystemLog,翻舊帳用的…
// [定時排程任務](https://www.readfog.com/a/1637371620314157056)
func cronSchedule(database *gorm.DB, scheduleType string) {

	schedule := cron.New()

	schedule.AddFunc(scheduleType, func() {
		autoUpdateAllAppVersion(database)
	})

	schedule.Start()
}

// 更新資料庫內(auto_update = true)的版本號
func autoUpdateAllAppVersion(database *gorm.DB) {

	var versionList []model.AppVersion
	var _database = database

	_database = _database.Where("auto_update == ?", true)
	_database.Find(&versionList)

	for index := 0; index < len(versionList); index++ {
		versionInfo := versionList[index]
		autoUpdateAppVersion(database, versionInfo)
	}
}

// 更新資料庫內的版本號
func autoUpdateAppVersion(database *gorm.DB, info model.AppVersion) {

	var appVersion model.AppVersion

	var id = info.AppId
	var lang = info.Lang
	var allError error = nil
	var result map[string]interface{}

	dictionary := map[string]interface{}{}
	dictionary["type"] = fmt.Sprintf(`%v`, info.AppStoreType)
	dictionary["id"] = id
	dictionary["lang"] = lang

	defer func() {
		var systemLog model.SystemLog
		systemLog.Insert(database, dictionary, result, allError)
	}()

	list, error := appVersion.CheckAppVersion(dictionary)
	if error != nil { allError = error; return }

	dictionary["version"] = map[string]interface{}{"store": list["version"]}
	dictionary["icon"] = list["icon"]

	_result, error := appVersion.Update(database, dictionary)
	if error != nil { allError = error; return }

	result = _result
}

Vue 3 + TypeScript

  • 這裡基本的CRUD就不多做介紹了,後面來處理網頁端常見的功能
  • 非專業人員的我,本著防呆不防笨的原則,請大家小心餵食
<template>
  <div class="app" v-loading.fullscreen.lock="isLoading" element-loading-background="rgba(0, 0, 0, 0.7)">

    <h1>手機App版本設定介面</h1>

    <!-- 顯示列表 -->
    <el-table :data="versions">
      <el-table-column :label="versionLabel.icon">
        <template #default="scope">
          <el-image :src="scope.row.icon" />
        </template>
      </el-table-column>
      <el-table-column :label="versionLabel.type">
        <template #default="scope">
          <a :href="scope.row.url" target="_blank"><el-image :src='utility.appIcon(scope.row.type)' /></a>
        </template>
      </el-table-column>
      <el-table-column prop="name" :label="versionLabel.name" />
      <el-table-column prop="id" :label="versionLabel.id" />
      <el-table-column prop="version" :label="versionLabel.version" />
      <el-table-column prop="versionProd" :label="versionLabel.versionProd" />
      <el-table-column prop="versionDev" :label="versionLabel.versionDev" />
      <el-table-column prop="lang" :label="versionLabel.lang" :formatter="utility.langText"/>
      <el-table-column :label="versionLabel.auto">
        <template #default="scope">
          <el-image :src='utility.autoUpdateIcon(scope.row.auto)' />
        </template>
      </el-table-column>
      <el-table-column fixed="right" width="200">
        <template #default="scope">
          <el-button :color=versionLabel.fixButton.color round @click="utility.displayDialog(ButtonType.Edit, true, scope.row)">{{ versionLabel.fixButton.text }}</el-button>
          <el-button :color=versionLabel.deleteButton.color round @click="utility.displayDialog(ButtonType.Delete, true, scope.row)">{{ versionLabel.deleteButton.text }}</el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 功能按鍵 -->
    <el-button-group size="large" class="append-button" >
      <el-button type="primary" @click="utility.displayDialog(ButtonType.Append, true)">新增</el-button>
      <el-button type="success" @click="utility.reloadData()">更新</el-button>
      <el-upload class="inline-block" multiple :action="uploadSetting.action" :headers="uploadSetting.headers" file="file"><el-button type="danger">上傳</el-button></el-upload>
      <el-button type="warning" @click="utility.downloadExcel()">下載</el-button>
    </el-button-group>

    <!-- 刪除確認框 -->
    <el-dialog v-model="confirmDialog.isVisible" :title="confirmDialog.title" :background="confirmDialog.background">
      <template #footer>
        <span class="dialog-footer">
          <el-button type="primary" @click="utility.displayDialog(ButtonType.Cancel, false)">{{ confirmDialog.cancel }}</el-button>
          <el-button type="danger" @click="utility.deleteVersion(utility.tempVersion.value)">{{ confirmDialog.sure }}</el-button>
        </span>
      </template>
    </el-dialog>

    <!-- 編譯確認框 -->
    <el-dialog v-model="editorDialog.isVisible" :title="editorDialog.title" :background="editorDialog.background">
      <el-form-item :label="versionLabel.auto"><el-switch v-model="utility.editorVersion.value.auto" autocomplete="off" /></el-form-item>  
      <el-form-item :label="versionLabel.name"><el-input v-model="utility.editorVersion.value.name" autocomplete="off" /></el-form-item>  
      <el-form-item :label="versionLabel.version"><el-input v-model="utility.editorVersion.value.version.store" autocomplete="off" /></el-form-item>  
      <el-form-item :label="versionLabel.versionProd"><el-input v-model="utility.editorVersion.value.version.prod" autocomplete="off" /></el-form-item>  
      <el-form-item :label="versionLabel.versionDev"><el-input v-model="utility.editorVersion.value.version.dev" autocomplete="off" /></el-form-item>  
      <el-form-item :label="versionLabel.icon"><el-input v-model="utility.editorVersion.value.icon" autocomplete="off" /></el-form-item>  
      <template #footer>
        <span class="dialog-footer">
          <el-button type="primary" @click="utility.displayDialog(ButtonType.Cancel, false)">{{ editorDialog.cancel }}</el-button>
          <el-button type="success" @click="utility.editVersion(utility.tempVersion.value)">{{ editorDialog.sure }}</el-button>
        </span>
      </template>
    </el-dialog>

    <!-- 新增確認框 -->
    <el-dialog v-model="appendDialog.isVisible" :title="appendDialog.title" :background="appendDialog.background">
      <el-form-item :label="versionLabel.id"><el-input v-model="utility.tempAppendVersion.value.id" autocomplete="off" /></el-form-item>  
      <el-form-item :label="versionLabel.name"><el-input v-model="utility.tempAppendVersion.value.name" autocomplete="off" /></el-form-item>  
      <el-form-item :label="versionLabel.type">
        <el-select v-model="utility.tempAppendVersion.value.type" placeholder="請選擇商店" @change="utility.resetLangType">
          <el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
        </el-select>
      </el-form-item>
      <el-form-item :label="versionLabel.lang">
        <el-select v-model="utility.tempAppendVersion.value._lang" placeholder="請選擇語言地區">
          <el-option v-for="item in utility.storeTypeTextList(utility.tempAppendVersion.value.type)" :key="item.value" :label="item.label" :value="item.value"></el-option>
        </el-select>
      </el-form-item>
      <template #footer>
        <span class="dialog-footer">
          <el-button type="primary" @click="utility.displayDialog(ButtonType.Cancel, false)">{{ editorDialog.cancel }}</el-button>
          <el-button type="success" @click="utility.appendVersion()">{{ editorDialog.sure }}</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

Excel下載功能

  • 這邊,是使用這個處理Excel的套件 - excelize
  • 想法就是先把Excel存下來,然後回傳下載的Url,讓網頁端處理…
package model

import (
	"fmt"

	"github.com/xuri/excelize/v2"
)

// [Excelize 繁體字文檔](https://xuri.me/excelize/zh-tw/)
type ExcelTools struct {
	Sheet string
	excel *excelize.File
}

type ExcelField struct {
	Index int
	Key   string
	Value interface{}
}

// 建立工作表
func (_excelTools *ExcelTools) CreateExcel() int {

	_excelTools.excel = excelize.NewFile()

	index := _excelTools.excel.NewSheet(_excelTools.Sheet)
	_excelTools.excel.DeleteSheet("Sheet1")
	_excelTools.excel.SetActiveSheet(index)

	return index
}

// 儲存工作表
func (_excelTools *ExcelTools) SaveAppVersions(versions []AppVersion, filename string) (map[string]interface{}, error) {

	_excel := _excelTools.excel
	_sheet := _excelTools.Sheet

	fields := []ExcelField{
		{Index: 0, Key: "A", Value: "App Name"},
		{Index: 0, Key: "B", Value: "App Id"},
		{Index: 0, Key: "C", Value: "App Store"},
		{Index: 0, Key: "D", Value: "App Lang"},
	}

	for _index := 0; _index < len(fields); _index++ {

		fields[_index].Index += 1

		key := fmt.Sprintf("%v%v", fields[_index].Key, fields[_index].Index)
		value := fmt.Sprintf("%v", fields[_index].Value)

		_excel.SetCellValue(_sheet, key, value)
	}

	for index := 0; index < len(versions); index++ {

		_version := versions[index]
		_values := []interface{}{_version.AppName, _version.AppId, _version.AppStoreType, _version.Lang}

		for _index := 0; _index < len(fields); _index++ {

			fields[_index].Index += 1

			key := fmt.Sprintf("%v%v", fields[_index].Key, fields[_index].Index)
			value := fmt.Sprintf("%v", _values[_index])

			_excel.SetCellValue(_sheet, key, value)
		}
	}

	filePath := fmt.Sprintf("/excel/%s", filename)
	savePath := fmt.Sprintf("./html/public%s", filePath)
	error := _excel.SaveAs(savePath)

	result := map[string]interface{}{}
	result["filePath"] = filePath

	return result, error
}

export default defineComponent({
  name: 'App',
  components: {},
  setup() {
      // 下載Excel
      downloadExcel: () => {
        API.saveVersion().then((result) => {
          window.open(result.filePath)
        })
      },
	}
} 

Excel上傳功能

export default defineComponent({
  name: 'App',
  components: {},
  setup() {

      // 上傳Excel
      uploadExcel: async (info: any) => {        

        const formData = new FormData()
        const url = Constant.GolangAPI.uploadVersion()

        formData.append('file', info.file)

        const response = await axios.post(url, formData)
        const { result } = response.data

        return result
      },
	}
}
  • 後端這邊呢,也是使用這個處理Excel的套件 - excelize
// main.go 
// <POST> 上傳檔案文件
func uploadAppVersions(router *gin.Engine, database *gorm.DB) {

	var excelTools model.ExcelTools

	router.POST("/uploadExcel", func(context *gin.Context) {

		_, header, error := context.Request.FormFile("file")
		result := map[string]interface{}{"filename": header.Filename, "size": header.Size, "filePath": nil}

		defer func() {
			utility.ContextJSON(context, http.StatusOK, result, error)
		}()

		if error != nil {
			return
		}

		filePath := fmt.Sprintf(`./html/public/file/%v_%v`, time.Now().Unix(), header.Filename)
		result["filePath"] = filePath

		error = context.SaveUploadedFile(header, filePath)

		if error != nil {
			return
		}

		result, error = excelTools.InsertExcel(database, Sheet, filePath)
	})
}
  • 解析Excel之後,再一筆筆寫入資料庫中…
// excelTools
// 將Excel匯入DB
func (_excelTools *ExcelTools) InsertExcel(database *gorm.DB, sheet string, filePath string) (map[string]interface{}, error) {

	var appVersion AppVersion
	result := map[string]interface{}{"count": 0}
	count := 0

	excel, error := excelize.OpenFile(filePath)

	if error != nil {
		return result, error
	}

	rows, _error := excel.GetRows(sheet)
	if _error != nil {
		return result, _error
	}

	for index, row := range rows {

		if index == 0 {
			continue
		}

		info := map[string]interface{}{}
		info["name"] = row[0]
		info["id"] = row[1]
		info["type"], _ = utility.StringToFloat64(row[2])

		if len(row) > 3 {
			info["lang"] = row[3]
		}

		_result, _ := appVersion.Insert(database, info)

		if _result["isSuccess"] == true {
			count += 1
		}
	}

	result["count"] = count
	error = excel.Close()

	return result, error
}

範例程式碼下載 - GitHub

後記

真的是:『台上一分鐘,台下十年功』,光想這個主題,然後實做出來就要兩週了,再加上寫文章,至少也要4、5天吧?而且,有沒有人看還不知道呢?有人說,寫這個都把資訊都公開了,不就都讓別人學走了嗎?而且薪水也不會變多啊?其實啊,自己是很想當老師的,但學歷經歷長相都不好的我,就沒什麼賣點啊…還是乖乖當個薪水小偷的好了?還是也來洗一下學歷呢?不然就去整型一下,去當Udemy的講師好了…