跳到主要内容

11 篇博文 含有标签「golang」

查看所有标签

· 阅读需 6 分钟
wen

让我为您深入浅出地讲解 Golang 的 Channel 概念。

1. 生活类比 🌟

想象一个咖啡店的点单流程:

  • Channel 就像咖啡店的取餐窗口
  • 咖啡师(生产者)将做好的咖啡放在窗口
  • 服务员(消费者)从窗口取走咖啡给客人
  • 窗口有限制:最多放 3-4 杯咖啡
  • 如果窗口满了,咖啡师需要等待
  • 如果窗口空了,服务员需要等待

Golang Channels in Life

2. 技术定义 📚

Channel 是 Go 语言中的一个核心概念,它提供了 goroutine 之间的通信机制,实现了 CSP(Communicating Sequential Processes)模型。

基础示例:

package main

import (
"fmt"
"time"
)

func main() {
// 创建一个容量为 3 的 channel
coffee := make(chan string, 3)

// 生产者 goroutine
go func() {
drinks := []string{"拿铁", "美式", "卡布奇诺"}
for _, drink := range drinks {
fmt.Printf("咖啡师制作了 %s\n", drink)
coffee <- drink // 将咖啡放入 channel
time.Sleep(time.Second)
}
close(coffee) // 关闭 channel
}()

// 消费者(主 goroutine)
for drink := range coffee {
fmt.Printf("服务员取走了 %s\n", drink)
}
}

3. 核心特性表 📊

特性说明示例
缓冲性可以创建带缓冲的 channelch := make(chan int, 3)
阻塞性当 channel 满/空时会阻塞满时发送阻塞,空时接收阻塞
方向性可以限制 channel 的方向chan<- (仅发送) <-chan (仅接收)
关闭性channel 可以被关闭close(ch)

4. 实践案例 💡

让我们实现一个更实用的例子:一个简单的任务处理系统

package main

import (
"fmt"
"time"
)

// Task 代表一个待处理的任务
type Task struct {
ID int
Data string
}

// Worker 代表一个工作协程
func Worker(id int, tasks <-chan Task, results chan<- string) {
for task := range tasks {
// 模拟处理任务
fmt.Printf("Worker %d 开始处理任务 %d\n", id, task.ID)
time.Sleep(time.Second)

// 发送结果
results <- fmt.Sprintf("任务 %d 已被 Worker %d 完成", task.ID, id)
}
}

func main() {
tasks := make(chan Task, 10)
results := make(chan string, 10)

// 启动 3 个 worker
for i := 1; i <= 3; i++ {
go Worker(i, tasks, results)
}

// 发送 5 个任务
for i := 1; i <= 5; i++ {
tasks <- Task{ID: i, Data: fmt.Sprintf("数据-%d", i)}
}
close(tasks)

// 收集所有结果
for i := 1; i <= 5; i++ {
fmt.Println(<-results)
}
}

5. 最佳实践 ⭐

我来介绍 Golang channel 处理超时的几个常用例子。

  1. 使用 time.After

    • 最简单直接的方法
    • 适合一次性的超时检查
    • 使用 selecttime.After 实现
// Example 1: Basic timeout using select and time.After
func example1() {
ch := make(chan string)

// Simulate slow operation
go func() {
time.Sleep(2 * time.Second)
ch <- "data"
}()

select {
case result := <-ch:
fmt.Println("Received:", result)
case <-time.After(1 * time.Second):
fmt.Println("Operation timed out")
}
}
  1. 使用 context.WithTimeout

    • Go 推荐的标准方式
    • 可以传递超时信息到多个 goroutine
    • 支持取消操作
    • 资源会自动清理
// Example 2: Using context for timeout
func example2() {
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

ch := make(chan string)

// Simulate slow operation
go func() {
time.Sleep(2 * time.Second)
ch <- "data"
}()

select {
case result := <-ch:
fmt.Println("Received:", result)
case <-ctx.Done():
fmt.Println("Operation timed out:", ctx.Err())
}
}
  1. 使用超时通道
    • 使用专门的超时 channel
    • 更灵活但需要手动管理
    • 适合需要自定义超时行为的场景
