Skip to content

Commit 75926d2

Browse files
authored
feat: sample for big-file-upload via MultiPartReader / IPipeReader / IFormFeature (net9) (#35527)
1 parent a58512d commit 75926d2

File tree

7 files changed

+283
-0
lines changed

7 files changed

+283
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**/*.dat
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using FileManagerSample.Services;
2+
using Microsoft.AspNetCore.Http.Features;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Microsoft.Net.Http.Headers;
5+
6+
namespace FileManagerSample.Controllers
7+
{
8+
[ApiController]
9+
[Route("controller")]
10+
public class FileController : ControllerBase
11+
{
12+
public const int BufferSize = 16 * 1024 * 1024; // 16 MB buffer size
13+
public const string UploadFilePath = "file-upload.dat";
14+
15+
private readonly ILogger<FileController> _logger;
16+
private readonly FileManagerService _fileManager;
17+
18+
public FileController(
19+
ILogger<FileController> logger,
20+
FileManagerService fileManager)
21+
{
22+
_logger = logger;
23+
_fileManager = fileManager;
24+
}
25+
26+
[HttpPost]
27+
[Route("multipart")]
28+
public async Task<IActionResult> UploadMultipartReader()
29+
{
30+
if (!Request.ContentType?.StartsWith("multipart/form-data") ?? true)
31+
{
32+
return BadRequest("The request does not contain valid multipart form data.");
33+
}
34+
35+
var boundary = HeaderUtilities.RemoveQuotes(MediaTypeHeaderValue.Parse(Request.ContentType).Boundary).Value;
36+
if (string.IsNullOrWhiteSpace(boundary))
37+
{
38+
return BadRequest("Missing boundary in multipart form data.");
39+
}
40+
41+
var cancellationToken = HttpContext.RequestAborted;
42+
var filePath = await _fileManager.SaveViaMultipartReaderAsync(boundary, Request.Body, cancellationToken);
43+
return Ok("Saved file at " + filePath);
44+
}
45+
46+
[HttpPost]
47+
[Route("pipe")]
48+
public async Task<IActionResult> UploadPipeReader()
49+
{
50+
if (!Request.HasFormContentType)
51+
{
52+
return BadRequest("The request does not contain a valid form.");
53+
}
54+
55+
var cancellationToken = HttpContext.RequestAborted;
56+
var filePath = await _fileManager.SaveViaPipeReaderAsync(Request.BodyReader, cancellationToken);
57+
return Ok("Saved file at " + filePath);
58+
}
59+
60+
[HttpPost]
61+
[Route("form")]
62+
public async Task<IActionResult> ReadForms()
63+
{
64+
if (!Request.HasFormContentType)
65+
{
66+
return BadRequest("The request does not contain a valid form.");
67+
}
68+
69+
var cancellationToken = HttpContext.RequestAborted;
70+
var formFeature = Request.HttpContext.Features.GetRequiredFeature<IFormFeature>();
71+
await formFeature.ReadFormAsync(cancellationToken);
72+
73+
var filePath = Request.Form.Files.First().FileName;
74+
return Ok("Saved file at " + filePath);
75+
}
76+
}
77+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
</PropertyGroup>
8+
9+
</Project>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using FileManagerSample.Services;
2+
using Microsoft.AspNetCore.Http.Features;
3+
using Microsoft.Net.Http.Headers;
4+
5+
var builder = WebApplication.CreateBuilder(args);
6+
builder.Services.AddSingleton<FileManagerService>();
7+
8+
builder.WebHost.ConfigureKestrel(options =>
9+
{
10+
// if not present, will throw similar exception:
11+
// Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Request body too large. The max request body size is 30000000 bytes.
12+
options.Limits.MaxRequestBodySize = 6L * 1024 * 1024 * 1024; // 6 GB
13+
14+
// optional: timeout settings
15+
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
16+
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(10);
17+
});
18+
19+
builder.Services.Configure<FormOptions>(options =>
20+
{
21+
options.MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024; // 10 GB
22+
});
23+
24+
builder.Services.AddControllers();
25+
26+
var app = builder.Build();
27+
app.MapControllers();
28+
29+
app.MapPost("minimal/multipart", async (FileManagerService fileManager, HttpRequest request, CancellationToken cancellationToken) =>
30+
{
31+
if (!request.ContentType?.StartsWith("multipart/form-data") ?? true)
32+
{
33+
return Results.BadRequest("The request does not contain valid multipart form data.");
34+
}
35+
36+
var boundary = HeaderUtilities.RemoveQuotes(MediaTypeHeaderValue.Parse(request.ContentType).Boundary).Value;
37+
if (string.IsNullOrWhiteSpace(boundary))
38+
{
39+
return Results.BadRequest("Missing boundary in multipart form data.");
40+
}
41+
42+
var filePath = await fileManager.SaveViaMultipartReaderAsync(boundary, request.Body, cancellationToken);
43+
return Results.Ok("Saved file at " + filePath);
44+
});
45+
46+
app.MapPost("minimal/pipe", async (FileManagerService fileManager, HttpRequest request, CancellationToken cancellationToken) =>
47+
{
48+
if (!request.HasFormContentType)
49+
{
50+
return Results.BadRequest("The request does not contain a valid form.");
51+
}
52+
53+
var filePath = await fileManager.SaveViaPipeReaderAsync(request.BodyReader, cancellationToken);
54+
return Results.Ok("Saved file at " + filePath);
55+
});
56+
57+
app.MapPost("minimal/form", async (HttpRequest request, CancellationToken cancellationToken) =>
58+
{
59+
if (!request.HasFormContentType)
60+
{
61+
return Results.BadRequest("The request does not contain a valid form.");
62+
}
63+
64+
var formFeature = request.HttpContext.Features.GetRequiredFeature<IFormFeature>();
65+
await formFeature.ReadFormAsync(cancellationToken);
66+
67+
var filePath = request.Form.Files.First().FileName;
68+
return Results.Ok("Saved file at " + filePath);
69+
});
70+
71+
app.Run();
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using System.IO.Pipelines;
2+
using Microsoft.AspNetCore.WebUtilities;
3+
using Microsoft.Net.Http.Headers;
4+
5+
namespace FileManagerSample.Services
6+
{
7+
public class FileManagerService
8+
{
9+
private const int BufferSize = 16 * 1024 * 1024; // 16 MB buffer size
10+
private const string UploadFilePath = "file-upload.dat";
11+
12+
private readonly ILogger<FileManagerService> _logger;
13+
14+
public FileManagerService(ILogger<FileManagerService> logger)
15+
{
16+
_logger = logger;
17+
}
18+
19+
public async Task<string> SaveViaMultipartReaderAsync(string boundary, Stream contentStream, CancellationToken cancellationToken)
20+
{
21+
string targetFilePath = Path.Combine(Directory.GetCurrentDirectory(), UploadFilePath);
22+
CheckAndRemoveLocalFile(targetFilePath);
23+
24+
using FileStream outputFileStream = new FileStream(
25+
path: targetFilePath,
26+
mode: FileMode.Create,
27+
access: FileAccess.Write,
28+
share: FileShare.None,
29+
bufferSize: BufferSize,
30+
useAsync: true);
31+
32+
var reader = new MultipartReader(boundary, contentStream);
33+
MultipartSection? section;
34+
long totalBytesRead = 0;
35+
36+
// Process each section in the multipart body
37+
while ((section = await reader.ReadNextSectionAsync(cancellationToken)) != null)
38+
{
39+
// Check if the section is a file
40+
var contentDisposition = section.GetContentDispositionHeader();
41+
if (contentDisposition != null && contentDisposition.IsFileDisposition())
42+
{
43+
_logger.LogInformation($"Processing file: {contentDisposition.FileName.Value}");
44+
45+
// Write the file content to the target file
46+
await section.Body.CopyToAsync(outputFileStream, cancellationToken);
47+
totalBytesRead += section.Body.Length;
48+
}
49+
else if (contentDisposition != null && contentDisposition.IsFormDisposition())
50+
{
51+
// Handle metadata (form fields)
52+
string key = contentDisposition.Name.Value!;
53+
using var streamReader = new StreamReader(section.Body);
54+
string value = await streamReader.ReadToEndAsync(cancellationToken);
55+
_logger.LogInformation($"Received metadata: {key} = {value}");
56+
}
57+
}
58+
59+
_logger.LogInformation($"File upload completed (via multipart). Total bytes read: {totalBytesRead} bytes.");
60+
return targetFilePath;
61+
}
62+
63+
private void CheckAndRemoveLocalFile(string filePath)
64+
{
65+
if (File.Exists(filePath))
66+
{
67+
File.Delete(filePath);
68+
_logger.LogDebug($"Removed existing output file: {filePath}");
69+
}
70+
}
71+
72+
public async Task<string?> SaveViaPipeReaderAsync(PipeReader contentReader, CancellationToken cancellationToken)
73+
{
74+
string targetFilePath = Path.Combine(Directory.GetCurrentDirectory(), UploadFilePath);
75+
CheckAndRemoveLocalFile(targetFilePath);
76+
long totalBytesRead = 0;
77+
78+
using FileStream outputFileStream = new FileStream(
79+
path: targetFilePath,
80+
mode: FileMode.OpenOrCreate,
81+
access: FileAccess.Write,
82+
share: FileShare.None,
83+
bufferSize: BufferSize,
84+
useAsync: true);
85+
86+
while (true)
87+
{
88+
var readResult = await contentReader.ReadAsync();
89+
var buffer = readResult.Buffer;
90+
91+
foreach (var memory in buffer)
92+
{
93+
await outputFileStream.WriteAsync(memory);
94+
totalBytesRead += memory.Length;
95+
}
96+
contentReader.AdvanceTo(buffer.End);
97+
98+
if (readResult.IsCompleted)
99+
{
100+
break;
101+
}
102+
}
103+
104+
_logger.LogInformation($"File upload completed (via pipeReader). Total bytes read: {totalBytesRead} bytes.");
105+
return targetFilePath;
106+
}
107+
}
108+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
}
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
},
8+
"AllowedHosts": "*"
9+
}

0 commit comments

Comments
 (0)