一个理论上高性能高并发的简单图床设计

2025 年 3 月 29 日 星期六(已编辑)
40
3

一个理论上高性能高并发的简单图床设计

Tips: 本文的工程开源在 ImageServer

概述

一直想要一个自己的个人站,平时也没有注意整理归纳文章,正好遇到了心仪的开源博客站(感谢 innei 大大的开源喵),就想着开始做一下个人站吧!完成部署以后就遇到了第一个难题,找不到一个合适的图床。

选择互联网上的不知名免费小图床如果挂了,又会为我付出很多很极端的维护成本,但是大运营商的对象存储的售价又是我无法承担的,秉承着买不如造的想法,我决定自己开发一个图床服务器。

原理介绍

背景

这个项目一开始想的比较简单,向着一个小工具的方向去开发的。取 HTTP GET 请求的 URL 的路径部分,然后直接把这个图床程序所在目录相对路径下存放的图片返回回去,如果不存在这个文件或者路径就返回 404。

掏出我最擅长的 C#,直接使用最简单的 HTTPListener,写了几十行就搞定了,测试也顺利成功。

using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;

namespace SimpleImageServer
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var baseDir = Directory.GetCurrentDirectory();
            string prefix = "http://*:23564/";
            
            using var listener = new HttpListener();
            listener.Prefixes.Add(prefix);
            listener.Start();
            
            Console.WriteLine($"Server started on {prefix}");
            Console.WriteLine($"Serving images from: {baseDir}");
            Console.WriteLine("Press Ctrl+C to stop...");
            
            while (true)
            {
                var context = await listener.GetContextAsync();
                _ = Task.Run(() => HandleRequest(context, baseDir));
            }
        }
        
        static async Task HandleRequest(HttpListenerContext context, string baseDir)
        {
            var requestPath = context.Request.Url.LocalPath.TrimStart('/');
            var response = context.Response;
            
            try
            {
                // 构建并验证文件路径(防止目录遍历攻击)
                var fullPath = Path.GetFullPath(Path.Combine(baseDir, requestPath));
                if (!fullPath.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase))
                {
                    response.StatusCode = 403;
                    response.Close();
                    return;
                }
                
                // 检查文件是否存在
                if (!File.Exists(fullPath))
                {
                    response.StatusCode = 404;
                    response.Close();
                    return;
                }
                
                // 设置内容类型
                var extension = Path.GetExtension(fullPath).ToLower();
                response.ContentType = extension switch
                {
                    ".jpg" or ".jpeg" => "image/jpeg",
                    ".png" => "image/png",
                    ".gif" => "image/gif",
                    ".bmp" => "image/bmp",
                    ".webp" => "image/webp",
                    _ => "application/octet-stream"
                };
                
                // 返回文件内容
                using var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read);
                response.ContentLength64 = fileStream.Length;
                await fileStream.CopyToAsync(response.OutputStream);
                response.Close();
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error serving image: {ex.Message}");
                if (response.StatusCode == 200) response.StatusCode = 500;
                response.Close();
            }
        }
    }
}

工作流程

  1. 服务器初始化

    • 确定基础目录(程序运行目录)
    • 配置 HTTP 监听地址和端口(*:23564
    • 创建并启动 HttpListener 实例
  2. 请求监听循环

    • 服务器进入无限循环,等待传入请求
    • 使用 await listener.GetContextAsync() 异步等待客户端连接
    • 当接收到请求时,为每个请求创建一个新任务
  3. 请求处理

    • 从 URL 提取请求路径
    • 构建完整文件路径
    • 验证路径安全性(防止目录遍历攻击)
    • 检查请求的文件是否存在
    • 根据文件扩展名设置适当的 MIME 类型
    • 将文件内容流式传输到响应流
    • 关闭响应连接
  4. 错误处理

    • 使用 try-catch 块捕获处理过程中可能发生的异常
    • 对应不同情况返回适当的 HTTP 状态码
    • 记录错误信息到控制台

特别注意的是,服务器上需要考虑别人使用../之类的方式对你进行遍历攻击,所以需要有所防范

var fullPath = Path.GetFullPath(Path.Combine(baseDir, requestPath));
if (!fullPath.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase))
{
    response.StatusCode = 403;
    response.Close();
    return;
}