// Example 3: Custom timeout channel
func example3() {
ch := make(chan string)
timeout := make(chan bool, 1)

// Set timeout
go func() {
time.Sleep(1 * time.Second)
timeout <- true
}()

// Simulate slow operation
go func() {
time.Sleep(2 * time.Second)
ch <- "data"
}()

select {
case result := <-ch:
fmt.Println("Received:", result)
case <-timeout:
fmt.Println("Operation timed out")
}
}

运行这段代码,你会看到三个例子都会因为超时(1 秒)而终止,因为模拟的操作需要 2 秒才能完成。

建议在实际应用中:

  • 对于简单场景,使用 time.After
  • 对于复杂应用,优先使用 context.WithTimeout
  • 只在特殊需求下使用自定义超时通道

6. 常见陷阱 ⚠️

  1. 向已关闭的 channel 发送数据会导致 panic
  2. 重复关闭 channel 会导致 panic
  3. 在没有接收者的情况下关闭 channel 可能导致 goroutine 泄漏

· 阅读需 4 分钟
wen

1. Slice 是什么?

Slice 是 Go 语言中的一种类似数组的数据结构,是对数组的一个封装。

和数组相比,slice 的长度是可以动态变化的,可以通过内置函数 append 来动态增加切片的长度。

img

2. Slice 的注意事项和常见错误以及陷阱

2.1. 未初始化的切片

未初始化的切片是 nil,对其进行操作会导致运行时错误。

package main

import "fmt"

func main() {
var fruits []string
// fruits[0] = "🍎" // 这会导致运行时错误:panic: runtime error: index out of range [0] with length 0

// 正确做法1:初始化切片
fruits := make([]string, 1)
fruits[0] = "🍎"

// 正确做法2:使用字面量初始化切片
fruits := []string{"🍎"}

// 正确做法3:使用 append 函数初始化切片。(如果不是特别注重性能,这种方式是最简单的。)
fruits = append(fruits, "🍎")
}

2.2. 使用 append 函数时,注意重新赋值

append 函数会在容量不足时重新分配一个更大的底层数组,并将原来的数据复制到新的数组中,然后返回一个新的切片,

所以注意在使用 append 函数时,如果需要使用原来的切片变量,就需要重新赋值。

    fruits := []string{"🍎"}
fruits = append(fruits, "🍌")

2.3. 切片作为函数参数时的引用问题

切片是引用类型,所以在函数参数中传递切片时,实际上是传递了切片的引用。

func modifyFruits(fruits []string) {
fruits[0] = "🍌"
}

func main() {
fruits := []string{"🍎", "🍌"}
fmt.Println("Before modification:", fruits) // 输出: Before modification: [🍎 🍌]

modifyFruits(fruits)
fmt.Println("After modification:", fruits) // 输出: After modification: [🍌 🍌]
}

2.4. 切片的截取

切片的截取操作是左闭右开的,即 a[1:3] 表示从下标 1 开始到下标 3 结束,但不包括下标 3。

    fruits := []string{"🍎", "🍌", "🍇", "🍉"}
fmt.Println(fruits[1:3]) // 输出: [🍌 🍇]

2.5. 切片的长度和容量混淆

切片的长度是指切片中元素的个数,容量是指切片底层数组的长度。

    fruits := make([]string, 2, 5)
fmt.Println("Length:", len(fruits), "Capacity:", cap(fruits)) // 输出: Length: 2 Capacity: 5

// fruits[3] = "🍇" // 这会导致运行时错误:panic: runtime error: index out of range [3] with length 2

// 正确做法:使用 append 来添加元素
fruits = append(fruits, "🍇")

2.6. 切片的复制

切片的复制是浅拷贝,即复制的是切片的引用,而不是切片的底层数组。

这个和函数参数传递的引用是类似的。

    fruits := []string{"🍎", "🍌"}
fruitsCopy := fruits
fruitsCopy[0] = "🍌"
fmt.Println("Original slice:", fruits) // 输出: Original slice: [🍌 🍌]

