经验分享合集
title: "经验分享合集"
weight: 1
# bookFlatSection: false
# bookToc: true
# bookHidden: false
# bookCollapseSection: false
# bookComments: false
# bookSearchExclude: false

[TOC]

Go部分 #

xorm #

  1. 若使用纯 go 版本的 sqlite 驱动 github.com/glebarez/go-sqlite 需要注意 xorm 默认的驱动没有 sqlite 类型,需要手动 RegisterDriver ,示例如下:
package main

import (
	"fmt"
	_ "github.com/glebarez/go-sqlite"
	"github.com/go-xorm/xorm"
	"strings"
	"xorm.io/core"
)

type sqlite3Driver struct {}

func (p *sqlite3Driver) Parse(driverName, dataSourceName string) (*core.Uri, error) {
	if strings.Contains(dataSourceName, "?") {
		dataSourceName = dataSourceName[:strings.Index(dataSourceName, "?")]
	}

	return &core.Uri{DbType: core.SQLITE, DbName: dataSourceName}, nil
}

func StartupGlebarez() {
	var dbName = "test"

	core.RegisterDriver("sqlite", &sqlite3Driver{})
	db, err := xorm.NewEngine("sqlite", dbName)
	if err != nil {
		fmt.Println("new Engine err: ", err)
		return
	}

	err = db.Sync2(struct {
		Name string
	}{})
	if err != nil {
		fmt.Println("Sync2 err: ", err)
		return
	}

	fmt.Println("Sync2 ok")
}
  1. AllCols() 方法更新不了数据
var device model.Device // 需要入库的值
engine.AllCols().Update(&device, &model.Device{Cid: device.Cid, Eid: device.Eid})

打印 sql 发现:原来 AllCols 方法将我的条件信息也全部都作为 WHERE 条件给解析成 SQL 语句了!

解决方式:

  • 根据主键 id 更新 engine.Id(xxx).AllCols().Update(&device)
  • where 条件不要再用原结构体,直接使用 map 进行赋值更新 engine.AllCols().Update(&device, map[string]interface{}{"cid": device.Cid, "eid": device.Eid})

gorm #

go build 报错

gorm.io/plugin/dbresolver@v1.2.1/dbresolver.go:139:18: cannot use map[string]gorm.Stmt{} (value of type map[string]gorm.Stmt) as type map[string]*gorm.Stmt in struct literal

解决方案:执行 go get gorm.io/plugin/dbresolver@latestgorm.io/plugin/dbresolver 升级到最新版本。

一般情况下,开源库都是向前兼容的,遇到了类似的记得看下版本即可,如果用的老版本没有什么问题,不升级也行。

设置 http 请求超时 #

