新增可复用Go客户端模块

This commit is contained in:
Scout开发者 2026-08-30 22:10:02 +08:00
parent 8c1da695cd
commit 99342696ea
3 changed files with 566 additions and 129 deletions

View File

@ -1,32 +1,17 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"context"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"scout/internal/registry"
"scout/pkg/client"
)
type packageInfo struct {
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
type updateEvent struct {
Package packageInfo `json:"package"`
}
func main() {
if len(os.Args) < 2 {
printUsage()
@ -37,6 +22,8 @@ func main() {
checkCommand(os.Args[2:])
case "download":
downloadCommand(os.Args[2:])
case "upload":
uploadCommand(os.Args[2:])
case "watch":
watchCommand(os.Args[2:])
default:
@ -52,9 +39,12 @@ func checkCommand(args []string) {
current := flags.String("current", "", "当前版本")
flags.Parse(args)
requirePackage(*name)
var latest packageInfo
requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest)
if registry.CompareVersions(latest.Version, *current) > 0 {
api := newClient(*server, "")
latest, available, err := api.CheckUpdate(context.Background(), *name, *current)
if err != nil {
fatal("检查更新失败: %v", err)
}
if available {
fmt.Printf("发现更新:%s %s -> %s\n", latest.Name, *current, latest.Version)
return
}
@ -69,41 +59,55 @@ func downloadCommand(args []string) {
output := flags.String("output", "", "保存路径")
flags.Parse(args)
requirePackage(*name)
if *version == "" {
var latest packageInfo
requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest)
*version = latest.Version
}
if *output == "" {
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, *version))
resolvedVersion := *version
if resolvedVersion == "" {
resolvedVersion = "latest"
}
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, resolvedVersion))
}
url := fmt.Sprintf("%s/api/v1/packages/%s/%s/download", strings.TrimRight(*server, "/"), *name, *version)
response, err := http.Get(url)
api := newClient(*server, "")
item, err := api.Download(context.Background(), *name, *version, *output)
if err != nil {
fatal("请求下载失败: %v", err)
fatal("下载失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
fatalResponse(response)
fmt.Printf("下载完成:%s\n版本:%s\nSHA-256:%s\n", *output, item.Version, item.SHA256)
}
func uploadCommand(args []string) {
flags := flag.NewFlagSet("upload", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "服务端地址")
token := flags.String("token", "", "开发者令牌")
name := flags.String("package", "", "包名")
version := flags.String("version", "", "版本")
filePath := flags.String("file", "", "包文件路径")
description := flags.String("description", "", "包描述")
platform := flags.String("platform", "", "运行平台")
architecture := flags.String("architecture", "", "运行架构")
flags.Parse(args)
requirePackage(*name)
if *version == "" || *filePath == "" {
fatal("必须指定 --version 和 --file")
}
file, err := os.Create(*output)
file, err := os.Open(*filePath)
if err != nil {
fatal("创建输出文件失败: %v", err)
fatal("打开包文件失败: %v", err)
}
_, copyErr := io.Copy(file, response.Body)
closeErr := file.Close()
if copyErr != nil || closeErr != nil {
fatal("保存包文件失败")
}
digest, err := fileSHA256(*output)
defer file.Close()
api := newClient(*server, *token)
item, err := api.Upload(context.Background(), client.UploadRequest{
Name: *name,
Version: *version,
Description: *description,
Platform: *platform,
Architecture: *architecture,
FileName: filepath.Base(*filePath),
File: file,
})
if err != nil {
fatal("校验包文件失败: %v", err)
fatal("上传失败: %v", err)
}
if expected := response.Header.Get("X-Scout-SHA256"); expected != "" && digest != expected {
_ = os.Remove(*output)
fatal("包文件校验失败,文件已删除")
}
fmt.Printf("下载完成:%s\nSHA-256:%s\n", *output, digest)
fmt.Printf("上传成功:%s %s\nSHA-256:%s\n", item.Name, item.Version, item.SHA256)
}
func watchCommand(args []string) {
@ -111,102 +115,34 @@ func watchCommand(args []string) {
server := flags.String("server", "http://localhost:8080", "服务端地址")
packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔")
flags.Parse(args)
url := strings.TrimRight(*server, "/") + "/api/v1/updates/stream"
if *packages != "" {
url += "?packages=" + *packages
}
response, err := http.Get(url)
api := newClient(*server, "")
updates, err := api.WatchUpdates(context.Background(), splitPackages(*packages)...)
if err != nil {
fatal("连接更新通知失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
fatalResponse(response)
}
fmt.Println("已连接更新通知,等待新包……")
readSSE(response.Body)
for update := range updates {
fmt.Printf("收到更新:%s %s\n", update.Package.Name, update.Package.Version)
}
}
func requestJSON(method, url string, body io.Reader, token string, target any) {
request, err := http.NewRequest(method, url, body)
func newClient(server, token string) *client.Client {
api, err := client.New(server, client.WithDeveloperToken(token))
if err != nil {
fatal("创建请求失败: %v", err)
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
fatal("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
fatalResponse(response)
}
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
fatal("解析服务端响应失败: %v", err)
fatal("创建客户端失败: %v", err)
}
return api
}
func readSSE(reader io.Reader) {
dataLines := make([]string, 0, 1)
buffer := make([]byte, 4096)
pending := ""
for {
count, err := reader.Read(buffer)
if count > 0 {
pending += string(buffer[:count])
for {
lineEnd := strings.IndexByte(pending, '\n')
if lineEnd < 0 {
break
}
line := strings.TrimSuffix(pending[:lineEnd], "\r")
pending = pending[lineEnd+1:]
if line == "" {
if len(dataLines) > 0 {
var event updateEvent
if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &event) == nil {
fmt.Printf("收到更新:%s %s\n", event.Package.Name, event.Package.Version)
}
}
dataLines = dataLines[:0]
continue
}
if strings.HasPrefix(line, "data: ") {
dataLines = append(dataLines, strings.TrimPrefix(line, "data: "))
}
}
}
if err != nil {
if !errors.Is(err, io.EOF) {
fatal("读取更新通知失败: %v", err)
}
return
}
func splitPackages(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func fatalResponse(response *http.Response) {
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
fatal("服务端返回 %s:%s", response.Status, strings.TrimSpace(string(body)))
return strings.Split(value, ",")
}
func requirePackage(name string) {
if name == "" {
if strings.TrimSpace(name) == "" {
fatal("必须指定 --package")
}
}
@ -218,5 +154,8 @@ func fatal(format string, args ...any) {
func printUsage() {
fmt.Println("Scout 轻量化包管理客户端")
fmt.Println("用法:scout-client <check|download|watch> [参数]")
fmt.Println("用法:scout-client <check|download|upload|watch> [参数]")
fmt.Println("upload 用于开发者上传,check、download、watch 用于客户端更新流程")
}
var _ io.Reader

382
pkg/client/client.go Normal file
View File

@ -0,0 +1,382 @@
package client
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
type Package struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Platform string `json:"platform,omitempty"`
Architecture string `json:"architecture,omitempty"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
UploadedAt string `json:"uploaded_at"`
}
type Update struct {
Package Package `json:"package"`
}
type UploadRequest struct {
Name string
Version string
Description string
Platform string
Architecture string
FileName string
File io.Reader
}
type Option func(*Client)
type Client struct {
baseURL string
httpClient *http.Client
token string
}
func New(baseURL string, options ...Option) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, errors.New("服务端地址不能为空")
}
if _, err := url.ParseRequestURI(baseURL); err != nil {
return nil, fmt.Errorf("服务端地址无效: %w", err)
}
client := &Client{baseURL: baseURL, httpClient: http.DefaultClient}
for _, option := range options {
option(client)
}
return client, nil
}
func WithHTTPClient(httpClient *http.Client) Option {
return func(client *Client) {
if httpClient != nil {
client.httpClient = httpClient
}
}
}
func WithDeveloperToken(token string) Option {
return func(client *Client) {
client.token = token
}
}
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
var response struct {
Packages []Package `json:"packages"`
}
if err := c.getJSON(ctx, "/api/v1/packages", &response); err != nil {
return nil, err
}
return response.Packages, nil
}
func (c *Client) GetLatest(ctx context.Context, name string) (Package, error) {
var item Package
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name), &item); err != nil {
return Package{}, err
}
return item, nil
}
func (c *Client) ListVersions(ctx context.Context, name string) ([]Package, error) {
var response struct {
Name string `json:"name"`
Versions []Package `json:"versions"`
}
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name)+"/versions", &response); err != nil {
return nil, err
}
return response.Versions, nil
}
func (c *Client) CheckUpdate(ctx context.Context, name, currentVersion string) (Package, bool, error) {
latest, err := c.GetLatest(ctx, name)
if err != nil {
return Package{}, false, err
}
return latest, CompareVersions(latest.Version, currentVersion) > 0, nil
}
func (c *Client) Download(ctx context.Context, name, version, destination string) (Package, error) {
if version == "" {
latest, err := c.GetLatest(ctx, name)
if err != nil {
return Package{}, err
}
version = latest.Version
}
path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return Package{}, err
}
response, err := c.httpClient.Do(request)
if err != nil {
return Package{}, fmt.Errorf("请求下载失败: %w", err)
}
defer response.Body.Close()
if err := checkResponse(response); err != nil {
return Package{}, err
}
if destination == "" {
return Package{}, errors.New("保存路径不能为空")
}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return Package{}, fmt.Errorf("创建保存目录失败: %w", err)
}
file, err := os.Create(destination)
if err != nil {
return Package{}, fmt.Errorf("创建保存文件失败: %w", err)
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body)
closeErr := file.Close()
if copyErr != nil {
return Package{}, fmt.Errorf("保存包文件失败: %w", copyErr)
}
if closeErr != nil {
return Package{}, fmt.Errorf("关闭保存文件失败: %w", closeErr)
}
digest := hex.EncodeToString(hash.Sum(nil))
expected := response.Header.Get("X-Scout-SHA256")
if expected != "" && !strings.EqualFold(expected, digest) {
return Package{}, fmt.Errorf("包文件校验失败: 期望 %s,实际 %s", expected, digest)
}
return Package{
Name: name,
Version: version,
Size: response.ContentLength,
SHA256: digest,
}, nil
}
func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, error) {
if c.token == "" {
return Package{}, errors.New("上传包需要开发者令牌")
}
if input.File == nil {
return Package{}, errors.New("包文件不能为空")
}
bodyReader, bodyWriter := io.Pipe()
multipartWriter := multipart.NewWriter(bodyWriter)
go func() {
err := writeUploadForm(multipartWriter, input)
if closeErr := multipartWriter.Close(); err == nil {
err = closeErr
}
_ = bodyWriter.CloseWithError(err)
}()
request, err := c.newRequest(ctx, http.MethodPost, "/api/v1/packages/upload", bodyReader)
if err != nil {
return Package{}, err
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
request.Header.Set("Authorization", "Bearer "+c.token)
response, err := c.httpClient.Do(request)
if err != nil {
return Package{}, fmt.Errorf("请求上传失败: %w", err)
}
defer response.Body.Close()
if err := checkResponse(response); err != nil {
return Package{}, err
}
var item Package
if err := json.NewDecoder(response.Body).Decode(&item); err != nil {
return Package{}, fmt.Errorf("解析上传响应失败: %w", err)
}
return item, nil
}
func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan Update, error) {
query := url.Values{}
wanted := make([]string, 0, len(packages))
for _, name := range packages {
if strings.TrimSpace(name) != "" {
wanted = append(wanted, name)
}
}
if len(wanted) > 0 {
query.Set("packages", strings.Join(wanted, ","))
}
path := "/api/v1/updates/stream"
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
response, err := c.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("连接更新通知失败: %w", err)
}
if err := checkResponse(response); err != nil {
response.Body.Close()
return nil, err
}
updates := make(chan Update)
go readUpdates(ctx, response.Body, updates)
return updates, nil
}
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return err
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %w", err)
}
defer response.Body.Close()
if err := checkResponse(response); err != nil {
return err
}
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
return fmt.Errorf("解析服务端响应失败: %w", err)
}
return nil
}
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
if c.token != "" {
request.Header.Set("Authorization", "Bearer "+c.token)
}
return request, nil
}
func writeUploadForm(writer *multipart.Writer, input UploadRequest) error {
fields := map[string]string{
"name": input.Name,
"version": input.Version,
"description": input.Description,
"platform": input.Platform,
"architecture": input.Architecture,
}
for name, value := range fields {
if err := writer.WriteField(name, value); err != nil {
return err
}
}
part, err := writer.CreateFormFile("file", input.FileName)
if err != nil {
return err
}
_, err = io.Copy(part, input.File)
return err
}
func checkResponse(response *http.Response) error {
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
message := strings.TrimSpace(string(body))
if message == "" {
message = response.Status
}
return fmt.Errorf("服务端返回 %s: %s", response.Status, message)
}
func readUpdates(ctx context.Context, body io.ReadCloser, updates chan<- Update) {
defer close(updates)
defer body.Close()
go func() {
<-ctx.Done()
_ = body.Close()
}()
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 4096), 4<<20)
dataLines := make([]string, 0, 1)
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" {
if len(dataLines) > 0 {
var update Update
if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &update) == nil {
select {
case updates <- update:
case <-ctx.Done():
return
}
}
}
dataLines = dataLines[:0]
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
}
func CompareVersions(left, right string) int {
leftParts := splitVersion(left)
rightParts := splitVersion(right)
for index := 0; index < len(leftParts) || index < len(rightParts); index++ {
if index >= len(leftParts) {
return -1
}
if index >= len(rightParts) {
return 1
}
leftNumber, leftOK := numberPart(leftParts[index])
rightNumber, rightOK := numberPart(rightParts[index])
if leftOK && rightOK && leftNumber != rightNumber {
if leftNumber > rightNumber {
return 1
}
return -1
}
if leftParts[index] != rightParts[index] {
return strings.Compare(leftParts[index], rightParts[index])
}
}
return 0
}
func splitVersion(version string) []string {
version = strings.TrimPrefix(version, "v")
version = strings.SplitN(version, "+", 2)[0]
parts := strings.FieldsFunc(version, func(char rune) bool {
return char == '.' || char == '-' || char == '_'
})
return parts
}
func numberPart(value string) (int64, bool) {
if value == "" {
return 0, false
}
var number int64
for _, char := range value {
if char < '0' || char > '9' {
return 0, false
}
number = number*10 + int64(char-'0')
}
return number, true
}

116
pkg/client/client_test.go Normal file
View File

@ -0,0 +1,116 @@
package client
import (
"bytes"
"context"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"scout/internal/httpapi"
"scout/internal/registry"
)
func TestClientUploadQueryCheckAndDownload(t *testing.T) {
store, err := registry.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer((&httpapi.Server{Registry: store, DeveloperToken: "测试令牌"}).Handler())
defer server.Close()
api, err := New(server.URL, WithDeveloperToken("测试令牌"))
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
uploaded, err := api.Upload(ctx, UploadRequest{
Name: "client-demo",
Version: "1.0.0",
FileName: "demo.pkg",
File: bytes.NewBufferString("client package"),
})
if err != nil {
t.Fatal(err)
}
if uploaded.SHA256 == "" {
t.Fatal("上传结果缺少 SHA-256")
}
packages, err := api.ListPackages(ctx)
if err != nil || len(packages) != 1 {
t.Fatalf("包列表错误: %v, %d", err, len(packages))
}
latest, available, err := api.CheckUpdate(ctx, "client-demo", "0.9.0")
if err != nil || !available || latest.Version != "1.0.0" {
t.Fatalf("更新检查错误: %+v, %t, %v", latest, available, err)
}
versions, err := api.ListVersions(ctx, "client-demo")
if err != nil || len(versions) != 1 {
t.Fatalf("版本列表错误: %v, %d", err, len(versions))
}
output := filepath.Join(t.TempDir(), "download.pkg")
downloaded, err := api.Download(ctx, "client-demo", "", output)
if err != nil {
t.Fatal(err)
}
if downloaded.SHA256 != uploaded.SHA256 {
t.Fatalf("下载校验值不一致: %s != %s", downloaded.SHA256, uploaded.SHA256)
}
content, err := os.ReadFile(output)
if err != nil || string(content) != "client package" {
t.Fatalf("下载内容错误: %q, %v", content, err)
}
}
func TestClientWatchUpdates(t *testing.T) {
store, err := registry.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer((&httpapi.Server{Registry: store, DeveloperToken: "测试令牌"}).Handler())
defer server.Close()
api, err := New(server.URL, WithDeveloperToken("测试令牌"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
updates, err := api.WatchUpdates(ctx, "client-demo")
if err != nil {
t.Fatal(err)
}
go func() {
_, _ = api.Upload(context.Background(), UploadRequest{
Name: "client-demo",
Version: "2.0.0",
FileName: "demo.pkg",
File: bytes.NewBufferString("new package"),
})
}()
select {
case update := <-updates:
if update.Package.Name != "client-demo" || update.Package.Version != "2.0.0" {
t.Fatalf("通知内容错误: %+v", update)
}
case <-time.After(2 * time.Second):
t.Fatal("等待更新通知超时")
}
}
func TestCompareVersions(t *testing.T) {
cases := []struct {
left string
right string
want int
}{
{left: "1.10.0", right: "1.9.0", want: 1},
{left: "v1.0.0", right: "1.0.0", want: 0},
{left: "1.0.0", right: "2.0.0", want: -1},
}
for _, item := range cases {
if got := CompareVersions(item.left, item.right); got != item.want {
t.Fatalf("CompareVersions(%q, %q) = %d, want %d", item.left, item.right, got, item.want)
}
}
}