· 阅读需 3 分钟
wen

在 Go (Golang) 编程中,map 是一种强大且灵活的数据结构,用于存储键值对。

然而,为了确保高效和正确的使用,有几个重要的点需要注意。

本文将详细介绍这些关键考虑因素,并通过示例代码展示如何在 Go 中有效地使用 map。

1. 初始化

在使用 map 之前,必须对其进行初始化。可以使用 make 函数或使用 map 字面量来初始化。

// 使用 make
m := make(map[string]int)

// 使用 map 字面量
m := map[string]int{"🍎": 1, "🍌": 2}

2. Nil Map

nil map空 map 不同。

nil map 不能写入,尝试写入会导致运行时恐慌(panic)。

因此,始终要初始化你的 map。

var m map[string]int // m 是 nil
m["🍎"] = 1 // 这会导致恐慌

3. 从 Map 中读取

从 map 中读取时,如果键不存在,会返回值类型的零值。为了区分缺失的键和实际的零值, 可以使用多返回值的方式。

value, ok := m["🍊"]
if ok {
fmt.Println("找到了:", value)
} else {
fmt.Println("未找到")
}

4. 从 Map 中删除

使用 delete 函数从 map 中移除键值对。在键不存在的情况下调用 delete 是安全的。

delete(m, "🍎")

5. 并发访问

Map 不是并发安全的。如果多个 goroutine 同时访问一个 map,并且至少有一个修改了 map,你必须使用互斥锁或通道来同步访问。

var mu sync.Mutex

mu.Lock()
m["🍎"] = 1
mu.Unlock()

6. 迭代顺序

map 的迭代顺序不能保证在程序的不同运行中保持一致。如果你需要稳定的迭代顺序,必须显式地对键进行排序。

keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}

7. 作为 set 使用

Golang 中没有 set 类型,但可以使用 map 来模拟 set 。只需将值类型设置为 struct{} 即可。

struct{} 是一个空结构,不占用任何内存空间。这样可以节省内存,因为 map 的值是空结构,而不是实际的值。

s := make(map[string]struct{})
s["🍎"] = struct{}{}

// 检查键是否存在
_, ok := s["🍎"]

如果觉得麻烦,可以直接使用第三方库 golang-set

· 阅读需 2 分钟
wen

问题

有 2 个数组,互相可能有重复的元素,如何合并这两个数组并去重?

比如有两个数组:

type User struct {
ID int // ID 作为唯一标识 (ID相同则认为是同一个元素)
Name string
}

old := []User{
{ID: 1, Name: "a"}, // only in old
{ID: 2, Name: "b"}, // 重复
}

new := []User{
{ID: 2, Name: "c"}, // 重复
{ID: 3, Name: "d"}, // only in new
}

合并后的结果应该是:

c := []User{
{ID: 1, Name: "a"}, // only in old
{ID: 2, Name: "c"}, // 重复 (保留 new 中的)
{ID: 3, Name: "d"}, // only in new
}

解决方案(一般化)

package main

import "fmt"

type User struct {
ID int
Name string
}

// contains Check if an element exists in a slice.
// keyFunc is used to uniquely identify the elements.
func contains(slice []any, item any, keyFunc func(any) any) bool {
for _, element := range slice {
if keyFunc(element) == keyFunc(item) {
return true
}
}
return false
}

// mergeSlices Merges two slices and removes duplicates.
//
// keyFunc is used to uniquely identify the elements.
// if an element exists in both old and new, the element in new takes precedence.
// old and new are assumed to have no duplicate elements.
// The order is not guaranteed.
func MergeSlices(old, new []any, keyFunc func(any) any) []any {
var merged []any

// copy new to merged
merged = append(merged, new...)

for _, item := range old {
if !contains(merged, item, keyFunc) {
merged = append(merged, item)
}
}

return merged
}