先看下面的一个例子,设置了该请求 1ms 超时,请问是否 panic 了

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func main() {
	req, err := http.NewRequest(http.MethodGet, "https://www.baidu.com", nil)
	if err != nil {
		panic(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
	defer cancel()
	req.WithContext(ctx)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}

	//resp.Write(os.Stdout)
	fmt.Println("end: ", resp.StatusCode)
}

结果是程序正常执行退出了,难道访问这么快,1ms 就结束了?其实是 req.WithContext 这里出问题了,这个方法会复制一个新的 req 出来,需要这样:

req = req.WithContext(ctx)

interface/any 的 nil 判断 #

interface 的底层结构,分为两种:包含 method 的 iface 和不包含 method 的 eface,也就是 empty interface。当我们判断一个 interface 的值是否为 nil 时,需要这个值的动态类型和动态值都为 nil 时,这个 interface 才会是 nil。

示例代码

package main

import(
	"encoding/json"
	"fmt"
)

type dataWrapper struct {
	data any
}

func convert(v any) *dataWrapper {
	d := new(dataWrapper)
	d.data = v
	return d
}

type sureData struct {
	Name string
}

func (d *dataWrapper) sureData() *sureData {
	buf, _ := json.Marshal(d.data)
	data := new(sureData)
	json.Unmarshal(buf, data)
	return data
}

type oldData struct {
	Name string
}

func main() {
	var data *oldData 
	fmt.Println("is nil: ", data == nil) // true

	sd := convert(data).sureData()
	fmt.Println("is nil: ", sd == nil) // false

	if sd == nil {
		// 逻辑代码
	} else {
		// 逻辑代码
	}
}

由于该代码仓库是为了让其他项目使用,基于之前的老项目抽离出来的,老项目的结构体和新项目不同,但是字段都是一样的,要进行结构体转换,偷懒用 json 的 Marshal 和 Unmarshal 来做的(这种方式不认同,对调用方不友好,而且效率还差)。

这里的 dataWrapper 就是进行结构体转换的一个封装,最终使用 sureData 方法获取真正的结构体数据。

代码简单,可以看到这里的 sureData 方法获取的数据肯定不为空,因为它在方法里做了 new(sureData) 了,返回的结构体肯定不为空。

代码看到这里,想要使其能正确地判断 nil,对 sureData 方法进行了如下修改(当然只是做示例用,真实场景中不推荐):

func (d *dataWrapper) sureData() *sureData {
	if d.data == nil {
		return nil
	}
	buf, _ := json.Marshal(d.data)
	data := new(sureData)
	json.Unmarshal(buf, data)
	return data
}

但是运行查看输出结果和刚刚没区别,就是因为 interface 的动态值为空,而动态类型不为空。

is nil:  true
is nil:  false

可以模拟 eface 的结构来进行 nil 判断。

type eface struct {
	rtype unsafe.Pointer
	data  unsafe.Pointer
}

func IsNil(obj any) bool {
	return (*eface)(unsafe.Pointer(&obj)).data == nil
}

module 名称与 GitHub/Gitlab 地址不同时的引用方式(module declares its path as: github.com/someone/repo ) #

两个引用错误场景:

  1. 我在 GitHub 上发现了优秀的开源库,fork 到自己的仓库改了下,希望用到自己的项目中。
  2. 我本地写了一个特牛的插件(module 名称是 utils 而不是 gitlab.com/xxx/utils),想分享给在座的各位,通常公司内部都会有方便快速开发的公共库,这时需要将该库推送到 gitlab 仓库中。

发现使用 module 直接引用仓库都会报错。

module declares its path as: github.com/someone/repo
        but was required as: github.com/you/repo

module declares its path as: utils
        but was required as: gitlab.com/xxx/utils

go.mod 中 replace 一下即可

replace github.com/someone/repo => github.com/you/repo latest

gin 框架设置 header 需要注意大小写 #

go gin 框架中使用 c.Header(“new-token”, “123”) 设置 http header 键值的时候,设置的 new-token 键会变成 New-Token !!!

查看 go 源码 go\src\net\textproto 的 CanonicalMIMEHeaderKey(s string) string 方法中,如下:

// CanonicalMIMEHeaderKey returns the canonical format of the
// MIME header key s. The canonicalization converts the first
// letter and any letter following a hyphen to upper case;
// the rest are converted to lowercase. For example, the
// canonical key for "accept-encoding" is "Accept-Encoding".
// MIME header keys are assumed to be ASCII only.
// If s contains a space or invalid header field bytes, it is
// returned without modifications.
func CanonicalMIMEHeaderKey(s string) string {
	commonHeaderOnce.Do(initCommonHeader)

	// Quick check for canonical encoding.
	upper := true
	for i := 0; i < len(s); i++ {
		c := s[i]
		if !validHeaderFieldByte(c) {
			return s
		}
		if upper && 'a' <= c && c <= 'z' {
			return canonicalMIMEHeaderKey([]byte(s))
		}
		if !upper && 'A' <= c && c <= 'Z' {
			return canonicalMIMEHeaderKey([]byte(s))
		}
		upper = c == '-'
	}
	return s
}

可以看到,就是在这里将我们赋给 header 的键 new-token 给改成大写的 New-Token 的。

sync.Map Range 的同时进行 Store,Range 的遍历结果 #

Range 和 Store 异步,能够遍历到后添加的数据吗?带着这个问题,翻了下源码,简单了解其原理。先说结论:可能会遍历到 Store 添加的数据的。

测试代码

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	type cans struct {
		t int
		v any
	}
	var c = make(chan cans, 10000)

	m := new(sync.Map)
	m.Store("a", "a")
	m.Store("b", "b")
	m.Store("c", "c")

	var ccc = make(chan struct{})
	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := 0; i < 1000; i++ {
			if i == 1 {
				ccc <- struct{}{}
			}
			m.Store(i, i)
			c <- cans{1, i}
		}
		fmt.Println("store end")
	}()
	wg.Add(1)
	go func() {
		<-ccc
		defer wg.Done()
		// range 的时候会判断是否有 dirty 数据,有的话也会去竞争锁,当拿到锁的那一刻,会清空 dirty 同步 read 然后会进入 for 去回调
		m.Range(func(key, value any) bool {
			c <- cans{2, key}
			return true
		})
		fmt.Println("range end")
	}()

	wg.Wait()
	close(c)

	fmt.Println("channel 长度:", len(c))

	for v := range c {
		fmt.Println(v.t, ": ", v.v)
	}

	fmt.Println("end")
}

go mod tidy 出错:create zip: module source tree too large (max size is 524288000 bytes) #

仓库太大了,已经超过 500M 了,一般是代码仓库中放了大文件,将其删除,缩小仓库体积即可。

go 官方对 module 做大小限制,也是通过多方考虑的。个人认为这个限制还是有点用的,大多数的包应该都不会有这么大,多数大包可能都是不注意的提交导致的。

遇到该问题时,我们应该首先问问自己:

  • 源代码可以再压缩下吗?
  • 包可以拆分吗?
  • 数据是否一定要放在包中?

go mod 引用出错问题(ambiguous import: found github.com/ugorji/go/codec in multiple modules) #

