【Golang 1.19】Go! Go! 購物趣! 取得APP版本號
聽說Golang也開始支援泛型 (Generics)了,但聽說可能會使程式變慢?求不要更新了,老子學不動了… 這次呢,主要是要做一個App版本的設定後台,因為在學了Flutter之後,發現Apple真的是太佛心了,有提供iTunes Search API,來取得該App在AppStore上的相關訊息;反觀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
- 在AppStore的部分,因為有iTunes Search API的保佑,一下子就處理好了,感謝Apple賜予我食物…
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
}
- 在GooglePlay的部分,就沒有這麼簡單 (臣妾做不到啊),要去自己爬網頁…
- 我的正規表示式很差,要解三次,意思有到就好…
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
<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上傳功能
- 網頁端這邊就很單純,模擬form的功能,選擇檔案上傳…
- 先把檔案存在./html/public/file/這邊,之後再去解析…
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的講師好了…