文件上传与静态资源
multer、express.static、云存储——处理文件的一切
🎯 学习目标
学完本章,你将能够:
- 使用 multer 处理文件上传,支持磁盘和内存两种存储模式
- 实现图片压缩和格式转换等常见处理
- 配置静态文件服务和 SPA 回退路由
- 使用云存储(S3/R2)和预签名 URL 实现可扩展的文件服务
- 对上传文件进行安全检查,防止恶意文件攻击
你有没有想过
你在社交媒体上传一张照片,服务器是怎么接收并保存的?
普通的 JSON 请求,Express 用 express.json() 就能解析。但文件不一样——它是二进制数据,需要特殊的中间件来处理。
这一章你将学会:用 multer 处理文件上传、用 express.static 托管静态文件、把文件存到云存储。
Part 1: 文件上传
从接收文件到安全校验——构建可靠的文件上传服务
multer 是 Express 生态中最流行的文件上传中间件,支持单文件、多文件上传和自定义存储策略。本部分你将学会 multer 的完整配置、图片处理、文件下载,以及前端上传代码的实现。
multer 配置
安装后配置存储位置、文件名和过滤规则
$ npm install multer
$ npm install -D @types/multer
// src/middleware/upload.ts
import multer, { FileFilterCallback } from 'multer';
import { Request } from 'express';
import path from 'path';
import crypto from 'crypto';
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
const uniqueSuffix = crypto.randomUUID();
const ext = path.extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
},
});
const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const fileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (ALLOWED_MIMES.includes(file.mimetype)) cb(null, true);
else cb(new Error(`不支持的文件类型: ${file.mimetype}`));
};
export const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024, files: 5 }, // 5MB,最多5个
});
上传路由
单文件、多文件、多字段——三种上传模式
// 单文件上传
router.post('/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: '请选择文件' });
res.json({
originalName: req.file.originalname,
filename: req.file.filename,
size: req.file.size,
});
});
// 多文件上传(同一字段名,最多5个)
router.post('/images', upload.array('images', 5), (req, res) => {
const files = req.files as Express.Multer.File[];
res.json({ count: files.length });
});
// 多字段上传
router.post('/profile', upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 10 },
]), (req, res) => {
const files = req.files as { [key: string]: Express.Multer.File[] };
res.json({ avatar: files['avatar']?.[0]?.filename });
});
内存存储模式
文件不落盘,直接在内存中处理——适合上传到云存储
// src/config/upload-memory.ts
import multer from 'multer';
export const memoryUpload = multer({
storage: multer.memoryStorage(), // 文件在 req.file.buffer 中
limits: { fileSize: 10 * 1024 * 1024 },
});
磁盘存储适合本地开发;内存存储适合需要直接上传到 S3/R2 等云存储的场景。文件不需要写入磁盘,直接从内存转发。
图片处理
用 sharp 库对上传的图片做缩放、格式转换和水印
$ npm install sharp
$ npm install -D @types/sharp
// src/utils/image.ts
import sharp from 'sharp';
import path from 'path';
// 缩放 + 格式转换
export async function processImage(buffer: Buffer, options?: {
width?: number; format?: 'webp' | 'jpeg' | 'png';
}) {
const { width = 800, format = 'webp' } = options ?? {};
return sharp(buffer)
.resize(width, null, { withoutEnlargement: true })
.toFormat(format, { quality: 80 })
.toBuffer();
}
// 生成缩略图
export async function createThumbnail(buffer: Buffer) {
return sharp(buffer)
.resize(200, 200, { fit: 'cover' })
.webp({ quality: 70 })
.toBuffer();
}
// 添加水印
export async function addWatermark(buffer: Buffer, text: string) {
const svg = `<svg><text x="10" y="30" fill="white"
opacity="0.5" font-size="20">${text}</text></svg>`;
return sharp(buffer)
.composite([{
input: Buffer.from(svg),
gravity: 'southeast',
}])
.toBuffer();
}
sharp 基于 libvips,处理速度比 ImageMagick 快 5-10 倍,是 Node.js 图片处理的最佳选择。建议统一把用户上传的图片转为 WebP 格式,体积减少 30-50%。
图片处理实战
上传后自动缩放 + 转换 + 生成缩略图的完整流程
// src/routes/upload-image.ts
import { memoryUpload } from '../config/upload-memory';
import { processImage, createThumbnail } from '../utils/image';
import { uploadToCloud } from '../config/storage';
router.post('/upload/image',
memoryUpload.single('image'),
async (req, res) => {
const file = req.file!;
try {
// 1. 处理原图:缩放到最大宽度 1200px,转 WebP
const processed = await processImage(file.buffer, {
width: 1200, format: 'webp',
});
// 2. 生成缩略图
const thumb = await createThumbnail(file.buffer);
// 3. 上传到云存储
const [original, thumbnail] = await Promise.all([
uploadToCloud(processed, 'image.webp'),
uploadToCloud(thumb, 'thumb.webp'),
]);
res.json({ original: original.url, thumbnail: thumbnail.url });
} catch (err) {
res.status(500).json({ error: '图片处理失败' });
}
}
);
文件下载端点
三种下载方式:直接下载、流式传输、分片请求
// 方式一:res.download() —— 触发浏览器下载对话框
router.get('/download/:key', async (req, res) => {
const filePath = path.join(UPLOAD_DIR, req.params.key);
res.download(filePath, '下载文件.pdf'); // 自定义下载文件名
});
// 方式二:流式传输 —— 适合大文件,不占用整个内存
router.get('/stream/:key', (req, res) => {
const filePath = path.join(UPLOAD_DIR, req.params.key);
const stream = fs.createReadStream(filePath);
stream.pipe(res);
});
// 方式三:Range 请求 —— 支持视频/音频拖拽进度条
router.get('/media/:key', (req, res) => {
const filePath = path.join(UPLOAD_DIR, req.params.key);
const stat = fs.statSync(filePath);
const range = req.headers.range; // bytes=0-1024
if (range) {
const [start, end] = range.replace(/bytes=/, '').split('-')
.map(Number);
res.status(206)
.setHeader('Content-Range', `bytes ${start}-${end || stat.size - 1}/${stat.size}`)
.setHeader('Content-Length', (end || stat.size) - start + 1);
fs.createReadStream(filePath, { start, end }).pipe(res);
} else {
res.setHeader('Content-Length', stat.size);
fs.createReadStream(filePath).pipe(res);
}
});
res.download() 会自动设置 Content-Disposition 头,浏览器弹出保存对话框。fs.createReadStream 适合大文件,分块传输不撑爆内存。Range 请求是视频/音频播放拖拽的必要条件。
前端上传代码
从原生表单到 React/Vue 组件——前端上传的完整写法
<!-- 最简单的表单上传 -->
<form action="/api/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<button type="submit">上传</button>
</form>
// FormData + fetch 上传(无进度)
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
const res = await fetch('/api/upload', {
method: 'POST', body: formData,
});
const data = await res.json();
// XMLHttpRequest 上传(带进度条)
function uploadWithProgress(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const fd = new FormData();
fd.append('avatar', file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
xhr.onload = () => resolve(JSON.parse(xhr.responseText));
xhr.onerror = () => reject(new Error('上传失败'));
xhr.open('POST', '/api/upload');
xhr.send(fd);
});
}
React/Vue 上传组件
带进度条和拖拽上传的前端组件示例
// React 上传组件
function UploadButton() {
const [progress, setProgress] = useState(0);
const [dragging, setDragging] = useState(false);
const handleUpload = async (file) => {
const fd = new FormData();
fd.append('avatar', file);
await fetch('/api/upload', {
method: 'POST', body: fd,
});
};
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault(); setDragging(false);
handleUpload(e.dataTransfer.files[0]);
}}
style={{ border: dragging ? '2px dashed blue' : '2px dashed #ccc',
padding: 40, textAlign: 'center' }}
>
拖拽文件到此处上传
<input type="file" onChange={(e) =>
handleUpload(e.target.files[0])} />
</div>
);
}
拖拽上传通过 dragover/drop 事件实现。注意 fetch API 目前不支持上传进度,如果需要进度条必须用 XMLHttpRequest 或第三方库如 axios。
存储策略全景
从本地磁盘到云存储的三种方案

