perf(attachment): 上传流式化(io.Pipe 零内存缓冲)+ 大文件进度显示

This commit is contained in:
Taoyouce 2026-07-07 15:43:34 +00:00
parent 0bd100a5e3
commit cd5338f303
2 changed files with 141 additions and 19 deletions

View File

@ -1,7 +1,6 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
@ -14,6 +13,63 @@ import (
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// progressThreshold is the minimum file size for which upload progress is
// reported on stderr.
const progressThreshold = 1 << 20 // 1 MiB
// progressReporter prints upload progress to stderr at 10% steps for files
// larger than progressThreshold. It implements io.Writer so it can sit on
// the tee side of the upload stream.
type progressReporter struct {
name string
total int64
done int64
lastPct int64
lastLine int
out io.Writer
}
func newProgressReporter(name string, total int64) *progressReporter {
return &progressReporter{name: name, total: total, lastPct: -1, out: os.Stderr}
}
func newProgressReporterTo(name string, total int64, out io.Writer) *progressReporter {
return &progressReporter{name: name, total: total, lastPct: -1, out: out}
}
func (p *progressReporter) Write(b []byte) (int, error) {
p.done += int64(len(b))
if p.total >= progressThreshold {
pct := p.done * 100 / p.total
if pct/10 > p.lastPct/10 || (pct == 100 && p.lastPct != 100) {
line := fmt.Sprintf("uploading %s: %d%% (%s / %s)", p.name, pct, formatBytes(p.done), formatBytes(p.total))
if pad := p.lastLine - len(line); pad > 0 {
line += strings.Repeat(" ", pad)
}
fmt.Fprintf(p.out, "\r%s", line)
p.lastLine = len(line)
if pct >= 100 {
fmt.Fprintln(p.out)
}
p.lastPct = pct
}
}
return len(b), nil
}
func formatBytes(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30))
case n >= 1<<20:
return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10))
default:
return fmt.Sprintf("%d B", n)
}
}
// PostMultipartFile uploads a local file as a multipart/form-data request.
// fileField is the form field name for the file (GitLink expects "file");
// extra fields (e.g. description) are added as plain form values.
@ -24,28 +80,39 @@ func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[
}
defer f.Close()
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile(fileField, filepath.Base(filePath))
info, err := f.Stat()
if err != nil {
return nil, err
}
if _, err := io.Copy(part, f); err != nil {
return nil, fmt.Errorf("read upload file: %w", err)
}
for k, v := range fields {
if v != "" {
if err := writer.WriteField(k, v); err != nil {
return nil, err
}
}
}
if err := writer.Close(); err != nil {
return nil, err
return nil, fmt.Errorf("stat upload file: %w", err)
}
// Stream the multipart body through a pipe so arbitrarily large files
// are never buffered in memory.
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
progress := newProgressReporter(filepath.Base(filePath), info.Size())
go func() {
part, err := writer.CreateFormFile(fileField, filepath.Base(filePath))
if err != nil {
pw.CloseWithError(err)
return
}
if _, err := io.Copy(part, io.TeeReader(f, progress)); err != nil {
pw.CloseWithError(fmt.Errorf("read upload file: %w", err))
return
}
for k, v := range fields {
if v != "" {
if err := writer.WriteField(k, v); err != nil {
pw.CloseWithError(err)
return
}
}
}
pw.CloseWithError(writer.Close())
}()
fullURL := c.BaseURL + normalizeAPIPath(c.BaseURL, path)
req, err := http.NewRequest("POST", fullURL, &buf)
req, err := http.NewRequest("POST", fullURL, pr)
if err != nil {
return nil, err
}

View File

@ -0,0 +1,55 @@
package client
import (
"bytes"
"strings"
"testing"
)
func TestProgressReporterLargeFile(t *testing.T) {
var buf bytes.Buffer
total := int64(4 << 20)
p := newProgressReporterTo("big.bin", total, &buf)
chunk := make([]byte, 1<<20)
for i := 0; i < 4; i++ {
if _, err := p.Write(chunk); err != nil {
t.Fatal(err)
}
}
out := buf.String()
if !strings.Contains(out, "uploading big.bin") {
t.Fatalf("missing progress prefix: %q", out)
}
if !strings.Contains(out, "100%") {
t.Fatalf("missing 100%% mark: %q", out)
}
if !strings.Contains(out, "4.0 MiB / 4.0 MiB") {
t.Fatalf("missing byte summary: %q", out)
}
}
func TestProgressReporterSmallFileSilent(t *testing.T) {
var buf bytes.Buffer
p := newProgressReporterTo("small.txt", 1024, &buf)
if _, err := p.Write(make([]byte, 1024)); err != nil {
t.Fatal(err)
}
if buf.Len() != 0 {
t.Fatalf("expected no progress output for small file, got %q", buf.String())
}
}
func TestFormatBytes(t *testing.T) {
cases := map[int64]string{
512: "512 B",
2 << 10: "2.0 KiB",
3 << 20: "3.0 MiB",
5 << 30: "5.0 GiB",
}
for in, want := range cases {
if got := formatBytes(in); got != want {
t.Fatalf("formatBytes(%d) = %q, want %q", in, got, want)
}
}
}