func main() {
old := []any{
User{ID: 1, Name: "a"},
User{ID: 2, Name: "b"}, // 重複
}
new := []any{
User{ID: 2, Name: "c"}, // 重複
User{ID: 3, Name: "d"},
}

mergedUsers := MergeSlices(old, new, func(item any) any {
return item.(User).ID
})
fmt.Printf("Merged Users:%+v", mergedUsers) // Merged Users:[ {ID:1 Name:a} {ID:2 Name:c} {ID:3 Name:d}]
}

· 阅读需 4 分钟
wen

问题

如何计算字符串中的(所见)字符数是一个常见的问题。

在 Golang 中,我们可以使用utf8.RuneCountInString()函数来计算字符串中的字符数。

中文这样的多字节字符也可以正确计算。

示例

package main

import (
"fmt"
"unicode/utf8"
)

func main() {
str := "abc世界"
fmt.Println(utf8.RuneCountInString(str)) // 5
}

但是如果字符串中包含 👉🏻 这样的 emoji 字符,那么有些情况下这个函数就无法正确计算了。

package main

import (
"fmt"
"unicode/utf8"
)

func main() {
emojiWorld := "🌍"
fmt.Println(utf8.RuneCountInString(emojiWorld)) // 1 ✅ 没有问题

emojiHand := "👉"
fmt.Println(utf8.RuneCountInString(emojiHand)) // 1 ✅ 没有问题

emojiHandBlack := "👉🏿"
fmt.Println(utf8.RuneCountInString(emojiHandBlack)) // 2 ❌ 有问题 期望是 1。 同一种 emoji,但是不同的皮肤颜色的字符数不一样

emojiOne := "1️⃣"
fmt.Println(utf8.RuneCountInString(emojiOne)) // 3 ❌ 有问题 期望是 1。
}

emoji 可以从这里emojipedia复制。

原因

这是因为有些 emoji 是多个 unicode 字符 (Code Points) 组合而成的,而 utf8.RuneCountInString() 函数只会计算 unicode 字符的数量。

术语描述
Bytes(字节)计算数据存储的最小单元,通常是 8 位二进制。
Code Units(编码单元)在编码方案中,用于表示一个字符的固定大小的单元。在 UTF-8 中,一个 Code Unit 是 8 位,而在 UTF-16 中,是 16 位。
Code Points(码点)在 Unicode 标准中,每个字符都被分配一个唯一的代码点,是一个用来标识字符的数字。例如,拉丁字母"A"的代码点是 U+0041。
Grapheme Clusters(字符簇)表示语言中可感知的最小字符单元,通常是一个或多个 Code Points 组成的序列。例如,字母加重音符可能是一个 Grapheme Cluster。

比如 1️⃣ 这个 emoji,它是由 3 个 Code Points 组成的,分别是:

img

Rendered by Markdown Table

提示

Emoji 的 Code Points 可以在这里emojipedia 查看。

解决方案

所以我们需要计算的是 Grapheme Clusters(字符簇)的数量,而不是 Code Points 的数量。

使用第三方库 rivo/uniseg

package main

import (
"fmt"

"github.com/rivo/uniseg"
)

func main() {
emojiWorld := "🌍"
fmt.Println(uniseg.GraphemeClusterCount(emojiWorld)) // 1 ✅ 没有问题

emojiHand := "👉"
fmt.Println(uniseg.GraphemeClusterCount(emojiHand)) // 1 ✅ 没有问题

emojiHandBlack := "👉🏿"
fmt.Println(uniseg.GraphemeClusterCount(emojiHandBlack)) // 1 ✅ 没有问题

emojiOne := "1️⃣"
fmt.Println(uniseg.GraphemeClusterCount(emojiOne)) // 1 ✅ 没有问题
}

补充

其实不只是 emoji,还有一些泰语,阿拉伯语的字符也是由多个 unicode 字符组成的。

Reference

Go で文字数をカウントする 在 Go 中计算字符数

文字数をカウントする 7 つの方法

Go: Unicode と rune 型

· 阅读需 1 分钟
wen

背景

labstack/gommon 的代码中 看到了这个库 valyala/fasttemplate, 于是就去 调查了一下。

提示

labstack 是 Echo Web Framework 的 Organization。

什么是 fasttemplate