github.com/ugorji/go 这个库的 codec 目录下也有个 go.mod 文件,go 把 github.com/ugorji/go/codec 和 github.com/ugorji/go 这两个 path 当成不同的模块引入导致的冲突。

解决方式:将 github.com/ugorji/go v1.1.4 的引用升级到 v1.2.6。

使用 interface 转换时间戳需要注意 int64 -> float64 #

func tJsonTimestamp() {
	type test struct {
		Timestamp int64
	}
	var data = test{time.Now().Unix()}
	buf, _ := json.Marshal(data)

	var data2 = struct{ Timestamp interface{} }{}
	json.Unmarshal(buf, &data2)
	fmt.Println(reflect.TypeOf(data2.Timestamp)) // float64

	var resMap = make(map[string]interface{})
	if er := json.Unmarshal(buf, &resMap); er != nil {
		fmt.Println(er)
		return
	}

	t, ok := resMap["Timestamp"]
	if !ok {
		return
	}

	fmt.Println(reflect.TypeOf(t)) // float64
}

时间相关的前后端交互 #

  1. json 转换时间(time.Time)的格式(默认格式为 RFC3339)

注意 go 和 js 的默认时间格式是不同的,需要注意交互转换。

go 使用的是国际标准 RFC3339 (2006-01-02T15:04:05Z07:00) 格式来作为默认时间格式进行解析的。

而 js 并不能解析这种字符串 new Date("2006-01-02T15:04:05"),需要转换下。

// RFC3339转为标准格式日期
var toTime = function(dateStr) {
    var date = new Date(dateStr).toJSON();
    return newDate=new Date(+new Date(date)+8*3600*1000).toISOString().replace(/T/g,' ').replace(/\.[\d]{3}Z/,'');
}

var toRFC3339 = function(date){
    let y = date.getFullYear()
    let m = date.getMonth()+1<10?'0'+(date.getMonth()+1):(date.getMonth()+1)
    let d = date.getDate()<10?'0'+date.getDate():date.getDate()
    let hh = date.getHours()<10?'0'+date.getHours():date.getHours();            
    let mm = date.getMinutes()<10?'0'+date.getMinutes():date.getMinutes()
    let ss = date.getSeconds()<10?'0'+date.getSeconds():date.getSeconds()
    var endDate = y +'-' + m + '-' + d + ' ' + hh + ':' + mm + ':' + ss
    endDate = endDate.replace(/\s+/g, 'T')+'+08:00'
    return endDate
}
  1. 时间戳 go 中 Unix 得到的是秒,而 js 中 new Date(xxx) 的是毫秒,要注意下毫秒在 go 中需要使用 UnixMilli

通过channel异步读写切片,切片内容不符预期 #

注意切片是引用传值,一定要留意!

示例代码

package main

import (
	"os"
	"bufio"
	"io"
	"fmt"
)

func main() {
	err := ddImage(`E:\大文件.txt`, ``)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("程序执行完毕")
}

type Resource struct {
	Index           uint64
	Buffer          []byte
	Size            int
	Err             error
	RefreshedBuffer []byte
}

func ddImage(ddPath, ddDstPath string) error {
	reader, err := os.Open(ddPath)
	if err != nil {
		return err
	}
	defer reader.Close()
	br := bufio.NewReader(reader)

	//writer, err := os.OpenFile(ddDstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm)
	//if err != nil {
	//	return err
	//}
	//defer writer.Close()

	const bufferSize int64 = 1024 * 1024
	chanCount := 30
	writeChan := make(chan *Resource, chanCount)

	go readBufioData(br, bufferSize, writeChan)

	for {
		data := <-writeChan

		if data.Err != nil && data.Err != io.EOF {
			return data.Err
		}
		if data.Size > 0 {
			if !CompareSlice(data.Buffer, data.RefreshedBuffer) {
				fmt.Printf("Buffer:%p, RefreshedBuffer:%p\n", data.Buffer, data.RefreshedBuffer)
				fmt.Printf("&Buffer:%p, &RefreshedBuffer:%p\n", &data.Buffer, &data.RefreshedBuffer)
				return fmt.Errorf("两个slice不同了")
			}
			//if _, err = writer.Write(data.Buffer); err != nil {
			//	return err
			//}
		}

		if data.Err == io.EOF {
			return nil
		}
		if data.Err != nil {
			return data.Err
		}
	}
}

func readBufioData(reader *bufio.Reader, bufferSize int64, compChan chan *Resource) {
	buf := make([]byte, bufferSize)
	for {
		refreshedBuf := buf
		n, err := reader.Read(buf)

		resource := new(Resource)
		resource.Size = n
		resource.Buffer = buf[:n]
		resource.Err = err
		resource.RefreshedBuffer = refreshedBuf[:n]

		compChan <- resource

		if err != nil {
			if err != io.EOF {
				fmt.Println(err)
			}
			break
		}
	}
	fmt.Println("read exit")
}

