做照片分享站的时候,我栽过一个很典型的跟头:用户千辛万苦上传的旅游照片,后台系统愣是拿不到拍摄时间,更不要说照片里的经纬度定位了。一开始我以为是没有权限或者上传被压缩了,后来扒开二进制文件一看才发现,图片里明明存着一大堆 EXIF 信息,只是代码压根没去读。这之后我花时间把 Golang 生态里处理 EXIF 的库挨个过了一遍,尤其是 goexif 提取拍摄时间和 GPS 位置这套流程,总算把“读”和“改”两条路都走通了。这篇文章就是那次折腾的完整记录,给同样在跟图片元数据较劲的人一个能直接抄作业的版本。
这篇文章适合这几类人看:正在做图片管理系统、相册应用或图片上传服务的后端同学;想批量给旅行照片打时间戳和地理信息的摄影爱好者;还有面试准备期想聊清楚 Golang 怎么处理二进制元数据细节的求职者。我会从 EXIF 的内部结构讲起,再给 goexif 的完整读取示例,最后专门聊怎么修改 EXIF——这恰恰是很多文章一笔带过的部分。
1. EXIF 到底藏了什么,为什么用 Go 解析时总觉得绕
1.1 一张照片的 EXIF 并不是一个独立文件
很多第一次接触 EXIF 的人会误以为图片里有个类似“属性文件”的东西挂在旁边,实际上 EXIF 是直接写进图像文件字节流里的一组结构化数据。JPEG 文件在文件头之后紧跟着一个个 Segment,EXIF 通常藏在标记为APP1(0xFFE1)的段里。相机拍摄时生成一个符合 TIFF 格式的数据块,塞进这个 Segment,后面才是 JPEG 的压缩图像数据。
这个 TIFF 数据块的核心组织单位叫 IFD(Image File Directory),也就是“映像文件目录”。你可以把 IFD 理解成一个仓库里的货架:每个货架上摆着一排标签,每个标签由“编号(Tag)、类型(Type)、长度(Count)、对应值(Value)”四件套组成。比如相机型号、镜头参数、拍摄时间、GPS 坐标,都是以这种形式挂在不同 IFD 下面的。
这里最容易被绕晕的点是:EXIF 里通常有两个甚至三个 IFD。IFD0 存放相机厂商、型号、图像分辨率这类基础信息;EXIF SubIFD 存放曝光时间、光圈、拍摄时间等专业拍摄参数;GPS IFD 单独存放经纬度、海拔、卫星信号相关数据。所以当你用代码去“读 EXIF”时,实际要做的是先找到一个 IFD0,再从 IFD0 里找到指向 EXIF SubIFD 和 GPS IFD 的偏移量,然后跳过去继续读。
1.2 拍摄时间与 GPS 坐标会被记在哪些字段里
在线相册系统里最常用的几个字段,我直接列个表,后续代码里也要用到它们:
| 含义 | IFD | Tag 名 | Tag 十六进制编号 |
|---|---|---|---|
| 拍摄时间(原始) | EXIF SubIFD | DateTimeOriginal | 0x9003 |
| 数字化时间 | EXIF SubIFD | DateTimeDigitized | 0x9004 |
| 文件修改时间 | IFD0 | DateTime | 0x0132 |
| GPS 纬度参考(N/S) | GPS IFD | GPSLatitudeRef | 0x0001 |
| GPS 纬度 | GPS IFD | GPSLatitude | 0x0002 |
| GPS 经度参考(E/W) | GPS IFD | GPSLongitudeRef | 0x0003 |
| GPS 经度 | GPS IFD | GPSLongitude | 0x0004 |
| GPS 海拔 | GPS IFD | GPSAltitude | 0x0006 |
拍摄时间这里有个坑要注意:DateTimeOriginal才是相机按下快门那一瞬间写入的时间,而DateTime是影像文件最后一次被修改的时间。部分手机/后期软件在导入或编辑照片后,会更新DateTime,但DateTimeOriginal仍保持最初拍摄时间。处理用户上传的图片时,优先取DateTimeOriginal;取不到再回退到DateTime。
GPS 字段的设计则更显“老派”:它不用单纯的浮点数,而是把经纬度拆成“度 / 分 / 秒”三段有理数,再配一个单独的方向参考字符串。北纬、东经是 N 和 E,南纬、西经是 S 和 W。比如某坐标是N 31° 13' 48.00",那GPSLatitudeRef为N,GPSLatitude是由31、13、48.00三个数组成的多个值字段,谁用谁就得自己换算成小数。
2. 读取之前先搞明白:Go 生态里 EXIF 库应该怎么选
2.1 goexif:老牌读取库的优势与局限
github.com/rwcarlsen/goexif是 Go 社区里知名度最高的 EXIF 解析库,很多教程和工具都基于它。它能自动完成上面说的那套“找 APP1 -> 分析 TIFF -> 跳转 IFD”的动作,对外暴露exif.Decode这种简单接口,使用者不用关心底层字节布局,对新手非常友好。
但它有两个硬伤必须在项目启动前想清楚。第一,它基本只做读取,不提供正规的写回接口,想修改 EXIF 字段得用别的方案。第二,这个库已经很久没有大版本更新了,源码里对某些特殊乱标、缺少 IFD 偏移的情况处理得比较保守;遇到非 JPEG 文件或者 DNG、HEIC 这类容器,它往往会直接报错或者返回空结果。所以它适合“快速读取标准 JPEG 的拍摄信息和 GPS”,但并不能当万能瑞士军刀使。
2.2 除了 goexif,还有哪些读写方案值得列入对比清单
我在实际项目中会从下面这几个方案里挑:
| 库/工具 | 读写能力 | 维护活跃度 | 适合场景 |
|---|---|---|---|
| rwcarlsen/goexif | 只读 | 低 | 快速提取 JPEG 拍摄时间和 GPS |
| xiam/go-exif | 可读可写 | 低 | 修改 DateTimeOriginal、Artist 等常规字段 |
| dsoprea/go-exif/v3 | 可读可写 | 高 | 企业级标准 IFD 解析,对标成熟商业工具 |
| barasher/go-exiftool | 调外部程序 | 高 | 需要兼容 DNG / HEIC 等非 JPEG 格式 |
| 直接用 os/exec 调 exiftool | 读写全能 | 取决于 exiftool 版本 | 做一次性批量写入、复杂字段修改 |
xiam/go-exif的 API 是老式Get/Set/Save风格,简单直观;dsoprea/go-exif/v3则是严格按标准 IFD 模型来设计,扩展性更强,适合写正规解析器;barasher/go-exiftool是把 Perl 神器 exiftool 用子进程包了一层,你用 Go 代码调用它的方法,底层仍是命令行工具。
2.3 我建议的选型结论
只读和批量导出场景,直接用 goexif 就够了,它代码量小,学习成本低,输出结果清晰。如果需要修改常用元数据字段,我首选 xiam/go-exif,前提是你清楚它支持的字段范围。如果是生产环境,面对各种格式混杂的用户图片,或者需要稳定修改 GPS 这类结构化字段,我建议直接把 exiftool 纳入技术栈,Go 代码只负责组织和调度,真正的二元解析交给 exiftool。这样看着不够“优雅”,但在真实业务里最省心。
3. 用 goexif 提取拍摄时间和 GPS 位置:从第一行代码跑通
3.1 环境准备与依赖安装
先确保本地 Go 版本在 1.20 以上,然后拉取 goexif 依赖:
go get -u github.com/rwcarlsen/goexif/exif如果你的模块启用了 Go Modules(现在默认都是),直接在主模块下跑这个命令会把依赖写入go.mod。这个库的包路径有点长,导入的时候是:
import "github.com/rwcarlsen/goexif/exif"命名上容易跟别的包冲突,我建议在代码里起个别名,比如goexif "github.com/rwcarlsen/goexif/exif",后面用起来更顺手。
3.2 第一段代码:读取拍摄时间
下面是最小可行的读取示例,我注释写得多一点,方便你直接改路径跑:
package main import ( "fmt" "log" "os" goexif "github.com/rwcarlsen/goexif/exif" ) func main() { filePath := "IMG_20241001_083000.jpg" f, err := os.Open(filePath) if err != nil { log.Fatalf("打开文件失败: %v", err) } defer f.Close() x, err := goexif.Decode(f) if err != nil { log.Fatalf("EXIF 解析失败: %v", err) } tm, err := x.DateTime() if err != nil { log.Printf("没有读取到拍摄时间: %v", err) } else { fmt.Printf("拍摄时间: %s\n", tm.Format("2006-01-02 15:04:05")) } }这份代码跑通后会输出类似:
拍摄时间: 2024-10-01 08:30:00如果文件没有 EXIF,或者路径错误,程序会直接log.Fatalf退出,这种写法适合命令行小工具;如果你要把这套逻辑塞进 HTTP 接口里,记得把Fatal换成return err,让上层决定怎么处理。
3.3 读取 GPS 坐标并转换成十进制度数
goexif提供了非常方便的LatLong()方法,直接返回十进制的纬度和经度。不过为了让你理解底层逻辑,我把手动解析 DMS 的算法也放进来,这样以后接触不支持LatLong()的库时也不会慌:
func main() { f, err := os.Open("IMG_20241001_083000.jpg") if err != nil { log.Fatal(err) } defer f.Close() x, err := goexif.Decode(f) if err != nil { log.Fatal(err) } lat, lon, err := x.LatLong() if err != nil { log.Printf("没有读取到 GPS: %v", err) return } fmt.Printf("GPS 坐标: 纬度 %.6f, 经度 %.6f\n", lat, lon) }LatLong()内部做的事情,本质上是先读GPSLatitudeRef和GPSLatitude,再用下面这个公式换算:
十进制度数 = 度 + 分/60 + 秒/3600 如果方向参考是 S 或 W,最终结果取负数如果你读取的是原始 DMS 字段,转换逻辑是这样的:
func dmsToDecimal(degree, minute, second float64, ref string) float64 { value := degree + minute/60 + second/3600 if ref == "S" || ref == "W" { value = -value } return value }3.4 加几个保护,让程序在处理非 EXIF 图片时不崩
我在最早写这种工具时,拿到一个目录就硬着头皮Decode,结果一个 PNG 文件直接把程序干崩了。后来总结出几条基本经验:
- 先检查文件魔数,JPEG 的前三个字节固定是
FF D8 FF,不满足就直接跳过。 exif.Decode对没有APP1段或文件截断的情况会返回错误,要把这类错误统一当成“无 EXIF 信息”处理,而不是抛出到业务上层。- 对大文件或者目录遍历场景,避免一次性读整个文件进内存,尽量用
os.File做流式读取。
下面这个decodeExif函数就是加了文件类型预检的版本:
func decodeExif(path string) (*goexif.Exif, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } if len(data) < 3 || data[0] != 0xFF || data[1] != 0xD8 || data[2] != 0xFF { return nil, fmt.Errorf("不是 JPEG 文件: %s", path) } return goexif.Decode(bytes.NewReader(data)) }这样写虽然多读了一次文件,但胜在安全,放在工具脚本里完全够用。
4. 读取只是第一步:修改 EXIF 的三种可靠路径
4.1 为什么“一行代码改 EXIF”在 Go 里并不多见
很多人找 Go 读 EXIF 库时,默认认为它跟 JavaScript 里的piexif一样,能顺手把字段写回去。但 EXIF 是定长二进制结构,改一个字段往往牵一发动全身:字符串变长要重排整个 IFD,加一个新标签要重新计算偏移量,GPS 这类多值字段还要按规范重写类型和长度。所以 goexif 干脆砍掉了写能力,免得用户一把梭清空整个文件结构。
要改 EXIF,我把它分成三种路径:小字段用xiam/go-exif,复杂字段或全格式场景用exiftool,追求标准实现则上dsoprea/go-exif/v3。下面都给出可运行的参考写法。
4.2 方案一:用 xiam/go-exif 修改常用字符串字段
xiam/go-exif的 API 设计得很亲民,NewFromFile打开文件,Get读取,Set修改,Save保存。它适合修改Artist、Copyright、DateTimeOriginal这类标量字段。直接看代码:
package main import ( "fmt" "log" "github.com/xiam/go-exif" ) func main() { ex, err := exif.NewFromFile("IMG_20241001_083000.jpg") if err != nil { log.Fatal(err) } defer ex.Close() if tag, err := ex.Get("DateTimeOriginal"); err == nil { fmt.Printf("修改前 DateTimeOriginal: %s\n", tag.String()) } // 修改拍摄时间为 2024-10-02 12:00:00 // 注意:EXIF 规定日期时间格式是 YYYY:MM:DD HH:MM:SS if err := ex.Set("DateTimeOriginal", "2024:10:02 12:00:00"); err != nil { log.Fatal(err) } if err := ex.Save("IMG_20241001_083000_mod.jpg"); err != nil { log.Fatal(err) } fmt.Println("已输出到 IMG_20241001_083000_mod.jpg") }这个库的缺陷是它对 GPS 这类由多个子字段组成的结构化标签支持一般,比如GPSLatitude在底层是三个值组成的有理数数组,直接用字符串Set容易踩格式坑,所以我建议 GPS 字段还是走 exiftool 更稳。
4.3 方案二:Go 调用 exiftool 修改任意字段
exiftool 可以说是处理图片元数据的“终极方案”,它能读能写、支持 JPEG/TIFF/DNG/HEIC/PNG 等一堆格式。Go 里直接用os/exec把它拉起来即可,不引入复杂依赖。
下面的示例演示同时修改拍摄时间和 GPS 坐标:
package main import ( "fmt" "os/exec" ) func main() { cmd := exec.Command("exiftool", "-DateTimeOriginal=2024:10:02 12:00:00", "-GPSLatitude=31.2304", "-GPSLongitude=121.4737", "-overwrite_original", "IMG_20241001_083000.jpg", ) out, err := cmd.CombinedOutput() if err != nil { fmt.Println(string(out), err) return } fmt.Println(string(out)) }这里有个非常重要的细节:exiftool 默认不会直接覆盖原文件,而是生成一个新文件并把原文件备份成IMG_20241001_083000.jpg_original。我在批量处理时一般保留这个备份,确认没问题后再统一清理_original文件。如果你明确不想要备份,就加上-overwrite_original。
这种方式唯一的前提是服务器上安装了 exiftool,在 Ubuntu/Debian 上可以用sudo apt install libimage-exiftool-perl装好,macOS 上brew install exiftool。对后端服务来说,增加一个外部命令依赖需要评估运维成本,不过考虑到它带来的格式兼容性提升,我觉得值。
4.4 方案三:用 dsoprea/go-exif/v3 面向标准 IFD 修改
dsoprea/go-exif/v3是这几个方案里最“正统”的,它严格按照 EXIF/TIFF 标准建模,支持很底层的 IFD 操作。它的读取流程是先建立 IFD 映射和 Tag 索引,再解析字节流,最后通过IfdPath去定位字段:
import ( "github.com/dsoprea/go-exif/v3" exifcommon "github.com/dsoprea/go-exif/v3/common" ) ti := exif.NewTagIndex() im, err := exif.NewIfdMappingWithStandard() // rawExif 是 JPEG APP1 段里提取出来的 EXIF 字节 imTIFF, err := im.Get("IFD0") ep := exif.NewExifParser(im, ti) index, err := ep.Parse(rawExif)这套 API 比前两种复杂不少,它比较适合有长期维护计划、需要兼容各种非标文件的生产级框架。如果你只是想在自己工具里把名字和日期改一下,没必要上这么大一坨;先记住它存在即可,真的碰到需求再回头啃。
5. 实战:批量整理照片,自动导出拍摄时间和 GPS 轨迹
5.1 需求与流程拆解
纯读取和纯修改部分都跑通后,真正的爽点是组合成自动化脚本。我举一个真实需求场景:你手头有上百张旅行照片,分别来自相机、两台手机,想整理出每张照片的拍摄时间、经纬度,并生成一张地图。
这个需求拆成四个步骤:
- 遍历指定目录,过滤出
.jpg/.jpeg文件。 - 用 goexif 解析每张照片,提取拍摄时间和 GPS。
- 把结果写入 CSV 文件,供表格软件或地图可视化使用。
- 如果有照片时间错乱,用 exiftool 批量修正。
5.2 批量遍历目录并生成 CSV 的代码
下面这段代码就是上述流程的完整落地,我会重点解析几个容易出错的地方:
package main import ( "encoding/csv" "fmt" "log" "os" "path/filepath" "strings" goexif "github.com/rwcarlsen/goexif/exif" ) func main() { dir := "./photos" entries := [][]string{{"文件名", "拍摄时间", "纬度", "经度"}} err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } lower := strings.ToLower(info.Name()) if !strings.HasSuffix(lower, ".jpg") && !strings.HasSuffix(lower, ".jpeg") { return nil } f, err := os.Open(path) if err != nil { log.Printf("跳过 %s: %v", path, err) return nil } defer f.Close() x, err := goexif.Decode(f) if err != nil { log.Printf("跳过 %s: %v", path, err) return nil } tm, _ := x.DateTime() lat, lon, _ := x.LatLong() entries = append(entries, []string{ path, tm.Format("2006-01-02 15:04:05"), fmt.Sprintf("%.6f", lat), fmt.Sprintf("%.6f", lon), }) return nil }) if err != nil { log.Fatal(err) } out, _ := os.Create("photo_index.csv") defer out.Close() w := csv.NewWriter(out) _ = w.WriteAll(entries) w.Flush() fmt.Println("生成 photo_index.csv") }这个脚本里有三个细节值得注意:一是filepath.Walk在遍历大目录时,会在每个子目录和文件上都回调一次,记得对目录类型做早期返回,避免白开文件句柄;二是 CSV 里的经纬度我统一格式化成 6 位小数,这大概对应到 0.1 米级别的精度,对绝大多数场景足够了;三是tm和lat/lon的读取错误我这里直接忽略,因为业务上这些信息缺失是常态,不应该中断整个目录的扫描。
想输出 JSON 格式给前端地图用的话,把 CSV 换成encoding/json编码一个切片即可,思想完全一样。
5.3 批量修正拍摄时间的完整操作
假设你发现这批照片里,有一台手机因为时区设置错误,所有照片都晚了 7 个小时。手动一张张改不现实,用 exiftool 批量替换时间就是几分钟的事:
exiftool "-DateTimeOriginal+=7:00:00" "-DateTimeDigitized+=7:00:00" photos/*.jpg这里的+=表示在原有时间上增加偏移,exiftool 支持给DateTimeOriginal这样的字段做时间加减。执行时 exiftool 会对每个文件生成_original备份文件,想要强制覆盖就再加-overwrite_original。我强烈建议批量操作第一轮先不加这个参数,运行完抽查几张文件,确认时间正确后再统一清理备份。
5.4 扩展:把 GPS 点画到地图上(WGS84 与 GCJ-02 的问题)
CSV 拿到之后,画地图最常见的坑是坐标漂移。手机和相机里的原始 GPS 坐标基于 WGS84 坐标系,而国内主流在线地图(高德、腾讯等)用的是 GCJ-02 坐标系,两者之间有一段非线性偏移,在高纬度地区能差到几百米。直接拿原始经纬度往国内地图上打点,点位会落在路边甚至隔壁街区,看起来很“翻车”。
解决方案有两类:一是地图服务商提供纠偏接口,在展示端处理;二是在后端做好坐标系转换,把 WGS84 转成 GCJ-02。转换算法本质是加一个基于经纬度计算的偏移量,网上有很多开源实现,在 Go 里也有现成函数可以抄,核心就十几行。做这类可视化功能时,先确认清楚目标地图用的坐标系,再决定要不要转。
6. 遇到这些问题,先别急着怀疑库坏了
6.1 华为 DNG / RAW 文件为什么读不到 EXIF
DNG 是 Adobe 制定的通用 RAW 格式,华为、小米等手机在专业模式里能产出 DNG。这类文件本质上是一种 TIFF 容器,但它不一定把 EXIF 放在 JPEG 的APP1段里,而是直接放在 TIFF 自己的各种 IFD 中,甚至很多信息落在 XMP 里。goexif 主要针对 JPEG 的APP1段设计,所以对 DNG 往往无能为力。
遇到这种情况,先用 exiftool 看一眼它的元数据到底存在哪个命名空间:
exiftool photo.dng如果输出里能看到DNGVersion、XMP:CreateDate等字段,说明信息都在,只是读取路径不同。真要支持 DNG,我建议直接用 exiftool 方案,它内部已处理好了这种格式的复杂映射,不必非得用 Go 库硬啃。
6.2 明明有 GPS 却读不到,或者坐标有几百米偏差
出现“没有读取到 GPS”先不要怀疑相机坏了,大概率是两类原因。一类是 EXIF 里只有 GPS 版本号,没有实际坐标值,这常见于相机拍摄后在电脑上重新导出过的文件;另一类是手机默认不写 GPS 权限信息,你需要在相机设置里打开“保存位置信息”再拍。goexif 的LatLong()只要缺一个标签就返回错误,这种严格模式既让人吐槽又值得肯定。
至于坐标偏差,民用 GPS 的原始定位精度在开阔地带一般是 3~10 米,在城市峡谷、隧道附近会跌到几十米甚至不可用。这是硬件和卫星信号特性决定的,不是图片元数据处理能解决的。真正要注意的是,别把带偏移的坐标直接喂给要求 WGS84 的 GIS 系统。
6.3 中文信息乱码
EXIF 里的Artist、Copyright、ImageDescription等字符串字段,在各设备上的编码并不统一。有些写 UTF-8,有些写 ASCII,还有些老相机写 JIS 编码。goexif 的tag.String()默认按原始字节解析,中文字符很可能变成一串乱码。
处理时我先拿十六进制看字段内容,再决定怎么转码。Go 标准库没有内置 GBK 解码,需要加golang.org/x/text/encoding/simplifiedchinese,对可能是 GBK 的字节走simplifiedchinese.GBK.NewDecoder()转一遍。写入时同样的道理,最好统一写成 ASCII 或 UTF-8,并在程序内部限定好规则,避免同样的文件在 mac 上能读、在 Windows 上乱码。
6.4 保存后文件体积变大甚至打不开
用 xiam/go-exif 或 exiftool 保存过文件的人会发现,修改后的文件经常比原来大几 KB 甚至几十 KB。这是因为很多 EXIF 库在写回时会重建整个 TIFF 结构,把原本压缩过或以特殊方式对齐的字段重新铺排。这不是 bug,只是实现策略,不必担心。
真正要警惕的是原地写回时的中断风险。如果程序在写第二个标签时崩了,文件就损坏了。所以我所有写操作都遵守一条规则:先拷贝出临时文件,写好后改名覆盖;期间任何一步出错,原文件都还保住。exiftool 默认生成_original备份也是这个用意。
6.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 读取返回 EOF | 文件没有 APP1 EXIF 段,或 JPEG 被截断 | 先校验文件魔数,把 EOF 当“无 EXIF”处理 |
| 能读拍摄时间但读不到 GPS | 相机/手机未开启位置记录,或文件被软件处理过 | 检查原始文件,确认 GPS IFD 里是否有坐标 |
| DNG / RAW 读不出信息 | 元数据不在 JPEG APP1 段 | 用 exiftool 定位并读取 |
| 中文信息乱码 | 字段编码不是 UTF-8 | 用golang.org/x/text做 GBK/UTF-8 转换 |
| 改完后文件变大 | EXIF 库重建 IFD 结构 | 正常现象,注意写备份 |
| GPS 坐标与地图点位偏几百米 | WGS84 与 GCJ-02 坐标系不同 | 按目标地图坐标系做转换 |
处理 EXIF 这几年我最大的体会是:解析元数据这件事本身不难,难的是对各种非标文件的“防御性编程”。我现在所有和图片元数据相关的代码,都会在入口统一做三步——先备份,再解析,后校验。解析失败绝不 panic,要么跳过要么返回业务错误;校验阶段确认修改后的文件能被正常打开,才算真正完成。这套习惯让我少修了无数个半夜报警的线上 bug。如果你也打算在项目里接入类似的图片元数据处理能力,强烈建议从一开始就把这三个步骤写进架构规范里。