这段代码是防止目录遍历攻击的关键安全措施:

  • Path.GetFullPath() 解析所有相对路径符号(如 ../
  • 验证最终路径必须位于服务根目录之内
  • 使用不区分大小写的比较(Windows 文件系统不区分大小写)

同时,需要使用 C# 模式匹配(switch 表达式)根据文件扩展名设置适当的 MIME 类型,确保浏览器能正确解释和渲染图像文件。

response.ContentType = extension switch
{
    ".jpg" or ".jpeg" => "image/jpeg",
    ".png" => "image/png",
    ".gif" => "image/gif",
    ".bmp" => "image/bmp",
    ".webp" => "image/webp",
    _ => "application/octet-stream"
};

以上代码看上去是不是简洁明了好用了?但是我深入思考了一下,事情似乎没那么简单:

  • 性能和吞吐量:HTTPListener 似乎并不是一个吞吐很好的库,经过资料查找,发现 HTTPListener 在 Windows 的实现和 Linux 上的实现完全不同。Windows 实现基于 HTTP.sys 内核组件,内核处理 TCP 和 HTTP 协议,而 Linux 实现是纯托管代码实现,用户空间处理全部协议栈。这个类的设计理念就是为简单 HTTP 服务需求提供直接 API,较少抽象层,但其实并不适用于服务器级别的高并发应用。

改进目标

秉承着写都写了,不如写好一些的思想,我希望实现一个相对专业的,理论上可以高并发,处理大量数据,减轻 IO 瓶颈的代码。 由此我需要实现:

  • 基于 ASP.NET Core 处理 HTTP 请求
  • 使用 Microsoft.Extensions.Caching.Memory 实现缓存
  • 内存缓存小文件以提高性能
  • 大文件流式处理以节省内存
  • HTTP 条件请求支持(ETag/304 响应)
  • 线程池优化
  • CORS 支持
  • 客户端缓存控制

新版本完整实现代码

using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ImageServer;

/// <summary>
/// Entry point for the Image Server application.
/// </summary>
public static class Program
{
    /// <summary>
    /// Application entry point.
    /// </summary>
    /// <param name="args">Command line arguments.</param>
    public static void Main(string[] args)
    {
        // Optimize thread pool settings
        ThreadPool.SetMinThreads(100, 100);

        var baseDir = Directory.GetCurrentDirectory();
        var port = 23564;

        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseKestrel(options =>
                    {
                        options.Limits.MaxConcurrentConnections = 1000;
                        options.Limits.MaxRequestBodySize = null; // No request size limit
                        options.Listen(IPAddress.Any, port);
                    })
                    .UseStartup<Startup>();
            })
            .ConfigureServices(services =>
            {
                services.AddSingleton(baseDir);
                services.AddMemoryCache(options =>
                {
                    options.SizeLimit = 200 * 1024 * 1024; // 200MB cache limit
                });
                services.AddSingleton<ImageService>();
                services.AddCors();
            })
            .Build()
            .Run();
    }
}

/// <summary>
/// Configures the application's HTTP pipeline.
/// </summary>
public class Startup
{
    /// <summary>
    /// Configures the application's request pipeline.
    /// </summary>
    /// <param name="app">The application builder.</param>
    /// <param name="env">The hosting environment.</param>
    /// <param name="imageService">The image service.</param>
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ImageService imageService)
    {
        if (env.IsDevelopment()) app.UseDeveloperExceptionPage();

        app.UseRouting();

        // Configure CORS
        app.UseCors(builder => builder
            .AllowAnyOrigin()
            .AllowAnyMethod()
            .AllowAnyHeader());

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/{**path}", async context => { await imageService.ServeImageAsync(context); });
        });

        Console.WriteLine($"Server started on http://*:{23564}/");
        Console.WriteLine($"Serving files from: {imageService.BaseDirectory}");
        Console.WriteLine("Press Ctrl+C to stop the server...");
    }
}

/// <summary>
/// Service responsible for serving image files with caching capabilities.
/// </summary>
public class ImageService
{
    // Only cache files smaller than this size to avoid memory pressure
    private const int MaxCacheFileSize = 5 * 1024 * 1024; // 5MB

    // Cache duration
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
    private readonly IMemoryCache _cache;
    
    /// <summary>
    /// Gets the base directory from which files are served.
    /// </summary>
    public readonly string BaseDirectory;

    /// <summary>
    /// Initializes a new instance of the <see cref="ImageService"/> class.
    /// </summary>
    /// <param name="cache">The memory cache to use.</param>
    /// <param name="baseDirectory">The base directory to serve files from.</param>
    public ImageService(IMemoryCache cache, string baseDirectory)
    {
        _cache = cache;
        BaseDirectory = Path.GetFullPath(baseDirectory);
        Directory.CreateDirectory(BaseDirectory); // Ensure directory exists
    }