func CompareSlice(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}

	if (a == nil) != (b == nil) {
		return false
	}

	for key, value := range a {
		if value != b[key] {
			return false
		}
	}

	return true
}

可能会有两种结果:

  1. 没有 error 输出
read exit
程序执行完毕
  1. error 了
Buffer:0xc000092000, RefreshedBuffer:0xc000092000
&Buffer:0xc0001940f8, &RefreshedBuffer:0xc000194128
两个slice不同了

当 resource 通过channel传输的时候,虽然每个resource都是新分配地址的值,但是 resource.Buffer 却指向的仍然是原始的buf的地址。

所以,在channel的接收端,会出现 Buffer 和 RefreshedBuffer 不相等的情况,即:由于channel是带缓存的,channel的缓存还未及时读完,之前的 Buffer 切片内容已经被修改,导致channel缓存中的 Buffer 都变成了最新的输入端的 buf 值。

包循环引用 (import cycle not allowed) #

一般来说,出现这种情况都是模块设计没考虑好,最好是要改动代码架构。如果改动实在太麻烦,可以这样处理:

A <-> B 循环引用,A 调用了 B 的 SetSB() 函数,B 调用了 A 的 SetSA() 函数

A 注册一个方法到 B。

目录结构

- A
	A.go
- B
	B.go
main.go

A.go

package A

import "../B"

func init() {
	B.RegisterSetSAEvent(SetSA)
}

func UseB() {
	B.SetSB("A use B")
}

var a string
func SetSA(value string) {
	a = value
}
func GetSA() string {
	return a
}

B.go

package B

var b string
func SetSB(value string) {
	b = value
}
func GetSB() string {
	return b
}

var SetSAEventHandler func(value string)

func RegisterSetSAEvent(f func(value string)) {
	SetSAEventHandler = f
}

func UseA() {
	if SetSAEventHandler != nil {
		SetSAEventHandler("B USE A")
	}
}

main.go

package main

import (
	"./A"
	"./B"
	"fmt"
)

func main() {
	A.UseB()
	B.UseA()
	
	fmt.Println(A.GetSA())
	fmt.Println(B.GetSB())
}

字符串 string 的并发读写 #

会存在并发读写问题,有可能会 panic

string 内部结构是 struct,有指向具体值的指针和长度。

struct {
	str uintptr
	len int
}
package main

import (
	"fmt"
	"time"
)

const (
	FIRST  = "WHAT THE"
	SECOND = "F*CK"
)

func main() {
	var s string
	go func() {
		i := 1
		for {
			i = 1 - i
			if i == 0 {
				s = FIRST
			} else {
				s = SECOND
			}
			time.Sleep(10)
		}
	}()

	for {
		if s == "WHAT" {
			panic(s)
		}
		fmt.Println(s)
		time.Sleep(10)
	}
}

运行程序是有可能 panic 的,简单解释一下原因:

对于这样一个 struct ,go 无法保证原子性地完成赋值,因此可能会出现goroutine 1 刚修改完指针(str)、还没来得及修改长度(len),goroutine 2 就读取了这个string 的情况。

因此我们看到了 “WHAT” 这个输出 —— 这就是将 s 从 “F*CK” 改成 “WHAT THE” 时,str 改了、len 还没来得及改的情况(仍然等于4)。对于这样一个 struct ,golang 无法保证原子性地完成赋值,因此可能会出现goroutine 1 刚修改完指针(str)、还没来得及修改长度(len),goroutine 2 就读取了这个string 的情况。

map 遍历删除注意内存释放 #

//#region 测试 delete 将 map 清空 / 设置 map 为 nil 后,内存的变化。delete 不能清理内存,设置为 nil 后,gc 会去主动清理内存。

// 放在堆里的map,要注意它的内存释放,即使 map 中的数据都被删除后也要注意。
// 解决方法:
// 1. map 设置为 nil
// 2. 定期将 map 的元素全量拷贝到另一个 map 中
// 3. 将 map 的 value 设置为指针类型的值,可以缩减一些内存消耗
// 4. 重启
var intMap map[int]int

var cnt = 8192

func tMapFree() {
	printMemStats()
	initMap()
	runtime.GC()
	printMemStats()
	log.Println(len(intMap))
	for i := 0; i < cnt; i++ {
		delete(intMap, i)
	}
	log.Println(len(intMap))
	runtime.GC()
	//debug.FreeOSMemory()
	printMemStats()
	intMap = nil
	runtime.GC()
	printMemStats()
}

func initMap() {
	intMap = make(map[int]int, cnt)
	for i := 0; i < cnt; i++ {
		intMap[i] = i
	}
}

func printMemStats() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	log.Printf("Alloc = %v TotalAlloc = %v Sys = %v NumGC = %v\n", m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC)
}

//#endregion

function arguments too large for new goroutine #

