Skip to content

静态文件中间件

本章只讲三件事:

  1. useStaticFiles() 如何提供文件
  2. useDefaultFiles() 如何处理目录默认页
  3. useFileServer() 如何把两者组合起来

最小示例

cangjie
import soulsoft_web_hosting.*
import soulsoft_web_staticfiles.*

main(args: Array<String>): Int64 {
    let builder = WebHost.createBuilder(args)
    let app = builder.build()

    // 一次启用默认文件和静态文件服务
    app.useFileServer()

    app.run()
    return 0
}

默认情况下:

  • 文件根目录来自 environment.webRootPath
  • 常见情况下就是项目下的 wwwroot
  • useFileServer() 会先注册 useDefaultFiles(),再注册 useStaticFiles()

一个典型目录结构如下:

text
demo/
├─ src/
│  └─ main.cj
└─ wwwroot/
   ├─ index.html
   ├─ hello.txt
   └─ assets/
      └─ app.css

三个入口

useStaticFiles()

只负责“把请求路径映射到具体文件并输出响应”,不会主动查找目录默认页。

cangjie
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

// GET /hello.txt -> wwwroot/hello.txt
app.useStaticFiles { options =>
    // 指定静态文件目录
    options.fileProvider = PhysicalFileProvider("wwwroot")
}

useDefaultFiles()

只负责“目录路径重写”或“补 / 重定向”,本身不输出文件内容。

cangjie
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

app.useDefaultFiles { options =>
    // 指定默认文件查找目录
    options.fileProvider = PhysicalFileProvider("wwwroot")
}

它处理的是这类路径:

  • /docs -> 如果目录存在且命中默认文件,先 301/docs/
  • /docs/ -> 重写为 /docs/index.html

useFileServer()

这是组合入口。默认等价于:

cangjie
app.useDefaultFiles()
app.useStaticFiles()

如果配置的是 FileServerOptionsrequestPathfileProvider 会同时共享给这两个中间件。

常见用法

提供根目录静态资源

cangjie
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

app.useStaticFiles { options =>
    // 访问 /hello.txt 时从 wwwroot/hello.txt 读取
    options.fileProvider = PhysicalFileProvider("wwwroot")
}

挂到指定前缀

cangjie
import soulsoft_web_http.*
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

app.useStaticFiles { options =>
    // 只有 /static 开头的请求才进入静态文件中间件
    options.requestPath = PathString("/static")
    options.fileProvider = PhysicalFileProvider("wwwroot")
}

这时:

  • GET /static/hello.txt 会命中 wwwroot/hello.txt
  • GET /hello.txt 不会命中这个中间件

默认文件 + 静态文件

cangjie
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

// 先处理目录默认页
app.useDefaultFiles { options =>
    options.fileProvider = PhysicalFileProvider("wwwroot")
}

// 再输出实际文件内容
app.useStaticFiles { options =>
    options.fileProvider = PhysicalFileProvider("wwwroot")
}

上面的行为是:

  • GET / -> 如果存在 index.html,返回首页
  • GET /docs -> 先重定向到 /docs/
  • GET /docs/ -> 重写为 /docs/index.html,再由静态文件中间件输出

扩展内容类型

cangjie
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

app.useStaticFiles { options =>
    let provider = ContentTypeProvider()

    // 给自定义扩展名补充映射
    provider.mappings[".cj"] = "text/plain"

    options.fileProvider = PhysicalFileProvider("wwwroot")
    options.contentTypeProvider = provider
}

允许未知类型

cangjie
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

app.useStaticFiles { options =>
    options.fileProvider = PhysicalFileProvider("wwwroot")

    // 未知扩展名也允许输出
    options.serveUnknownFileTypes = true

    // 未知类型统一使用这个 Content-Type
    options.defaultContentType = "application/octet-stream"
}

默认情况下,未知扩展名不会被提供。

写入响应前追加响应头

cangjie
import soulsoft_web_http.*
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