图:文件上传的三种存储策略架构对比
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三种文件上传存储策略的横向对比,每种策略各占一列,从左到右复杂度递增 - 第一列(标题"本地磁盘存储",浅蓝色底色): - 浏览器图标 → 箭头标注"multipart/form-data" → Express 服务器(画一个 Node.js 图标) - Express 内部画 multer 中间件方块,标注"diskStorage" - 箭头指向服务器本地文件夹图标,标注"/uploads/" - 下方画 Nginx 方块,标注"static /uploads/ → 直接返回文件" - 优点标签(绿色):简单、快速、零成本 - 缺点标签(红色):单点故障、无弹性扩展、服务器磁盘有限 - 适用场景标签:本地开发、小型项目 - 第二列(标题"云对象存储(S3/R2)",浅绿色底色): - 浏览器图标 → 箭头 → Express 服务器 → 内部画两个路径: - 路径 A:小文件 → multer(memoryStorage)→ AWS SDK → 箭头指向 S3/R2 云存储图标,标注"直接从内存上传" - 路径 B:大文件 → 预签名 URL → 浏览器直接上传到 S3/R2(标注"客户端直传,不经服务器",用虚线箭头绕过 Express) - S3/R2 图标返回 URL → Express 保存 URL 到数据库 - 优点标签(绿色):无限存储、CDN 加速、高可用 - 缺点标签(红色):有成本、配置较复杂 - 适用场景标签:生产环境、中大型项目 - 第三列(标题"CDN + 云存储",浅紫色底色): - 基于第二列的架构,在 S3/R2 前面加一个 CDN 图标(标注"CloudFront / Cloudflare CDN") - 浏览器请求文件 → CDN(命中缓存则直接返回,标注"缓存命中 → 全球边缘节点")→ 未命中回源到 S3/R2 - 优点标签(绿色):全球加速、降低源站压力 - 缺点标签(红色):成本最高、缓存失效策略复杂 - 适用场景标签:高流量、全球用户 - 底部画一条渐变箭头,从左到右标注"复杂度和成本递增" - 顶部画一条渐变箭头,从左到右标注"可靠性和性能递增" - 配色建议:浅灰背景,三列分别用浅蓝/浅绿/浅紫底色,优点用绿色标签,缺点用红色标签,数据流箭头用深灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、文字过小 - 标注文字用中文,技术术语和路径用等宽字体
安全检查清单
文件上传是最常见的攻击入口,六项必检
| 安全措施 | 说明 |
|---|---|
| 限制文件大小 | 防止大文件耗尽磁盘/内存 |
| 限制文件类型 | 仅允许安全的 MIME 类型 |
| 重命名文件 | 用 UUID 命名,避免路径遍历攻击 |
| 独立存储目录 | 上传目录不应在代码目录内 |
| 禁止执行权限 | 上传目录不应有执行权限 |
| 验证真实类型 | 检查文件头(magic bytes),不只看扩展名 |
文件类型魔法字节检测
为什么扩展名校验不够?用 magic bytes 识别真实文件类型
$ npm install file-type
// src/utils/validate-file-type.ts
import { fileTypeFromBuffer } from 'file-type';
// 常见文件的 magic bytes(文件头)
// JPEG: FF D8 FF PNG: 89 50 4E 47 GIF: 47 49 46 38
// PDF: 25 50 44 46 ZIP: 50 4B 03 04 WebP: 52 49 46 46
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
export async function validateFileType(
buffer: Buffer, allowedTypes = ALLOWED_TYPES
): Promise<string> {
const typeInfo = await fileTypeFromBuffer(buffer);
if (!typeInfo) {
throw new Error('无法识别文件类型');
}
if (!allowedTypes.includes(typeInfo.mime)) {
throw new Error(
`不支持的文件类型: ${typeInfo.mime},` +
`检测到扩展名 .${typeInfo.ext}`
);
}
return typeInfo.mime;
}
// 在路由中使用
router.post('/upload/secure',
memoryUpload.single('file'),
async (req, res) => {
try {
const realMime = await validateFileType(req.file!.buffer);
// realMime 是通过文件内容检测出的真实 MIME
const result = await uploadToCloud(req.file!.buffer, 'file');
res.json(result);
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
}
);
Part 2: 静态文件
用 express.static 托管前端资源,配置缓存策略,支持 SPA 路由回退
Express 内置了 express.static 来托管静态文件。开发环境一行代码搞定,生产环境建议加缓存策略或用 Nginx/CDN 分担流量。SPA 应用还需要配置路由回退,让前端路由在刷新时不 404。
express.static
一行代码把目录变成静态文件服务器
import express from 'express';
import path from 'path';
const app = express();
// 基本静态文件服务
app.use('/static', express.static(path.join(__dirname, '../public')));
// 带缓存策略
app.use('/assets', express.static(path.join(__dirname, '../assets'), {
maxAge: '7d',
etag: true,
immutable: true,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
}
},
}));
SPA 回退
React/Vue 等单页应用需要把所有路由都指向 index.html
const CLIENT_BUILD_PATH = path.join(__dirname, '../../client/dist');
// 1. 提供静态资源
app.use(express.static(CLIENT_BUILD_PATH, {
maxAge: '1y',
index: false, // 不自动发送 index.html
}));
// 2. API 路由
app.use('/api', apiRouter);
// 3. SPA 回退:所有非 API 请求都返回 index.html
app.get('*', (req, res) => {
res.sendFile(path.join(CLIENT_BUILD_PATH, 'index.html'));
});
SPA 的前端路由(如 /dashboard)在浏览器刷新时,请求会到达服务器。如果不做回退,服务器会返回 404。把所有非 API 请求都指向 index.html,让前端路由来处理。
缓存策略
不同类型的文件需要不同的缓存策略
| 文件类型 | 缓存策略 | Cache-Control |
|---|---|---|
| HTML | 不缓存 | no-cache |
| JS/CSS(带 hash) | 长期缓存 | max-age=31536000, immutable |
| 图片/字体 | 中期缓存 | max-age=604800 |
| API 响应 | 不缓存 | no-store |
Part 3: 云存储
从本地磁盘迁移到 S3/R2,实现高可用、无限容量的文件存储
生产环境的文件不应该存在应用服务器上。云对象存储(AWS S3、Cloudflare R2)提供高可用、无限容量和 CDN 加速。本部分涵盖直接上传、预签名 URL 直传,以及常见问题的排错。
S3 vs R2
AWS S3 是行业标准,Cloudflare R2 是性价比之选
| 特性 | AWS S3 | Cloudflare R2 |
|---|---|---|
| API 兼容性 | 原生 S3 API | S3 兼容 API |
| 出口流量费用 | $0.09/GB | 免费 |
| 存储费用 | $0.023/GB/月 | $0.015/GB/月 |
| 免费额度 | 5GB(12个月) | 10GB/月永久 |
上传到云存储
用 AWS SDK v3 直接上传
$ npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
region: process.env.STORAGE_REGION || 'auto',
endpoint: process.env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID!,
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY!,
},
});
async function uploadToCloud(buffer: Buffer, originalName: string) {
const key = `uploads/${crypto.randomUUID()}${path.extname(originalName)}`;
await s3.send(new PutObjectCommand({
Bucket: process.env.STORAGE_BUCKET,
Key: key,
Body: buffer,
}));
return { key, url: `${process.env.STORAGE_PUBLIC_URL}/${key}` };
}
预签名 URL 实现
生成带签名的临时 URL,让客户端直接上传/下载,服务器不经手文件
// src/routes/presign.ts
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
// 生成上传用的预签名 URL
router.post('/presign/upload', async (req, res) => {
const { contentType, fileName } = req.body;
const key = `uploads/${crypto.randomUUID()}/${fileName}`;
const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({
Bucket: process.env.STORAGE_BUCKET,
Key: key,
ContentType: contentType,
}), { expiresIn: 300 }); // 5 分钟有效
const publicUrl = `${process.env.STORAGE_PUBLIC_URL}/${key}`;
res.json({ uploadUrl, publicUrl, key });
});
// 生成下载用的预签名 URL(私有文件)
router.get('/presign/download/:key', async (req, res) => {
const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({
Bucket: process.env.STORAGE_BUCKET,
Key: req.params.key,
}), { expiresIn: 60 }); // 1 分钟有效
res.json({ downloadUrl });
});
预签名 URL 把凭证嵌入 URL 参数,客户端拿到后可以直接向 S3 发请求,无需暴露 Access Key。上传 URL 有效期建议 5 分钟,下载 URL 建议 1 分钟。这是大文件上传的最佳实践。
客户端直传 S3 完整流程
前端拿到预签名 URL 后用 fetch 直接 PUT 到 S3
// 前端代码:完整的预签名上传流程
async function uploadWithPresign(file) {
// 1. 向后端请求预签名 URL
const { uploadUrl, publicUrl } = await fetch('/api/presign/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentType: file.type,
fileName: file.name,
}),
}).then(r => r.json());
// 2. 用预签名 URL 直接上传到 S3
const uploadResult = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!uploadResult.ok) throw new Error('上传到云存储失败');
// 3. 通知后端保存文件记录
await fetch('/api/files/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: publicUrl,
name: file.name,
size: file.size,
}),
});
return publicUrl;
}
前端直传
用预签名 URL 让前端直接上传到云存储,不经过 Node.js