当前程序中有一段创建任务的代码,由于之前任务逻辑不复杂,任务存储的结构体字段较少。后续业务越来越复杂,结构体嵌套结构体得到了一个非常大的结构体Task。然后启动新协程的时候,copy了一份task的副本,导致参数超过了新goroutine的可用堆栈空间。 goroutine默认分配2k的内存。

func CreateTask() {
	//初始化任务信息,大量的逻辑代码
	// ......
	var task model.Task
	err = setTaskInfo(&params, &task)
	unsafe.Sizeof(task) // 这时task的大小已超过了2k了

	// 直接panic了
	go RecordTaskStartLogInfo(task)
	// 下面两种方式没有该问题,后续若修改原task内容无影响,建议传指针
	/*go func(){
		RecordTaskStartLogInfo(task)
	}()
	go RecordTaskStartLogInfo(&task)*/

	return
}

切片初始化 #

切片高效操作的要点是要降低内存分配的次数,尽量保证append操作不会超出cap的容量,降低触发内存分配的次数和每次分配内存大小。

如果切片大小已有预计,可以在初始化的时候定义一下。

s := make([]string, 0, 20)

sqlite 数据库 “database is locked“ 异常处理 #

SQLite只支持一写多读。SQLite在进行写操作时,数据库文件会被锁定,此时任何其他的读/写操作都会被阻塞,如果阻塞超过5秒钟(默认是5秒,可通过重新编译SQLite进行修改),就会抛出描述为“database is locked”的异常。

解决方案:自己在程序上加读写锁。

package main

import (
	"github.com/go-xorm/xorm"
	"sync"
)

type Database struct {
	mutex  sync.RWMutex
	engine *xorm.Engine
}
func (this *Database) Insert(beans ...interface{}) (int64, error) {
	this.mutex.Lock()
	defer this.mutex.Unlock()
	return this.engine.Insert(beans...)
}
func main() {
	db := &Database{}
	engine, err := xorm.NewEngine("sqlite3", "test.db")
	if err != nil {
		return
	}
	
	db.engine = engine

	db.Insert()
}

svg 图片展示 #

服务端存储了 svg 格式的图片,直接将内容返回给前端,img 标签无法渲染,需要设置 Content-Type

c.Header("Content-Type", "image/svg+xml")

1、slice不能使用nil来判断是否为空,必须使用len判断 #

s := []string{}
if s == nil { // 错误判断方式
    println("空切片")
}

if len(s) == 0 {
    println("空切片")
}

2、golang默认的结构体json转码出来,都是根据字段名生成的大写驼峰格式,如果要使用小驼峰或下划线,需要指定json序列化的标签 #

type typeA struct {
    NameSpace string `json:"name_space"`
    NameFirst string `json:"nameFirst"`
}

3、go 循环中需要通过下标才能修改原数组中结构体的值 #

s := []typeA{
    {"a1", "a2"},
    {"b1", "b2"},
}
for i := range s {
    s[i].NameSpace = "space"
}

4、go中遍历map时,可以删除指定的元素 #

m := make(map[string]int)
m["a"] = 1
m["b"] = 2
for k, _ := range m {
    delete(m, k)
}

5、捕捉panic需要在方法中执行 #

func main() {
    defer func() { recover() }() //正确的使用方式

    defer recover() //错误的使用方式

    m := make(map[string]int)
    m["a"] = 1
    m["b"] = 2
    for k, _ := range m {
        delete(m, k)
    }
}

6、golang 没有继承只有组合、更没有重载 #

type People struct{}

func (p *People) ShowA() {
    fmt.Println("showA")
    p.ShowB()
}
func (p *People) ShowB() {
    fmt.Println("showB")
}

type Teacher struct {
    People
}

func (t *Teacher) ShowB() {
    fmt.Println("teacher showB")
}
func main() {
    t := Teacher{}
    t.ShowA()
}

上面的代码输出结果为showA、showB

1 goroutine生命周期管理问题,比如kill问题。 #

Go语言没有提供直接杀死 goroutine的机制,但可以通过上下文控制(context)来达到取消或终止的效果。context包提供了一种优雅的方式来传递取消信号,通常用于管理 goroutine的生命周期,尤其是在需要在父任务中终止部分子任务的情况下。

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("Worker %d is done\n", id)
			return
		default:
			// 模拟一些工作
			fmt.Printf("Worker %d is working\n", id)
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	// 创建一个上下文和取消函数
	ctx, cancel := context.WithCancel(context.Background())

	// 启动多个goroutine
	for i := 0; i < 3; i++ {
		go worker(ctx, i)
	}

	// 让goroutines运行一段时间
	time.Sleep(2 * time.Second)

	// 取消所有goroutine
	cancel()

	// 等待goroutine退出
	time.Sleep(1 * time.Second)
	fmt.Println("All workers are done")
}

1.1 goroutine的启动与退出

goroutine的生命周期从启动到退出,通常由两部分决定:

  • 启动 :通过 go func()语法启动一个新的 goroutine
  • 退出goroutine的退出通常依赖于:
  • 执行完函数的主体。
  • 函数内逻辑判断(例如某些条件满足时提前返回)。
  • 接收到通知或信号(例如通过通道的方式)。