app.useStaticFiles { options =>
    options.fileProvider = PhysicalFileProvider("wwwroot")

    // 在正文写入前补充缓存头
    options.onPrepareResponse = { fileContext =>
        fileContext.context.response.headers.cacheControl = ["public, max-age=604800"]
    }
}

组合多个文件目录

cangjie
import std.collection.*
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

let provider = CompositeFileProvider([
    PhysicalFileProvider("wwwroot"),
    PhysicalFileProvider("assets")
] as Collection<IFileProvider>)

app.useStaticFiles { options =>
    // 按注册顺序查找,第一个命中的文件直接返回
    options.fileProvider = provider
}

中间件顺序

静态文件是否会被终结点“抢先处理”,取决于顺序。

StaticFileMiddleware 在运行时会先检查 context.getEndpoint()

  • 如果当前请求已经匹配到终结点,它直接跳过
  • 如果还没有终结点,它才尝试读文件

所以有两种常见写法。

终结点优先

cangjie
app.useRouting()
app.useStaticFiles()

app.mapGet("/hello.txt") { ctx =>
    ctx.response.write("dynamic")
}

这时如果 /hello.txt 已被路由匹配成终结点,静态文件中间件会跳过,返回动态结果。

静态文件优先

cangjie
app.useStaticFiles()

app.mapGet("/hello.txt") { ctx =>
    ctx.response.write("dynamic")
}

这时静态文件中间件先运行,只要命中物理文件,就不会再继续走到后面的终结点。

时序图

下面是 useDefaultFiles() + useStaticFiles() 组合时的典型链路:

执行流程

useStaticFiles() 做什么

静态文件中间件只有在下面条件同时满足时才会尝试输出文件:

  1. 当前请求还没有匹配到 Endpoint
  2. 请求方法是 GETHEAD
  3. 请求路径命中了 requestPath
  4. 能解析出 Content-Type,或者显式允许未知类型
  5. 文件确实存在

少一项都直接 next(context)

useDefaultFiles() 做什么

默认文件中间件只做目录判断:

  1. 当前请求还没有匹配到 Endpoint
  2. 请求方法是 GETHEAD
  3. 请求路径命中了 requestPath
  4. 对应目录存在
  5. 目录下命中了默认文件名列表

命中后分两种情况:

  • 路径缺少尾斜杠且开启了 redirectToAppendTrailingSlash:返回 301
  • 路径已经带 /:把请求路径改写为默认文件,再交给后续静态文件中间件

静态文件响应了什么

命中文件后,静态文件中间件会自动处理这些 HTTP 语义:

  • Content-Type
  • Content-Length
  • ETag
  • Last-Modified
  • Accept-Ranges
  • If-Match
  • If-None-Match
  • If-Modified-Since
  • If-Unmodified-Since
  • Range
  • If-Range

因此它不只是“把文件读出来”,还会根据请求头返回:

  • 200 OK
  • 206 Partial Content
  • 304 Not Modified
  • 412 Precondition Failed
  • 416 Requested Range Not Satisfiable

一个推荐写法

如果你的目标是“有默认首页、静态资源挂在 /assets、动态路由优先”,可以这样写:

cangjie
import soulsoft_web_http.*
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_web_staticfiles.*
import soulsoft_web_staticfiles.fileProviders.*

let builder = WebHost.createBuilder(args)
let app = builder.build()

// 先做路由匹配,让终结点有机会优先命中
app.useRouting()

// 目录访问走默认页,例如 /assets/docs/ -> /assets/docs/index.html
app.useFileServer { options =>
    options.requestPath = PathString("/assets")
    options.fileProvider = PhysicalFileProvider("wwwroot")
}

// 这里的动态终结点会优先于静态文件
app.mapGet("/assets/version") { ctx =>
    ctx.response.write("1.0.0")
}

这个顺序的含义很明确:

  • 路由先决定当前请求是否已经命中终结点
  • 静态文件只处理还没命中终结点的请求
  • 默认文件和静态文件共享同一个前缀与文件提供器