fasttemplate 是一个高效的 Go 模板引擎,比 Go 标准库的模板引擎 text/template 快很多,

而且比 strings.Replace, strings.Replacerfmt.Fprintf 都要快。

img

具体可以看一下 fasttemplate 的 benchmark

fasttemplate 的使用

基础用法

    template := "http://{{host}}/?q={{query}}&foo={{bar}}{{bar}}"
t := fasttemplate.New(template, "{{", "}}")
s := t.ExecuteString(map[string]interface{}{
"host": "google.com",
"query": url.QueryEscape("hello=world"),
"bar": "foobar",
})
fmt.Printf("%s", s)

// Output:
// http://google.com/?q=hello%3Dworld&foo=foobarfoobar

高阶用法

    template := "Hello, [user]! You won [prize]!!! [foobar]"
t, err := fasttemplate.NewTemplate(template, "[", "]")
if err != nil {
log.Fatalf("unexpected error when parsing template: %s", err)
}
s := t.ExecuteFuncString(func(w io.Writer, tag string) (int, error) {
switch tag {
case "user":
return w.Write([]byte("John"))
case "prize":
return w.Write([]byte("$100500"))
default:
return w.Write([]byte(fmt.Sprintf("[unknown tag %q]", tag)))
}
})
fmt.Printf("%s", s)

// Output:
// Hello, John! You won $100500!!! [unknown tag "foobar"]

· 阅读需 5 分钟
wen

背景

img

  • 获取两个切片之间的公共元素还是一个比较常见的需求,但是在 Code Review 的过程中,我发现还是会有一些人会用双重循环来实现。(这样的时间复杂度是 O(n^2),效率比较低)
  • 最近 Golang 用的多,顺便分享一下 Golang 中如何获取两个切片之间的公共元素的方法。

❌ 用双重循环实现的代码例子:

func intersection(nums1 []int, nums2 []int) []int {
var result []int

// 双重循环 O(n^2)
for _, v1 := range nums1 { // O(n) 外循环
for _, v2 := range nums2 { // O(n) 内循环
if v1 == v2 {
result = append(result, v1)
}
}
}
return result
}

改善方案

把上面例子中的内循环中的元素查找改成用 set* 来实现,这样内循环部分的时间复杂度就可以降低到 O(1),整体的时间复杂度就可以降低到 O(n)

Javascript 中的 set 解释

Set 对象是值的合集(collection)。集合(set)中的元素只会出现一次,即集合中的元素是唯一的。

规范要求集合的实现是"对合集中的元素的平均访问时间与集合中元素的数量呈次线性关系"。

因此,它可以在内部表示为哈希表(查找的时间复杂度为 O(1))、搜索树(查找的时间复杂度为 O(log(N)))或任何其他的时间复杂度低于 O(N) 的数据结构。

参考链接

set 来实现的例子

func intersection(nums1 []int, nums2 []int) []int {
var result []int

// 把 nums2 转换成 set,这样在 nums2 中查找元素的时间复杂度就变成了 O(1)。
// 考虑性能优化的话,可以把 nums1 和 nums2 中的元素数量进行比较,把数量多的那个切片转换成 set。
set := make(map[int]struct{}) // golang 中的 没有 set,用 map 来实现。struct{} 是一个空结构体,用来节省内存。
for _, v := range nums2 {
set[v] = struct{}{}
}

// 遍历 nums1,如果 nums1 中的元素在 nums2 中存在,就把它加入到 result 中
for _, v := range nums1 {
if _, ok := set[v]; ok {
result = append(result, v)
}
}
}

使用第三方库实现

考虑性能优化以及各种类型的切片,我们可以使用下面的第三方库来实现。

deckarep/golang-set

如其名,Golang 的 set 实现。

import (
"fmt"
mapset "github.com/deckarep/golang-set/v2"
)