1.2 goroutine泄漏问题

如果一个 goroutine无法终止,系统上线后可能会在内存中无限存在,从而导致 goroutine泄漏。这种泄漏通常发生在以下场景:

  • goroutine正在等待永远不会接收到的通道数据。
  • 进入了死循环。
  • 没有合理处理退出信号或错误。

1.3 如何避免泄漏

  • 使用通道(channel)通知退出 :确保 goroutine能够及时接收到退出信号并优雅地退出。
  • 上下文控制 :如前面提到的,使用 context管理生命周期。
  • 超时控制 :防止 goroutine无限期地等待操作。
  • 合理的资源监控 :通过 runtime.NumGoroutine()来获取当前系统中 goroutine的数量,并对其进行监控和日志记录。

1.3.1 示例:通过通道通知退出

package main

import (
	"fmt"
	"time"
)

func worker(stopChan chan struct{}) {
	for {
		select {
		case <-stopChan:
			fmt.Println("Received stop signal, worker exiting")
			return
		default:
			fmt.Println("Worker is doing work")
			time.Sleep(1 * time.Second)
		}
	}
}

func main() {
	stopChan := make(chan struct{})
	go worker(stopChan)

	// 运行一段时间后通知退出
	time.Sleep(3 * time.Second)
	close(stopChan)

	// 等待worker退出
	time.Sleep(1 * time.Second)
}

这里,stopChan用于通知 worker退出,避免了 goroutine泄漏的问题。

1.3.2 示例:使用 sync.WaitGroup

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // 保证退出时调用Done()
	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(time.Second)
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	// 等待所有goroutine完成
	wg.Wait()
	fmt.Println("All workers finished")
}

WaitGroup确保所有 goroutine都完成后,主程序才能继续运行。

1. cgo中字符编码的问题 #

这是写cgo代码时经常会遇到的问题了,golang默认使用UTF-8字符编码,所以在将go的字符数据传给c函数调用时,要根据本机系统设置进行相应转码,否则会有程序崩溃问题。

1. CGO中字符串传递的字符编码问题 #

Go中的字符串均是UTF-8编码,而C的程序默认字符编码是跟着当前操作系统的环境走,即在中文环境下的Windows环境默认字符编码为宽字符(UTF16),因此在CGO中使用C库或者写C代码需要注意字符串的编码问题

比如:在动态库加载中因为使用到了Windows.h库的函数LoadLibrary(),这个windows系统级别的函数需要宽字符编码的字符串,所以在Go中需要将UTF8转成本地编码,再调用函数

/*
#include <stdlib.h>

#ifdef _WIN32
	#include <windows.h>
	HMODULE avp_dll;

	int init_avp(const char* dll_path) {
		avp_dll	= LoadLibrary(dll_path);
		......
	}
#endif
*/
import "C"

func (ffTool *FFmpegAvp) Init(avpDllPath ...string) error {
	// 需要转成本地编码(宽字符UTF16)
	gAvpDllPath, err := common.UTF8PathToLocal(avpDllPath[0])
	...
}

2. CGO内存管理问题 #

Go使用垃圾回收,而C语言中的内存管理通常是手动管理的,这就引入了两个主要问题:

  • 内存泄漏

    由于C中的内存需要手动释放,而Go使用的是垃圾回收,所以在跨语言调用时如果不小心,可能会导致内存泄漏。常见的解决方案是在Go中使用defer释放C分配的内存,或者显式地调用C函数进行清理。

    例如:

    cStr := C.CString("hello")
    defer C.free(unsafe.Pointer(cStr))
    
  • 悬空指针

    Go的垃圾回收机制会在变量失效时自动回收其内存。如果你在C函数中持有指向Go数据的指针,而Go变量已经被回收,这将导致悬空指针,进而可能导致崩溃。Go中的数据在传递给C时要确保使用对应的CGO类型转换函数。

    不要直接传递Go的指针给C函数:

    var goStr string = "hello"
    // 错误示例:直接传递Go指针到C代码,Go的内存管理可能会回收此内存
    C.some_c_function(unsafe.Pointer(&goStr))
    

3. CGO跨平台问题 #

CGO代码可能需要跨平台支持(例如,在Windows、Linux上运行)。在不同平台上,C库的API可能有所差异,需要为不同平台编写不同的CGO代码,需要链接的库也不一样,具体每个平台缺少的库根据编译的报错信息去补全即可

4. 使用CGO的性能问题 #

CGO会引入额外的性能开销,尤其是在频繁的跨语言调用中。为了减少性能损失

解决方案:

  • 减少Go和C之间的频繁调用,尽量合并操作。
  • 如果需要处理大量数据,可以在C代码中尽量批量处理。

5. Go中切片避免高频的扩容操作 #

