Reader/Writer 流式 I/O(govips v2.19.0)
govips v2.19.0 可以直接把 io.Reader 连接到 libvips,并把编码结果写入 io.Writer。这避免了先用 io.ReadAll 把完整压缩文件复制到 Go byte slice,也让 HTTP Body、对象存储流和管道能够使用同一套接口。
三个 API 的职责
Section titled “三个 API 的职责”| API | 作用 | 访问策略 |
|---|---|---|
LoadImageFromReader |
从任意 io.Reader 创建 ImageRef |
默认实体化;显式 AccessSequential 时延迟读取 |
SaveToWriter / typed variants |
把编码块直接写入 io.Writer |
取决于编码器;TIFF 等格式可能需要完整缓冲 |
TranscodeStream |
组合 Reader、可选自动旋转和 Writer | 根据方向信息自动选择顺序或实体化路径 |
这些 API 在 govips v2.17.0 中不存在;本页和可执行示例均锁定到 v2.19.0。
可执行的顺序流示例
Section titled “可执行的顺序流示例”Reference 实现显式设置 AccessSequential,并保持输入 Reader 打开直到 ImageRef.Close()。四个 binding 都生成同一产品结果;sharp、pyvips 与 CLI 在这里用于结果比较,并不代表它们拥有与 govips 完全相同的 Reader/Writer 接口。
运行命令
go run . input.jpg output/完整源码
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/davidbyttow/govips/v2/vips"
"github.com/webp-sh/vips-handbook/examples/internal/recipeutil"
)
type countingWriter struct {
writer io.Writer
written int
}
func (w *countingWriter) Write(data []byte) (int, error) {
n, err := w.writer.Write(data)
w.written += n
return n, err
}
func main() {
if err := run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) != 3 {
return fmt.Errorf("usage: stream-basic <input.jpg> <output-directory>")
}
inputPath, outputDir := args[1], args[2]
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
vips.LoggingSettings(nil, vips.LogLevelError)
if err := vips.Startup(nil); err != nil {
return fmt.Errorf("start libvips: %w", err)
}
defer vips.Shutdown()
input, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer input.Close()
outputPath := filepath.Join(outputDir, "streamed.webp")
output, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
counter := &countingWriter{writer: output}
result, streamErr := streamWebp(input, counter)
closeErr := output.Close()
if streamErr != nil {
return streamErr
}
if closeErr != nil {
return fmt.Errorf("close output: %w", closeErr)
}
result.Path = filepath.Base(outputPath)
result.Size = counter.written
if err := recipeutil.WriteResult(outputDir, "stream-basic", inputPath, []recipeutil.Output{result}); err != nil {
return err
}
fmt.Printf("stream-basic: %dx%d, %d bytes\n", result.Width, result.Height, result.Size)
return nil
}
func streamWebp(input io.Reader, output io.Writer) (recipeutil.Output, error) {
params := vips.NewImportParams()
params.Access.Set(vips.AccessSequential)
image, err := vips.LoadImageFromReader(input, params)
if err != nil {
return recipeutil.Output{}, fmt.Errorf("load sequential stream: %w", err)
}
defer image.Close()
export := vips.NewWebpExportParams()
export.Quality = 82
export.StripMetadata = true
if err := image.SaveToWriterWebp(output, export); err != nil {
return recipeutil.Output{}, fmt.Errorf("save WebP stream: %w", err)
}
return recipeutil.Output{
Parameters: map[string]any{
"access": "sequential",
"input": "io.Reader",
"output": "io.Writer",
"pipeline": "LoadImageFromReader → SaveToWriterWebp",
"quality": 82,
"metadata": "strip",
},
Format: "webp",
Width: image.Width(),
Height: image.Height(),
Bands: image.Bands(),
HasAlpha: image.HasAlpha(),
}, nil
}运行命令
node index.mjs input.jpg output/完整源码
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
const [inputPath, outputDir] = process.argv.slice(2);
if (!inputPath || !outputDir) {
console.error('Usage: node index.mjs <input.jpg> <output-directory>');
process.exit(2);
}
try {
await mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'streamed.webp');
const writeResult = await sharp(inputPath, { sequentialRead: true })
.webp({ quality: 82 })
.toFile(outputPath);
await writeFile(
path.join(outputDir, 'result.json'),
`${JSON.stringify({
recipeId: 'stream-basic',
generator: { sharp: sharp.versions.sharp, libvips: sharp.versions.vips },
input: path.basename(inputPath),
outputs: [{
path: 'streamed.webp', format: writeResult.format,
width: writeResult.width, height: writeResult.height, size: writeResult.size,
}],
}, null, 2)}\n`,
);
console.log(`stream-basic: ${writeResult.width}x${writeResult.height}, ${writeResult.size} bytes`);
} catch (error) {
console.error(error);
process.exit(1);
}运行命令
python3 main.py input.jpg output/完整源码
import sys
import json
import os
import pyvips
input_path, output_dir = sys.argv[1], sys.argv[2]
if not input_path or not output_dir:
print("Usage: python3 main.py <input.jpg> <output-directory>", file=sys.stderr)
raise SystemExit(2)
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "streamed.webp")
try:
image = pyvips.Image.new_from_file(input_path, access="sequential")
image.write_to_file(output_path, Q=82, keep="none")
result_path = os.path.join(output_dir, "result.json")
with open(result_path, "w") as f:
json.dump({
"recipeId": "stream-basic",
"generator": {"pyvips": pyvips.__version__,
"libvips": f"{pyvips.version(0)}.{pyvips.version(1)}.{pyvips.version(2)}"},
"input": os.path.basename(input_path),
"outputs": [{"path": "streamed.webp", "format": "webp", "width": image.width, "height": image.height,
"bands": image.bands, "hasAlpha": image.hasalpha(), "size": os.path.getsize(output_path)}],
}, f, indent=2)
f.write("\n")
print(f"stream-basic: {image.width}x{image.height}, {os.path.getsize(output_path)} bytes")
except pyvips.Error as error:
print(error, file=sys.stderr)
raise SystemExit(1)运行命令
bash run.sh input.jpg output/完整源码
#!/usr/bin/env bash
set -euo pipefail
INPUT="$1"
OUTPUT_DIR="$2"
if [ $# -ne 2 ]; then
echo "Usage: bash run.sh <input.jpg> <output-directory>" >&2
exit 2
fi
mkdir -p "$OUTPUT_DIR"
OUTPUT_WEBP="$OUTPUT_DIR/streamed.webp"
# sequential access + WebP export
# The [access=sequential] hint tells libvips to read top-to-bottom
# without building a full random-access cache. This is the CLI
# equivalent of the govips/sharp/pyvips sequential modes.
vips copy "${INPUT}[access=sequential]" "$OUTPUT_WEBP[Q=82,keep=none]"
IDATA="$(vipsheader --all "$OUTPUT_WEBP")"
WIDTH="$(echo "$IDATA" | grep '^width:' | awk '{print $2}')"
HEIGHT="$(echo "$IDATA" | grep '^height:' | awk '{print $2}')"
BANDS="$(echo "$IDATA" | grep '^bands:' | awk '{print $2}')"
FILESIZE="$(stat -c%s "$OUTPUT_WEBP")"
LIBVIPS="$(vips --version | sed 's/vips-//')"
cat > "$OUTPUT_DIR/result.json" << EOF
{
"recipeId": "stream-basic",
"generator": {
"vipsCli": "${LIBVIPS}",
"libvips": "${LIBVIPS}"
},
"input": "$(basename "$INPUT")",
"outputs": [{
"path": "streamed.webp",
"format": "webp",
"width": ${WIDTH},
"height": ${HEIGHT},
"bands": ${BANDS},
"hasAlpha": false,
"size": ${FILESIZE}
}]
}
EOF
echo "stream-basic: ${WIDTH}x${HEIGHT}, ${FILESIZE} bytes"检查生成结果
Section titled “检查生成结果”
Generated with govips v2.19.0 / libvips 8.17.3.
- Format: WEBP
- Dimensions: 1938 × 1292
- Bands: 3
- Alpha: No
- File size: 398,898 bytes
- Generated with: govips v2.19.0 / libvips 8.17.3
Reference 的 Reader 与 Writer 错误传播已经由自动化测试覆盖。顺序模式下,加载函数通常只读取足够的头部;截断或中途失败可能直到 SaveToWriterWebp 首次完整消费像素时才返回。
顺序路径与实体化路径
Section titled “顺序路径与实体化路径”顺序路径适合缩小、裁剪、flatten、颜色空间转换、水平翻转和多数行卷积。90°/180°/270° 旋转、垂直翻转、Smart Crop 等需要乱序读取像素的操作必须先实体化,否则会得到 out of order read。
params := vips.NewImportParams()params.Access.Set(vips.AccessSequential)
image, err := vips.LoadImageFromReader(input, params)if err != nil { return fmt.Errorf("load stream: %w", err)}defer image.Close()
if err := image.SaveToWriterWebp(output, vips.NewWebpExportParams()); err != nil { return fmt.Errorf("save stream: %w", err)}如果业务需要根据 EXIF 方向决定是否实体化,可使用 TranscodeStream:
err := vips.TranscodeStream(input, output, &vips.TranscodeOptions{ Format: vips.ImageTypeWEBP, AutoRotate: true, ExportParams: &vips.ExportParams{ Quality: 82, Interlaced: false, StripMetadata: true, },})if err != nil { return fmt.Errorf("transcode stream: %w", err)}TranscodeStream 会让方向 1 和 2 留在顺序路径;需要转置或反向读取行的方向会先实体化。
不可信输入和错误边界
Section titled “不可信输入和错误边界”- 在 HTTP 层限制请求体总长度;
SetPipeReadLimit只限制非 seekable Reader 的头部缓冲,不等于上传大小限制。 - 顺序模式必须让 Reader 存活到
ImageRef.Close()。 - Reader 和 Writer 的原始错误会被包装,可以用
errors.Is判断。 - progressive JPEG、interlaced PNG、HEIF 解码和 TIFF 输出可能需要额外缓冲,不能仅凭 API 名称推断内存上限。
- 需要取消显式 Load/Save 流程时,可以从另一个 goroutine 调用
image.SetKill(true);完成后应清除 kill flag。TranscodeStream当前不接收context.Context。
比较顺序与实体化路径时,应固定输入、libvips/govips 版本、并发度和编码参数,并同时记录峰值 RSS、退出码、输出尺寸与文件大小。单次本机结果只能描述该环境,不能推广为所有格式和机器的性能结论。
- 阅读执行模型,理解需求驱动求值。
- 阅读生产环境生命周期管理,检查
Startup、Shutdown和ImageRef.Close()。