func main() {
set1 := mapset.NewSet[string]()
set1.Add("a")
set1.Add("b")
set1.Add("c")

set2 := mapset.NewSet[string]()
set2.Add("c")
set2.Add("d")
set2.Add("e")

// 交集
intersectionSet := set1.Intersect(set2)
fmt.Println(intersectionSet) // Set{c}

// 除了交集,还支持并集、差集、对称差集等操作
// 并集
unionSet := set1.Union(set2)
fmt.Println(unionSet) // Set{a, b, c, d, e}

// 差集
diffSet := set1.Difference(set2)
fmt.Println(diffSet) // Set{a, b}

// 对称差集
symDiffSet := set1.SymmetricDifference(set2)
fmt.Println(symDiffSet) // Set{a, b, d, e}
}

samber/lo

如果你除了要对切片进行交集操作,还需要对切片等进行排序、分组等操作,那么可以考虑使用 samber/lo 这个库。

你可以把它理解成 lodash 的 Golang 版本。

import (
"github.com/samber/lo"
)

func main() {
// 交集
lo.Intersection([]int{1, 2, 3}, []int{2, 3, 4}) // return []int{2, 3}

// 并集
lo.Union([]int{1, 2, 3}, []int{2, 3, 4}) //return []int{1, 2, 3, 4}

// 差集
lo.Difference([]int{1, 2, 3}, []int{2, 3, 4}) // return []int{1}, []int{4}
}

· 阅读需 2 分钟
wen

Symmetric API testing 是啥?

这个概念应该是源自于 Gopher Academy Blog

作者在维护一个 Golang 的 Twitter API 客户端,为了对 Twitter 的 API 进行测试,所以作者提出了 Symmetric API testing 的概念。

简单地说就是保存 API 的返回结果,然后在测试的时候,用保存的结果来进行测试。

这样就不用编写 mock 和 测试用例了。

至于名字为什么叫 Symmetric, 是相对于传统的需要编写 mock 和 测试用例的方式 Asymmetric 而言的。

其实个人觉得把它叫做 SnapShot Testing 更为合适。

怎么实现?

除了手动保存 API 的返回结果,还可以使用 go-vcr 这个库来实现。

大概的代码如下:

r, err := recorder.New("<filename>")
if err != nil {
return err
}
defer r.Stop()
client.Transport = r
res, err := client.Get("http://api.twitter.com/...")
if err != nil {
return err
}

这里提供了完整的 示例代码

Reference

Symmetric API Testing

Symmetric API Testing という、手間なく堅牢に外部 API Client をテストする手法

go-vcr を使った Symmetric API Testing のメモ

· 阅读需 3 分钟
wen

背景&需求

在 Golang 中,我们经常会遇到需要解析 JSON 数据的场景,比如从 HTTP 请求中获取 JSON 数据,或者从文件中读取 JSON 数据。

通常我们会提前定义好对应的结构体,然后才能将 JSON 数据解析到结构体中。

比如:

type User struct {
Name string `json:"name"`
Age int `json:"age"`
}

func main() {
jsonStr := `{"name": "wen", "age": 18}`
var user User
json.Unmarshal([]byte(jsonStr), &user)
fmt.Println(user)
}

但是有时候我们并不知道 JSON 数据的结构,或者 JSON 数据的结构会经常变化,这时候我们就无法提前定义好对应的结构体。

解决方案

可以使用 map[string]any (Golang1.18 之前的话 map[string]interface{} ) 来解析 JSON 数据,这样就不需要提前定义结构体了。

func main() {
jsonStr := `{"name": "wen", "age": 18}`
var user map[string]any
json.Unmarshal([]byte(jsonStr), &user)
fmt.Println(user)

// 获取具体的值
fmt.Println(user["name"])
fmt.Println(user["age"])
}

扩展

如果觉得 map[string]any 这种方式解析速度比较慢,可以使用 jsonparser 这个库来解析,速度会快很多。

我用 User 结构体来测试了一下,解析速度快了 8-9 倍左右 🚀

其他的比较大的 JSON 数据,解析速度也会快很多,具体可以看下这里的 benchmark

NameIterationsns/op
BenchmarkEncodingJsonInterfaceUser-122540230460.6 ns/op
BenchmarkJsonParserUser-122141329655.91 ns/op
查看测试代码
// Just for emulating field access, so it will not throw "evaluated but not used"
func nothing(_ ...interface{}) {}