    /// <summary>
    /// Serves an image file in response to an HTTP request.
    /// </summary>
    /// <param name="context">The HTTP context for the request.</param>
    /// <returns>A task representing the asynchronous operation.</returns>
    public async Task ServeImageAsync(HttpContext context)
    {
        var request = context.Request;
        var response = context.Response;

        try
        {
            // Get requested path
            var requestedPath = request.Path.Value?.TrimStart('/') ?? string.Empty;

            if (string.IsNullOrEmpty(requestedPath))
            {
                response.StatusCode = StatusCodes.Status400BadRequest;
                return;
            }

            // Construct and validate file path (prevent directory traversal attacks)
            var fullPath = Path.GetFullPath(Path.Combine(BaseDirectory, requestedPath));
            if (!fullPath.StartsWith(BaseDirectory, StringComparison.OrdinalIgnoreCase))
            {
                response.StatusCode = StatusCodes.Status403Forbidden;
                return;
            }

            // Check if file exists
            if (!File.Exists(fullPath))
            {
                response.StatusCode = StatusCodes.Status404NotFound;
                return;
            }

            // Get file information
            var fileInfo = new FileInfo(fullPath);
            var extension = Path.GetExtension(fullPath).ToLower();
            var mimeType = GetMimeType(extension);

            // Set Content-Type
            response.ContentType = mimeType;

            // Set cache control headers
            var lastModified = fileInfo.LastWriteTimeUtc;
            var etag = $"\"{lastModified.Ticks:X}-{fileInfo.Length:X}\"";

            response.Headers["ETag"] = etag;
            response.Headers["Last-Modified"] = lastModified.ToString("R");
            response.Headers["Cache-Control"] = "public, max-age=86400"; // Client cache for 1 day

            // Check conditional request (304 Not Modified handling)
            var ifNoneMatch = request.Headers["If-None-Match"].ToString();
            if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch == etag)
            {
                response.StatusCode = StatusCodes.Status304NotModified;
                return;
            }

            // Cache key
            var cacheKey = $"file:{fullPath}:{lastModified.Ticks}";

            // Determine if file size is suitable for caching
            if (fileInfo.Length <= MaxCacheFileSize)
            {
                // Try to get from cache
                if (!_cache.TryGetValue(cacheKey, out byte[]? cachedContent))
                {
                    // Cache miss, read file content
                    cachedContent = await File.ReadAllBytesAsync(fullPath);

                    // Set cache options
                    var cacheOptions = new MemoryCacheEntryOptions()
                        .SetSize(cachedContent.Length) // Set cache item size
                        .SetAbsoluteExpiration(CacheDuration);

                    // Store in cache
                    _cache.Set(cacheKey, cachedContent, cacheOptions);
                }

                // Send file from cache
                if (cachedContent != null)
                {
                    response.ContentLength = cachedContent.Length;
                    await response.Body.WriteAsync(cachedContent);
                }
            }
            else
            {
                // Stream large files directly, without caching
                response.ContentLength = fileInfo.Length;
                await using var fileStream = new FileStream(
                    fullPath,
                    FileMode.Open,
                    FileAccess.Read,
                    FileShare.Read,
                    64 * 1024, // 64KB buffer
                    true);

                await fileStream.CopyToAsync(response.Body);
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error serving image: {ex.Message}");

            if (!response.HasStarted) response.StatusCode = StatusCodes.Status500InternalServerError;
        }
    }

    /// <summary>
    /// Gets the MIME type for a given file extension.
    /// </summary>
    /// <param name="extension">The file extension.</param>
    /// <returns>The corresponding MIME type.</returns>
    private string GetMimeType(string extension)
    {
        return extension switch
        {
            ".jpg" => "image/jpeg",
            ".jpeg" => "image/jpeg",
            ".png" => "image/png",
            ".gif" => "image/gif",
            ".bmp" => "image/bmp",
            ".webp" => "image/webp",
            _ => "application/octet-stream"
        };
    }
}

让我们深入了解这个实现的每个部分。

程序入口与配置

程序入口点在 Program 类中,它设置和启动 Web 主机:

public static void Main(string[] args)
{
    // 优化线程池设置
    ThreadPool.SetMinThreads(100, 100);
    
    // 创建并运行Web主机
    // ...
}

线程池优化是高并发应用的关键。通过 SetMinThreads(100, 100),我们确保至少有 100 个工作线程和 100 个 I/O 完成端口线程立即可用,而不是等待 .NET 运行时逐渐增加线程数量。这减少了高负载时的线程不足问题。

Kestrel 配置专注于高性能: c options.Limits.MaxConcurrentConnections = 1000; options.Limits.MaxRequestBodySize = null; // 无请求大小限制 options.Listen(IPAddress.Any, port);

这限制了最大并发连接数(防止资源耗尽),移除了请求大小限制,并配置服务器监听所有网络接口的指定端口。