切片类型的对象尽可能用make创建,并设置适合的cap容量,因为append操作可能触发扩容,如果切片的容量不足,Go会重新分配内存,导致底层数组地址发生变化。当切片的容量足够时,append只增加长度,底层数组地址保持不变。

解决方案:

  • 使用字面量或make初始化切片时,长度和容量的关系影响切片行为,需要注意扩容时底层数组的地址变化,以避免潜在的性能问题。

6. 超大数组切片导致的内存泄漏 #

Go的切片虽然是引用类型,但其底层数组不会自动收缩。即使缩短了切片的长度,底层数组仍然保留之前的容量,导致潜在的内存泄漏,特别是在处理非常大的数组时。

解决方案:

  • 在需要缩减内存占用时,使用copy创建新的切片之后释放多余的底层数组。

7. map并发写入问题 #

在Go中,map并不是并发安全的。如果多个goroutine同时对同一个map进行读写操作,可能会引发运行时崩溃。

解决方案:

  • 使用sync.Map(里面的接口是并发安全的)
  • 加锁来保证并发安全,如:sync.Mutex

8. for-range不适用在元素较大的情况 #

当切片或数组的元素较大时,for-range可能导致性能问题。原因是for-range会复制每个元素,并将其赋值给循环变量,而不是直接对切片中的元素进行引用。

解决方案:

  • 对于较大的元素,这种复制操作会增加内存开销和性能消耗,需要更换成按索引的for循环遍历方式。

9. defer执行顺序和作用域陷阱 #

defer语句的执行顺序是先进后出(LIFO),并且在函数返回前触发。如果多个defer语句在同一函数中被调用,错误的执行顺序可能会导致资源泄漏或逻辑错误。

解决方案:

  • 谨慎使用多个defer,尤其是在涉及复杂资源管理时,确保它们按正确的顺序执行。

10. 跨平台中的路径分隔符问题 #

在跨平台开发中,Windows和Linux使用不同的文件路径分隔符。Go中的文件操作函数如果不处理路径分隔符,会导致跨平台代码执行失败。

解决方案:

  • 使用filepath.Join等标准库函数来处理路径分隔符,确保代码在不同操作系统上能正常运行。

  • 为保证相同的路径都能够统一输出相同格式的字符串,可以使用下面的方式统一格式

    filepath.ToSlash(filepath.Clean(filePath))
    

Python部分 #

  1. python requests 库不传代理时默认使用系统代理,requests 库会读取 os.environ 中的代理信息,在环境变量中增加一个以下内容即可避免使用系统代理 os.environ[’no_proxy’] = ‘*’

  2. execjs在电脑未装nodejs或本地node版本与调用的模块不兼容时调用execjs会导致报错,通过local_node_runtime._binary_cache 指定node位置,添加cwd 指定模块位置,参考饿了么插件

其它 #

ci 拉取私有仓库代码 #

可以生成 group token,然后 git 全局替换下

git config --global url."https://<name>:<token>@gitlab.com".insteadOf "https://gitlab.com"

或者使用 deploy key,可以看作是机器的认证,在项目仓库中授权下机器即可。

git config --global url."ssh://git@gitlab.com".insteadOf "https://gitlab.com"

docker 镜像中一般这样使用

# ssh key 安全考虑,一般放在环境变量中传入
RUN mkdir ~/.ssh && echo $id_rsa > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa
# 处理错误:Host key verification failed.
RUN echo "Host *\n\tStrictHostKeyChecking no\n\tCheckHostIP no\n" > ~/.ssh/config

minio #

需要注意权限,最小权限开放原则。

前端直接上传,可以使用 STS 方式或者 presignURL ,不能直接将 key 放在前端进行请求。

1. ffmpeg解码音频遇到的问题 #

在修复MP4格式损坏视频时,因为视频文件中的aac音频编码数据是没有封装格式的,也就无从知道一段音频数据的具体大小,视频修复也就无法进行下去; 后来想到是不是可以用ffmpeg底层解码函数直接尝试解码一段数据,并告知我们成功解码了多少字节,这不就是当前音频编码数据的具体大小了吗; 在功能实现完成开始测试时,发现事与愿违,在设置了aac规格(profile)、声道数、采样率等参数后,解码过程有时仍会失败,当时很困惑,后来在网上看到了下面这篇文章: aac的各种规格,里面有这样一段说明:
aac

也就是在解码音频数据时,要根据aac的profile是否为HEV1或HEV2,将采样率和声道数除以2,这样就能正确解码了。

2. 字符串和整型比较的性能差距 #

在将一个较大的未分配簇当损坏文件进行修复时,发现速度偏慢,初步排查过后发现该未分配簇中有效数据非常少,也就是整个修复过程都耗在了特征匹配上,而此时的特征匹配算法是通过分析 样例文件统计视频帧数据的前三个字节并转成字符串存储在了一个map中,猜测是不是字符串比较操作的性能问题导致速度慢,然后写了一个基准测试对比了三字节长度的数据分别作为字符串 和整型值进行相等比较,测试结果如下: bench