图:前端通过预签名 URL 直传云存储的完整时序流程
【AI 生图提示词】请生成一张中文技术时序图,风格简洁、配色柔和、适合教学: - 画面主体:前端、后端、云存储三个参与者之间的文件上传时序交互 - 各元素关系(分步列出): - 前端 → 后端:POST /presign 请求获取上传链接 - 后端 → 云存储:生成预签名 URL - 云存储 → 后端:返回签名 URL - 后端 → 前端:返回 uploadUrl 和 publicUrl - 前端 → 云存储:PUT 方法将文件直接上传到签名 URL(用橙色虚线箭头突出"直传绕过后端"的关键特征) - 云存储 → 前端:返回上传成功 - 前端 → 后端:POST /confirm 保存文件记录 - 关键标注:POST /presign、预签名 URL、PUT 直传、POST /confirm、三个参与者分别标注为"前端"、"后端"、"云存储"、"前端→云存储"的箭头用橙色虚线突出"直传绕过后端" - 配色建议:浅灰背景,前端用蓝色方框,后端用绿色方框,云存储用橙色方框,请求用实线箭头,响应用虚线箭头 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
前端直传的优势:文件不经过 Node.js 服务器,减少带宽和内存压力,上传速度更快。
常见问题
multipart/form-data,不是 application/json。
问题一:上传报"Unexpected field"
原因:前端字段名和 multer 的参数不匹配。比如前端用 formData.append('file', ...),但后端用的是 upload.single('avatar')。
修复:确保前端的字段名和后端的 single('字段名') 一致。
问题二:静态文件 404
原因:路径拼错了,或文件确实不存在。
修复:用 path.join(__dirname, '../public') 确认绝对路径正确。
问题三:S3 上传报权限错误
原因:IAM 策略不允许该操作,或 Bucket 不存在。
修复:检查 IAM 用户权限,确认 Bucket 名称和 Region 正确。
Multer 错误处理
捕获文件大小超限、文件数量超限等 Multer 专属错误
// src/middleware/upload-error.ts
import { MulterError } from 'multer';
// Multer 错误类型:
// LIMIT_PART_COUNT - 表单 part 数量超限
// LIMIT_FILE_SIZE - 文件大小超限
// LIMIT_FILE_COUNT - 文件数量超限
// LIMIT_FIELD_KEY - 字段名过长
// LIMIT_FIELD_VALUE - 字段值过长
// LIMIT_FIELD_COUNT - 字段数量超限
// LIMIT_UNEXPECTED_FILE - 未预期的字段名
export function handleUploadError(err: any, req: any, res: any, next: any) {
if (err instanceof MulterError) {
const messages: Record<string, string> = {
LIMIT_FILE_SIZE: '文件大小超出限制',
LIMIT_FILE_COUNT: '文件数量超出限制',
LIMIT_UNEXPECTED_FILE: '未预期的上传字段',
};
return res.status(400).json({
error: messages[err.code] ?? `上传错误: ${err.code}`,
});
}
if (err.message?.includes('不支持的文件类型')) {
return res.status(415).json({ error: err.message });
}
next(err);
}
// 在路由中使用
router.post('/avatar',
upload.single('avatar'),
handleUploadError, // 放在 multer 中间件之后
(req, res) => {
res.json({ filename: req.file!.filename });
}
);
Multer 抛出的错误是 MulterError 类实例,和普通业务错误不同。需要单独捕获并转换为用户友好的提示信息。建议把错误处理中间件放在 multer 之后、业务 handler 之前。
试一试
练习一
任务:写一个头像上传接口,只接受 jpg/png,大小不超过 2MB。
// 提示:用 avatarUpload.single('avatar')
// 你的代码:
参考答案
const avatarUpload = multer({
storage,
fileFilter,
limits: { fileSize: 2 * 1024 * 1024 },
});
router.post('/avatar', avatarUpload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: '请选择文件' });
res.json({ filename: req.file.filename, size: req.file.size });
});
练习二
任务:配置一个静态文件服务,让 /public 路径下的文件可以被访问,HTML 不缓存,其他文件缓存 7 天。
// 提示:express.static + setHeaders
// 你的代码:
参考答案
app.use('/public', express.static('public', {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
} else {
res.setHeader('Cache-Control', 'public, max-age=604800');
}
},
}));
📝 要点总结
本章核心知识点
multer 上传
磁盘存储适合大文件,内存存储适合需要后续处理的场景
安全检查
文件类型魔法字节检测 + 大小限制 + 文件名安全处理
云存储
预签名 URL 让客户端直传云存储,减轻服务器压力
静态资源
express.static + 缓存策略 + SPA 回退 = 完整的前端资源服务
下一章
文件能存能传了。下一章学习实时通信——WebSocket 和 Socket.io,让服务器能主动"推"数据给客户端。