// 使用 jsonparser
func BenchmarkJsonParserUser(b *testing.B) {
for i := 0; i < b.N; i++ {
jsonparser.Get(user, "name")
jsonparser.Get(user, "age")
nothing()
}
}

// 使用 map[string]any
func BenchmarkEncodingJsonInterfaceUser(b *testing.B) {
for i := 0; i <details b.N; i++ {
var data interface{}
json.Unmarshal(user, &data)
m := data.(map[string]interface{})

nothing(m["name"].(string), m["age"])
}
}

· 阅读需 4 分钟
wen

需求

  • Golang 中将结构体的数组保存到 CSV 文件中
  • CSV 文件的列顺序和结构体中的字段顺序不同
user.go
type User struct {
Name string
Age int
}

↓↓↓

AgeName
20"Musk"

调查

比较有人气的的 Golang CSV 库有:

但是两者都不支持指定结构体字段的顺序,而且也没有提供相应的 writer 接口,所以也无法自己覆盖接口实现。

本来想 fork 上面的其中一个库改造下,但搜索发现了 shigetaichi/xsv 这个库,在 gocsv 的代码的基础上实现了指定结构体字段顺序的功能。

字段的顺序可以通过 xsvSortOrder 属性指定一个数组,数组中的值是结构体中相应字段的索引(starting from 0)。

比如, 如果想要将结构体按照 Age, Name 的顺序输出,可以这样指定:

type User struct {
Name string `csv:"name"`
Age int `csv:"age"`
}

xsvWrite.SortOrder = []int{1, 0}

但是个人觉得字段顺序的指定方式不太友好。 既然已经有了 csv tag,为什么不直接使用 csv tag 来指定字段的顺序呢?

比如:

type User struct {
Name string `csv:"name,order:1"`
Age int `csv:"age,order:0"`
}

我的方案

代码

代码
package main

import (
"fmt"
"log"
"os"
"reflect"
"strconv"
"strings"

"github.com/shigetaichi/xsv"
)

type User struct {
Name string `csv:"name, order:1"`
Age int `csv:"age, order:0"`
}

func main() {
users := []*User{
{Name: "Alice", Age: 20},
{Name: "Bob", Age: 30},
}

// Create a csv file to write
file, err := os.OpenFile("users.csv", os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)
if err != nil {
panic(err)
}
defer file.Close()

xsvWrite := xsv.NewXsvWrite[*User]()
// Get the order of the fields in the struct and set it to the writer
orders := getOrderOfFields(reflect.TypeOf(User{}))
xsvWrite.SortOrder = orders

// Write the users to the csv file
err = xsvWrite.SetFileWriter(file).Write(users)
if err != nil {
log.Println(err)
return
}
}

// Get the order of the fields in specified struct
func getOrderOfFields(structType reflect.Type) []int {
// Create a slice of int with the same length as the number of fields in the struct
var res []int
// Iterate through the struct fields
for i := 0; i < structType.NumField(); i++ {
// Get the field
field := structType.Field(i)

// Get the "order" tag value
order := getTagValue(field, "csv", "order")
if order >= 0 {
res = append(res, order)
}
}

return res
}

// getTagValue は指定されたフィールドの指定されたタグの値を取得する
func getTagValue(field reflect.StructField, tag string, tagField string) int {
tagValue, _ := field.Tag.Lookup(tag)
// Split the tag string by ","
tagParts := strings.Split(tagValue, ",")

// Iterate through the tag parts to find the "order" value
res := -1
for _, part := range tagParts {
part = strings.TrimSpace(part)
// Check if the part starts with "order:"
prefix := fmt.Sprintf("%s:", tagField)
if strings.HasPrefix(part, prefix) {
// Extract the numeric value after "order:"
valueStr := strings.TrimPrefix(part, prefix)
value, err := strconv.Atoi(valueStr)
if err == nil {
res = value
}
break
}
}

return res
}

保存的 CSV 文件

agename
20Alice
30Bob