上周花了一天时间写了一个 MIME 检测 的库,作用是能够根据后缀名来检测对应的 Media Type 类型,比如说输入 pdf 能够返回 application/pdf。本文介绍一下这个库以及开发过程中的踩坑记录。

背景

服务器端检测文件 Media Type (之前经常被叫做 MIME types)的方法通常有三种:第一是看请求中携带的 content-type,第二是读取文件开头的 Magic Number,第三则是看请求中携带的文件名。

其中只有第一种根据 content-type 判断是在 RFC7273 中标准化过的行为,剩下的两种都是约定俗成:提供/使用这种 Media Type 的软件/服务自行规定 Magic Number 和后缀名。

以常见的 PDF 为例,它的 Media Type 模板中会有这样的内容:

Magic number(s): All PDF files start with the characters "%PDF-"
followed by the PDF version number, e.g., "%PDF-1.7" or
"%PDF-2.0".  These characters are in US-ASCII encoding.

File extension(s): .pdf

这些是在 PDF 相关的 RFC 8188: The application/pdf Media Type 中规定的,考虑的比较周到,不管是 Magic Number 还是文件后缀名都给出了明确的说明。

而根据 BCP 13,任何人和组织都能够注册 Media Type,因此文件后缀名撞车也是常有的事情,尤其是在 vnd. 前缀下的 Media Type,我也数不清有多少 Media Type 注册了 jsonxml。还有很多 Media Type 没有注册 Magic Number,其中有大部分是纯文本类型。

所以 Meida Type 与文件后缀名和 Magic Number 之间并没有明确的一一映射,大家只能自行归纳和整理,这也是为什么每个操作系统和语言都有一套自己的 mime-types 集。

其中 golang 的实现是自己有一个比较小的内置类型映射:

var builtinTypesLower = map[string]string{
	".css":  "text/css; charset=utf-8",
	".gif":  "image/gif",
	".htm":  "text/html; charset=utf-8",
	".html": "text/html; charset=utf-8",
	".jpeg": "image/jpeg",
	".jpg":  "image/jpeg",
	".js":   "text/javascript; charset=utf-8",
	".mjs":  "text/javascript; charset=utf-8",
	".pdf":  "application/pdf",
	".png":  "image/png",
	".svg":  "image/svg+xml",
	".wasm": "application/wasm",
	".webp": "image/webp",
	".xml":  "text/xml; charset=utf-8",
}

除此之外的类型会读取系统中以下三个文件:

  • /etc/mime.types
  • /etc/apache2/mime.types
  • /etc/apache/mime.types

问题

用户在调用 Pub Object 等 API 的时候如果没有指定 content-type 的话,服务器端会根据文件后缀名来尝试检测对应的 Media Type。之前的实现是使用 go 自带的 mime 包,但是在测试中遇到了相同文件后缀返回的 Media Type 不一致的问题。经过排查后发现是服务器上的 /etc/mime.types 文件不一致,有些节点安装了对应的包,有些则没有。避免这种不一致最好的方式不要依赖系统提供这个文件,因此我们决定替换掉 mime 包,改成自己的实现。

看了下社区的实现,主要有以下两个:

cubewise-code/go-mime

数据源来自 micnic/mime.json,而 micnic/mime.json 提取自一个 Node.js 社区的项目 jshttp/mime-db。功能上能够满足我们的需求,但是维护的质量堪忧,用于生成 mimeTypes 的代码并没有一并开源出来(虽然肯定非常简单)。原始数据也倒了很多层次,一方面是增加了出错的可能性,另一方面是影响更新的及时性。

gabriel-vasile/mimetype

看起来比上一个好不少,但是这个库专注于通过 Magic Number 来判断文件的 Media Type,而我们的业务场景决定了我们不能读取用户上传的数据来获取 Magic Number。此外这个库只支持了少量文件类型(147 种),数据源也不是非常明确。

所以需要自己造一个轮子。

设计

我们需要一个这样的 MIME 库:

  • 没有第三方依赖
  • 不依赖系统行为
  • 尽可能直接处理原始数据
  • 支持多种方式检测(首先支持文件后缀名)

最难的地方在于找到合适的数据源。正如前面所提到的,当今的各种规范中都没有明确定义 Media Type 到文件后缀名的一一映射。经过一番搜寻之后,只能找到如下相对可信的来源:

其中很多发行版会使用 mailcap 提供的 mime.types 作为 /etc/mime.types,已知的有 archlinux

所以最后决定第一期解析 IANA 的 Media Type Templatemailcap 提供的 mime.types 并生成一个 map[<file-extension>]<media-type>

实现

具体的实现没有什么值得说的,值得一提的是 IANA Media Types Registry 中居然还有个 template 404 了,好在他们的响应速度还挺快的,周五给他们发了邮件,周日就 Fix 了。

我真心觉得 IANA 应该尽快采用结构化数据,这 Meida Type Template 也太傻了。。

Showtime

目前 go-mime 只提供了两个接口:DetectFileExtDetectFilePath

import (
    "github.com/qingstor/go-mime"
)

func main()  {
    // Get mime type via file extension.
    mimeType := mime.DetectFileExt("pdf")
    // Get mime type via file path or name.
    mimeType := mime.DetectFilePath("/srv/http/a.pdf")
}

做了一些简单的 Benchmark:

goos: linux
goarch: amd64
pkg: github.com/qingstor/go-mime
BenchmarkDetectFilePath
BenchmarkDetectFilePath-8                	64920656	        18.1 ns/op
BenchmarkDetectFileExt
BenchmarkDetectFileExt-8                 	98209147	        10.9 ns/op
BenchmarkDetectFileExtWithMissingExt
BenchmarkDetectFileExtWithMissingExt-8   	98105074	        11.8 ns/op
BenchmarkGoMime
BenchmarkGoMime-8                        	25084992	        47.7 ns/op
BenchmarkGoMimeWithMissingExt
BenchmarkGoMimeWithMissingExt-8          	10683265	       104 ns/op
PASS

下一步规划

目前只实现了最基础的功能,接下来考虑在以下的方面做一些改进

  • 目前的 Generator 实现比较难看,之后考虑重构的更加清晰
  • 支持读取 Magic Number,多种方式判断
  • 支持 Media Type Parameters,比如说 charset
  • 支持给定 Media Type 返回可能的文件后缀名

参考资料