依赖注入配置添加了三个关键服务:

  • 基础目录路径(作为单例)
  • 内存缓存(限制为 200MB)
  • 图像服务(包含主要逻辑)

HTTP 管道配置

Startup 类配置了 HTTP 请求处理管道:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ImageService imageService)
{
    // 配置中间件
    // ...
}

管道包含以下中间件:

  • 开发环境下的异常页面
  • 路由中间件
  • CORS 中间件(允许来自任何源的请求)
  • 端点映射(所有 GET 请求路由到图像服务)

这里的路由配置很简单但很强大 - "/{**path}" 捕获所有路径,使我们能够处理任意深度的文件路径。

图像服务实现

ImageService 类是核心,处理图像请求并实现缓存策略:

public class ImageService
{
    private const int MaxCacheFileSize = 5 * 1024 * 1024; // 5MB
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
    private readonly IMemoryCache _cache;
    public readonly string BaseDirectory;
    
    // ...
}

关键常量

  • MaxCacheFileSize:只缓存 5MB 以下的文件,避免大文件占用过多内存
  • CacheDuration:缓存项有效期为 30 分钟

文件请求处理流程

public async Task ServeImageAsync(HttpContext context)
{
    // 获取和验证请求路径
    // 构建安全的文件路径
    // 检查文件是否存在
    // 设置 MIME 类型和缓存头
    // 处理条件请求
    // 从缓存或磁盘提供文件
}

安全路径验证防止目录遍历攻击:

var fullPath = Path.GetFullPath(Path.Combine(BaseDirectory, requestedPath));
if (!fullPath.StartsWith(BaseDirectory, StringComparison.OrdinalIgnoreCase))
{
    response.StatusCode = StatusCodes.Status403Forbidden;
    return;
}

这确保请求不能访问基础目录之外的文件。

HTTP 缓存控制通过 ETag 和 Last-Modified 头实现:

var lastModified = fileInfo.LastWriteTimeUtc;
var etag = $"\"{lastModified.Ticks:X}-{fileInfo.Length:X}\"";

response.Headers["ETag"] = etag;
response.Headers["Last-Modified"] = lastModified.ToString("R");
response.Headers["Cache-Control"] = "public, max-age=86400"; // 客户端缓存 1 天

这允许浏览器缓存内容并通过条件请求验证缓存有效性。

二级缓存策略是这个服务器的核心特性:

// 小文件使用内存缓存
if (fileInfo.Length <= MaxCacheFileSize)
{
    // 尝试从缓存获取
    if (!_cache.TryGetValue(cacheKey, out byte[]? cachedContent))
    {
        // 缓存未命中,读取文件内容
        cachedContent = await File.ReadAllBytesAsync(fullPath);
        
        // 设置缓存选项
        var cacheOptions = new MemoryCacheEntryOptions()
            .SetSize(cachedContent.Length)
            .SetAbsoluteExpiration(CacheDuration);
            
        // 存入缓存
        _cache.Set(cacheKey, cachedContent, cacheOptions);
    }
    
    // 从缓存发送文件
    await response.Body.WriteAsync(cachedContent);
}
else {
    // 大文件直接流式处理
    await using var fileStream = new FileStream(
        fullPath,
        FileMode.Open,
        FileAccess.Read,
        FileShare.Read,
        64 * 1024, // 64KB 缓冲区
        true);
        
    await fileStream.CopyToAsync(response.Body);
}

这种方法具有多个优势:

  • 小文件被缓存在内存中以加速访问
  • 大文件直接流式传输以避免内存压力
  • 使用 64KB 的缓冲区进行高效的文件传输
  • 异步 I/O 操作保持服务器的响应能力

MIME 类型检测通过文件扩展名实现:

private string GetMimeType(string extension)
{
    return extension switch
    {
        ".jpg" => "image/jpeg",
        ".jpeg" => "image/jpeg",
        ".png" => "image/png",
        // ...其他类型
        _ => "application/octet-stream"
    };
}

这确保浏览器能正确解释和渲染内容。

性能优化总结

这个图像服务器通过多种技术实现高性能:

  • 内存缓存:避免频繁的磁盘 I/O
  • 流式处理:高效处理大文件
  • 线程池优化:确保足够的线程处理并发请求
  • 异步 I/O:提高系统资源利用率
  • HTTP 条件请求:减少不必要的传输
  • 客户端缓存:减轻服务器负载

这些技术的组合使得该服务器能够高效地处理大量并发请求,同时保持较低的资源消耗。

结语

由此,我们就完成了我们的目标,实现了一个理论上高性能高并发并且可配置资源和性能的图床服务器,可喜可贺!

---

相关链接

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...