基于 ASP.NET WebAPI 构建轻量级文件服务
很多系统在设计初期,往往纠结于是否要引入 MinIO 或 OSS 等对象存储方案。对于大部分内部管理系统而言,复杂的分布式架构可能显得过于沉重。本文将分享如何基于 ASP.NET WebAPI(.NET Framework 4.5)实现一个简单、可控的文件服务。
设计思路
在动手写代码前,我们先明确几个核心原则:
- 存储位置:所有文件统一放在网站根目录下的
Uploads文件夹中。虽然考虑过挂载其他磁盘,但为了通过 URL 直接预览文件,放在网站目录下最为方便。 - 命名规范:采用'文件 ID.扩展名'的格式。访问时通过文件 ID 查找,避免文件名冲突和中文乱码问题。
- 接口设计:主要包含三个核心接口——上传、获取路径、删除。
- 封装调用:将文件服务的 HTTP 请求封装为工具类,降低业务层耦合度。
注意:单个文件限制最大 100MB,具体阈值可根据项目需求调整。同时需关注 WebApi 项目本身的报文大小限制配置。
核心代码实现
1. 基础控制器与配置
为了方便统一返回格式,我们定义一个 BaseController,并允许在配置文件中指定支持的扩展名。
Web.config 配置
<appSettings>
<add key="AllowedExtensions" value="jpg,jpeg,png,gif,pdf,txt,doc,docx,xls,xlsx,zip,rar"/>
</appSettings>
BaseController 基类
public abstract class BaseController : ApiController
{
protected IHttpActionResult Success(string message = "", object data = null)
{
return Json(new { Code = 0, Message = message, Data = data });
}
protected IHttpActionResult Fail(string message = "", object data = null)
{
return Json(new { Code = 1, Message = message, Data = data });
}
}
2. 文件服务控制器
FileController 负责处理具体的文件逻辑。这里的关键点在于使用 Guid 生成唯一 ID,并在上传失败时清理临时文件,防止磁盘占用。
public class FileController : BaseController
{
private const string folder = "~/Uploads";
private readonly string uploadFolder;
private readonly long maxFileSize = 100 * 1024 * 1024; // 100MB
private readonly string[] allowedExtensions;
public FileController()
{
allowedExtensions = ConfigurationManager.AppSettings["AllowedExtensions"]
.Split(',')
.Select(s => "." + s)
.ToArray();
uploadFolder = HttpContext.Current.Server.MapPath(folder);
if (!Directory.Exists(uploadFolder))
Directory.CreateDirectory(uploadFolder);
}
[HttpPost]
public async Task<IHttpActionResult> UploadFile()
{
if (!Request.Content.IsMimeMultipartContent())
return Fail("请选择文件上传");
var provider = new MultipartFormDataStreamProvider(Path.GetTempPath());
await Request.Content.ReadAsMultipartAsync(provider);
if (!provider.FileData.Any())
return Fail("没有上传文件");
var resultFiles = new List<object>();
var savedFiles = new List<string>();
var baseUrl = $"{Request.RequestUri.Scheme}://{Request.RequestUri.Authority}";
var relativePath = folder.TrimStart('~');
try
{
foreach (var fileData in provider.FileData)
{
var tempPath = fileData.LocalFileName;
var originalName = fileData.Headers.ContentDisposition.FileName.Trim('"');
var extension = Path.GetExtension(originalName).ToLower();
if (!allowedExtensions.Contains(extension))
throw new Exception($"不支持的文件类型:{extension}");
var fileInfo = new FileInfo(tempPath);
if (fileInfo.Length > maxFileSize)
throw new Exception($"文件大小超过 100MB: {originalName}");
var fileId = Guid.NewGuid().ToString();
var newFileName = fileId + extension;
var savePath = Path.Combine(uploadFolder, newFileName);
File.Move(tempPath, savePath);
savedFiles.Add(savePath);
var fileUrl = $"{baseUrl}{relativePath}/{newFileName}";
resultFiles.Add(new { FileId = fileId, FilePath = fileUrl });
}
return Success(data: resultFiles);
}
catch (Exception ex)
{
// 异常回滚:删除已保存的文件
foreach (var savedFile in savedFiles)
{
try { File.Delete(savedFile); }
catch { }
}
// 清理临时文件
foreach (var fileData in provider.FileData)
{
try { File.Delete(fileData.LocalFileName); }
catch { }
}
return Fail($"文件上传失败:{ex.Message}");
}
}
[HttpPost]
public IHttpActionResult GetFilePaths(string[] fileIds)
{
var baseUrl = $"{Request.RequestUri.Scheme}://{Request.RequestUri.Authority}";
var relativePath = folder.TrimStart('~');
var resultFiles = new List<object>();
foreach (string fileId in fileIds)
{
var filePath = Directory.GetFiles(uploadFolder, fileId + ".*").FirstOrDefault();
if (filePath == null)
return Fail($"文件不存在{fileId}");
var fileName = Path.GetFileName(filePath);
resultFiles.Add(new { FileId = fileId, FilePath = $"{baseUrl}{relativePath}/{fileName}" });
}
return Success(data: resultFiles);
}
[HttpPost]
public IHttpActionResult DeleteFiles(string[] fileIds)
{
if (fileIds == null || fileIds.Length == 0)
return Fail("请提供要删除的文件 ID");
using (var scope = new TransactionScope())
{
foreach (var fileId in fileIds)
{
var filePath = Directory.GetFiles(uploadFolder, fileId + ".*").SingleOrDefault();
if (filePath == null)
return Fail($"文件不存在:{fileId}");
File.Delete(filePath);
}
scope.Complete();
}
return Success();
}
}
3. 客户端调用工具类
为了避免业务代码中重复编写 HTTP 请求逻辑,我们封装一个 FileUploader 类。这样前端只需调用业务接口,无需直接感知文件服务的存在。
public class FileUploader
{
private string fileServiceUrl;
private HttpClient http;
public FileUploader(string fileServiceUrl)
{
this.fileServiceUrl = fileServiceUrl;
http = new HttpClient();
}
public async Task<dynamic> Upload(MultipartFileData[] files)
{
using (var form = new MultipartFormDataContent())
{
foreach (var file in files)
{
var fileBytes = File.ReadAllBytes(file.LocalFileName);
var content = new ByteArrayContent(fileBytes);
form.Add(content, "file", file.Headers.ContentDisposition.FileName.Trim('"'));
}
var url = fileServiceUrl + "/api/File/UploadFile";
var response = await http.PostAsync(url, form);
var json = await response.Content.ReadAsStringAsync();
return Newtonsoft.Json.JsonConvert.DeserializeObject<dynamic>(json);
}
}
public async Task<dynamic> GetFilePaths(string[] fileIds)
{
var url = fileServiceUrl + "/api/File/GetFilePaths";
var content = new StringContent(Newtonsoft.Json.JsonConvert.SerializeObject(fileIds), Encoding.UTF8, "application/json");
var response = await http.PostAsync(url, content);
var json = await response.Content.ReadAsStringAsync();
return Newtonsoft.Json.JsonConvert.DeserializeObject<dynamic>(json);
}
public async Task<dynamic> DeleteFiles(string[] fileIds)
{
var url = fileServiceUrl + "/api/File/DeleteFiles";
var content = new StringContent(Newtonsoft.Json.JsonConvert.SerializeObject(fileIds), Encoding.UTF8, "application/json");
var response = await http.PostAsync(url, content);
var json = await response.Content.ReadAsStringAsync();
return Newtonsoft.Json.JsonConvert.DeserializeObject<dynamic>(json);
}
}
服务调用示例
在实际项目中,建议采用后端代理模式:前端不直接调用文件服务,而是通过业务 API 中转。这样能更好地控制权限和日志。
1. 后端业务接口集成
以下是一个用户管理控制器的示例,展示了如何在创建用户时上传图片,并在查询时解析头像路径。
public class MyTestController : ApiController
{
private FileUploader fileUploader;
public MyTestController()
{
fileUploader = new FileUploader(ConfigurationManager.AppSettings["FileServiceUrl"]);
}
[HttpPost]
public async Task<IHttpActionResult> UploadTest()
{
var provider = new MultipartFormDataStreamProvider(Path.GetTempPath());
await Request.Content.ReadAsMultipartAsync(provider);
if (!provider.FileData.Any())
return BadRequest("没有上传文件");
try
{
var result = await fileUploader.Upload(provider.FileData.ToArray());
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
finally
{
// 务必清理临时文件,防止磁盘爆满
foreach (var file in provider.FileData)
{
if (!File.Exists(file.LocalFileName)) continue;
File.Delete(file.LocalFileName);
}
}
}
[HttpGet]
public async Task<IHttpActionResult> QueryUserDatas()
{
user[] users;
using (var dbContext = new jonas_testEntities())
users = dbContext.user.ToArray();
var result = await fileUploader.GetFilePaths(users.Select(u => u.IconImageId).ToArray());
if (result.Code != 0)
return Json(result);
var fileInfos = ((IEnumerable<dynamic>)result.Data).Select(d => new { d.FileId, d.FilePath }).ToArray();
List<QueryUserDatasResultItem> list = new List<QueryUserDatasResultItem>();
foreach (var user in users)
{
QueryUserDatasResultItem model = new QueryUserDatasResultItem
{
Account = user.Account,
RealName = user.RealName
};
model.IconImageUrl = fileInfos.Single(s => s.FileId == user.IconImageId).FilePath;
list.Add(model);
}
return Json(list);
}
[HttpPost]
public async Task<IHttpActionResult> AddUser()
{
if (!Request.Content.IsMimeMultipartContent())
return Json(new { Code = 1, Message = "请求格式错误" });
var provider = new MultipartFormDataStreamProvider(Path.GetTempPath());
await Request.Content.ReadAsMultipartAsync(provider);
try
{
var formData = provider.FormData;
var user = new user
{
Account = formData["Account"],
RealName = formData["RealName"]
};
if (provider.FileData.Count > 0)
{
var fileData = provider.FileData[0];
var result = await fileUploader.Upload(new[] { fileData });
if (result.Code != 0)
return Json(result);
user.IconImageId = result.Data[0].FileId;
}
using (var dbContext = new jonas_testEntities())
{
if (dbContext.user.Any(u => u.Account == user.Account))
return Json(new { Code = 1, Message = "账号已经存在" });
dbContext.user.Add(user);
await dbContext.SaveChangesAsync();
}
return Json(new { Code = 0 });
}
catch (Exception ex)
{
return Json(new { Code = 1, Message = ex.Message });
}
finally
{
foreach (var fileData in provider.FileData)
{
try
{
if (File.Exists(fileData.LocalFileName))
File.Delete(fileData.LocalFileName);
}
catch { }
}
}
}
[HttpPost]
public async Task<IHttpActionResult> DeleteUser(string account)
{
user user = null;
using (var dbContext = new jonas_testEntities())
{
user = dbContext.user.SingleOrDefault(u => u.Account == account);
if (user == null)
return Json(new { Code = 1, Message = "用户不存在" });
dbContext.user.Remove(user);
await dbContext.SaveChangesAsync();
}
await fileUploader.DeleteFiles(new string[] { user.IconImageId });
return Json(new { Code = 0 });
}
}
2. 前端交互示例(Vue2 + ElementUI)
前端部分主要涉及表单提交和图片预览。这里使用了 Axios 发送 multipart/form-data 请求。
<template>
<div id="app">
<el-table :data="tableDatas" style="width: 800px;" stripe border>
<el-table-column label="账号" prop="Account" width="150"></el-table-column>
<el-table-column label="真实姓名" prop="RealName" width="150"></el-table-column>
<el-table-column label="头像" width="100">
<template slot-scope="scope">
<span style="color: #409EFF; cursor: pointer;" @click="previewIconHandler(scope.row.IconImageUrl)">预览</span>
</template>
</el-table-column>
<el-table-column width="150">
<template #header>
<el-button type="primary" size="medium" @click="dialogAdd.show = true">新增</el-button>
</template>
<template slot-scope="scope">
<el-button type="danger" size="small" @click="removeUserHandler(scope.row.Account)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 预览弹窗 -->
<el-dialog title="预览头像" width="500px" :visible.sync="dialogPreviewIcon.show" append-to-body>
<div style="width: 100%; height: 500px;">
<img :src="dialogPreviewIcon.src" style="width: 100%; height: 100%;">
</div>
</el-dialog>
<!-- 新增用户弹窗 -->
<el-dialog title="新增用户" width="800px" :visible.sync="dialogAdd.show" append-to-body>
<el-form :model="dialogAdd.userInfo" label-width="150px" class="demo-ruleForm">
<el-form-item label="账号">
<el-input v-model="dialogAdd.userInfo.Account"></el-input>
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="dialogAdd.userInfo.RealName"></el-input>
</el-form-item>
<el-form-item label="头像">
<el-upload class="upload-demo" ref="upload" action=""
:on-remove="removeOrChangeHandler" :file-list="dialogAdd.files"
auto-upload="false" :on-change="removeOrChangeHandler">
<el-button slot="trigger" size="small" type="primary">选取文件</el-button>
</el-upload>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitFormHandler">提交</el-button>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>
<script>
import axios from 'axios';
const baseUrl = 'http://localhost:4623'
export default {
data() {
return {
tableDatas: [],
dialogPreviewIcon: { show: false, src: '' },
dialogAdd: {
show: false,
userInfo: { Account: '', RealName: '' },
files: []
}
};
},
methods: {
async queryDatas() {
const { data: res } = await axios.get(baseUrl + '/api/MyTest/QueryUserDatas');
this.tableDatas = res;
},
previewIconHandler(IconImageUrl) {
this.dialogPreviewIcon.src = IconImageUrl;
this.dialogPreviewIcon.show = true;
},
async submitFormHandler() {
console.log(this.dialogAdd);
if (this.dialogAdd.userInfo.Account === '' || this.dialogAdd.userInfo.RealName === '' || this.dialogAdd.files.length === 0)
return alert('请填写完整信息');
let formdata = new FormData();
formdata.append('file', this.dialogAdd.files[0].raw);
formdata.append('Account', this.dialogAdd.userInfo.Account);
formdata.append('RealName', this.dialogAdd.userInfo.RealName);
const { data: res } = await axios.post(baseUrl + '/api/MyTest/AddUser', formdata, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (res.Code != 0) return alert(res.Message);
this.dialogAdd.show = false;
this.queryDatas();
},
async removeUserHandler(account) {
const { data: res } = await axios.post(baseUrl + '/api/MyTest/DeleteUser?account=' + account);
if (res.Code != 0) return alert(res.Message);
this.queryDatas();
},
removeOrChangeHandler(file, files) {
this.dialogAdd.files = files;
},
},
created() {
this.queryDatas();
}
}
</script>
3. 效果说明
完成上述配置后,系统即可支持用户头像的上传、展示及删除。实际运行中,请注意以下几点:
- 临时文件清理:代码中多次使用了
finally块来删除Path.GetTempPath()中的临时文件,这是为了防止服务器磁盘被缓存文件占满。 - 事务一致性:删除用户时,先删数据库记录,再删物理文件,确保数据与文件状态一致。
- 安全性:生产环境建议增加身份验证(如 JWT),并校验文件内容而非仅依赖扩展名。
整体流程跑通后,前端界面会显示用户列表,点击新增可上传头像,点击预览可查看大图,删除操作则同步移除数据库记录和本地文件。