可见整型比较操作是要比字符串比较快了近一倍的。在将字符串特征改为整型特征后进行测试,修复时间也是快了将近一倍多,符合预期。

3. 采用Truncate函数修改文件大小达到释放磁盘空间的目的 #

在修复文件写满磁盘空间时,需要能正确记录文件修复的失败状态,而此时磁盘已经没有空间让数据库去做增删改的操作了,此时我们可以借助Truncate函数截断修复文件尾部的一小部分数据 达到释放部分磁盘空间的目的,避免了删除整个视频修复文件。

4. 从日志文件中寻找灵感 #

学会从软件输出日志中找寻有用信息,例如我们要从零开始研究实现某一功能时,如果遇到瓶颈了,可以试着去跑一跑友商的软件,然后分析一下其软件输出日志,也许能找到一些灵感。 我在实现MP4无结构修复时,一开始毫无头绪,后来试着看了看某一友商的日志,从中找到了突破口,顺利实现了该功能。

5. 写单元测试用例 #

培养自己写单元测试的习惯,一开始可能会觉得有点浪费时间,但随着你的业务逻辑越来越复杂,你会发现单元测试很有用,它能大幅减轻你修改复杂代码时的心理负担,不用担心会改出bug, 这其实也提高了后续开发维护的效率。

6. 经常去github逛逛,避免重复造轮子 #

在做视频修复时,我们要支持很多主流视频格式,这就涉及到对这些视频文件进行解析和封装,在充分理解了视频格式细节后,我们当然能自己写代码来解析和封装,但这可能要花费更多的时间和精力 而且最终的实现效果可能还不一定很好,因为短时间内要掌握一个视频格式的各种细节是很困难的;所以我会去github上找一些优秀的开源项目,研究其实现细节,再根据自己的项目需求决定是直接引用还是二次开发,这样既能通过阅读优秀的代码提高编码能力也能提高开发效率缩短开发时间。

开发流程可能会出现的坑总结 #

我下面说的坑不是代码层面而是开发流程和管理方面:

1.没有需求会议 #
  • 货不对版。结果交互之后,客户说:“我要的是那个,你怎么给我这个?”
  • 技术无法实现。比如说:实现功能到一半卡壳,甚至是需求不合理实现不了。
2.有原型,但是没有没有进行业务逻辑的分解 #
  • 开发的时候,经常发现逻辑根本走不通,或者有多个分支可以走,这个时候,来回询问产品,时间全部花在跑路上。等到开发完成,进行交付的时候,各种改,最后自己不知道自己写了什么。屎山开始诞生。
3.前端内部没有进行技术分析。 接到原型,粗略的数数页面,每人分几个就行了 #
  • 丢失效率,且用户体验错乱。重复开发,明明多个页面会重用某一个业务组件,成员各自开发了一遍。在细节实现上还有所不同,结果被测试和产品吐槽“怎么这两地方不一样?
  • 业务间交互不一致,各种适配。例如,中间业务组件与多个业务组件有数据交互的需求,结果 A 页面要的是 name,B 页面要的是 userName,C 页面要的是 nickName。中间组件需要给所有页面都都要适配一遍,累死个人。
4.前后端接口文档不规范 #
  • 各种数据库的对象层层嵌套,简直就是俄罗斯套娃。
  • 不必要的数据返回。
  • 数据结构体种类多,同样容易出接口入参名称不一致的问题。同样是“用户名”字段 可能从 A 接口拿来的时候,出参是 userName,结果到了 B 接口,入参又变成 nickName。各种适配,很难受。
  • 没有文档,口头约定,txt,word,电子邮件…
5.没有交互,或者交互不详细 #
  • 没有交互设计,只有平面设计。前端拿到的都只是一张图。每个页面的间距不一样,没有主题色字体,不区分等级,各种宋体,雅黑,平方,都存在,对齐看心情,换行自己想。有些图上,为了展现精致,按钮的大小可能只有 20 20 了。如果没有规范会使前端在实现 UI 上花的时间,比写代码还多。因此在交互和 UI 上也需要符合产品,符合某种主题,这样前端写的一些组件和样式就可以复用。
6.不注重维护性 #
  • 这里主要是团队内部协作和开发人员素质问题造就的。一个团队,如果没有规范,那么成员经常是短视的。而现阶段前端的开发模式,也容易造成大家写出不解耦的代码,没有 MVC,所有的代码全部都是 C。没有组件,只有页面。维护起来十分困难。
7.不区分环境 #
  • 没有测试环境,没有 mock,测试阶段,各个成员随意重启/部署。
8.代码管理混乱 #
  • 没有区分 master 和 dev 分支,所有代码只有一个分支,遇到线上 bug,各种 copy,save,写代码就像在走钢丝。
9.倒排需求 #
  • 对于代码管理的小白团队,就会因为这个坑,导致出现前面的坑,每天做需求,每天填坑,永不结束,无限循环。