分类: 知识储备

  • ZIP文件

    一、文件结构

    1、Local File Header — 每个压缩文件开头的本地头,包含文件名、压缩方式、CRC校验等

    2、Compressed Data — 实际的压缩数据

    3、End of Central Directory Record (EOCDR) — 最末尾的一条记录,相当于”目录索引”,记录了:
    – 这个zip里有多少个文件
    – 每个文件在zip里的偏移位置
    – 压缩前后的文件大小
    – 文件名列表


    二、解压过程

    第一步:定位EOCDR

    unzip从文件末尾往前搜索,找到End of Central Directory Record签名(PK\x05\x06)。EOCDR固定在最末尾,长度不固定但通常很小(几十到几百字节)。

    第二步:读取Central Directory

    EOCDR里记录了Central Directory(中央目录)在文件中的偏移位置和大小。unzip跳到那个位置,逐个读取每个文件的Central Directory Entry,得到:

    第三步:逐个解压文件

    对每个文件:

    • 根据偏移找到Local File Header
    • 读取压缩数据
    • 按压缩方式解压(最常用是deflate)
    • 用CRC32校验解压后的数据是否完整
    • 写入磁盘

  • Base64编码(Golang)

    Base64是一种用64个可打印字符来表示二进制数据的方法。叫64是因为用了64个字符。

    一、需求

    计算机底层是二进制(0和1),但很多传输通道只支持文本。比如:

    • 电子邮件:早期SMTP协议只支持ASCII字符,传不了二进制附件
    • URL:有些特殊字符在URL里有特殊含义(? & =),二进制数据传不了
    • HTML/CSS:在网页里直接嵌入图片,不需要额外请求
    • JSON/XML:这些文本格式不能直接塞二进制数据

    Base64就是把二进制数据”翻译”成纯文本,安全地通过这些通道。


    二、原理

    每3个字节(24位)分成4组,每组6位,查表得到4个字符。

    原始数据:Man
    二进制:01001101 01100001 01101110
    6位分组:010011 011000 010110 1110(补00)
    查表:T W F u
    结果:TWFu

    如果原始数据不是3的倍数:

    剩1个字节 → 输出2个字符 + 2个 “=” 填充

    剩2个字节 → 输出3个字符 + 1个 “=” 填充

    任何二进制数据都可以,不只是PNG
    图片:PNG、JPG、GIF、WEBP、SVG、BMP、ICO
    音频:MP3、WAV、AAC、OGG
    视频:MP4、AVI、WEBM
    文档:PDF、ZIP、EXE、DLL
    证书:PEM格式的SSL证书
    任意文件:任何你能想到的文件


    三、其他编码

    Base16(Hex):用16个字符(0-9 A-F),体积膨胀100%
    Base32:用32个字符,体积膨胀约60%
    Base64:用64个字符,体积膨胀约33%
    一张100KB的PNG图片:
    base64编码后约133KB
    解码后恢复为原始的100KB PNG

    Base85:用85个字符,体积膨胀约25%,但字符集更复杂

    Base64是体积和可读性的最佳平衡点

    四、使用示例

    package main
    
    import (
            "encoding/base64"
            "fmt"
            "os"
    )
    
    func main() {
            //1. 基础字符串编解码
            original := "Hello, 世界! "
            fmt.Printf("原始字符串: %s\n", original)
            encoded := base64.StdEncoding.EncodeToString([]byte(original))
            fmt.Printf("Base64编码: %s\n", encoded)
            decoded, err := base64.StdEncoding.DecodeString(encoded)
            if err != nil {
                    fmt.Printf("解码错误: %v\n", err)
            } else {
                    fmt.Printf("Base64解码: %s\n", string(decoded))
            }
    
            // 2. URL安全的Base64
            urlData := "user+name/test=data&key=val"
            fmt.Printf("原始数据: %s\n", urlData)
            stdEncoded := base64.StdEncoding.EncodeToString([]byte(urlData))
            urlEncoded := base64.URLEncoding.EncodeToString([]byte(urlData))
            fmt.Printf("标准Base64: %s\n", stdEncoded)
            fmt.Printf("URL Base64: %s\n", urlEncoded)
    
            // 3. 无填充的Base64
            data := "abc"
            fmt.Printf("原始数据: %s\n", data)
            fmt.Printf("带填充: %s\n", base64.StdEncoding.EncodeToString([]byte(data)))
            fmt.Printf("无填充: %s\n", base64.RawStdEncoding.EncodeToString([]byte(data)))
    
            // 4. 自定义Base64字母表
            custom := base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!")
            customEncoded := custom.EncodeToString([]byte("Hello World"))
            fmt.Printf("自定义编码: %s\n", customEncoded)
            customDecoded, _ := custom.DecodeString(customEncoded)
            fmt.Printf("自定义解码: %s\n", string(customDecoded))
    
            // 5. 模拟图片Base64编码
            // 创建一个假的PNG文件头(实际项目中替换为真实图片文件)
            fakePNG := []byte{
                    0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG文件头
                    0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR
            }
            imgEncoded := base64.StdEncoding.EncodeToString(fakePNG)
            fmt.Printf("图片Base64: %s\n", imgEncoded)
    
            // 模拟HTML内嵌(取前min(30, len)个字符)
            prefixLen := 30
            if len(imgEncoded) < prefixLen {
                    prefixLen = len(imgEncoded)
            }
            fmt.Printf("HTML内嵌: <img src=\"data:image/png;base64,%s...\" />\n", imgEncoded[:prefixLen])
    
            // 6. 读真实文件并编码(如果存在)
            filename := "/tmp/test_base64.txt"
            os.WriteFile(filename, []byte("这是一个测试文件内容\n用于演示Base64编码"), 0644)
            content, err := os.ReadFile(filename)
            if err != nil {
                    fmt.Printf("读文件失败: %v\n", err)
            } else {
                    fileEncoded := base64.StdEncoding.EncodeToString(content)
                    fmt.Printf("文件名: %s\n", filename)
                    fmt.Printf("原始大小: %d 字节\n", len(content))
                    fmt.Printf("编码大小: %d 字节\n", len(fileEncoded))
                    fmt.Printf("膨胀率: %.1f%%\n", float64(len(fileEncoded)-len(content))/float64(len(content))*100)
                    fmt.Printf("Base64: %s\n", fileEncoded)
            }
            os.Remove(filename)
    
            // 7. 错误处理 - 非法Base64
            invalidBase64 := "ThisIsNot@Valid#Base64!"
            _, err = base64.StdEncoding.DecodeString(invalidBase64)
            if err != nil {
                    fmt.Printf("非法Base64 '%s' 解码错误: %v\n", invalidBase64, err)
            }
    
            // 8. 严格模式
            // 标准模式允许尾部bits非零
            loose := base64.StdEncoding
            strict := base64.StdEncoding.Strict()
            testData := "SGVsbG8=" // "Hello"
            _, err1 := loose.DecodeString(testData)
            _, err2 := strict.DecodeString(testData)
            fmt.Printf("标准模式解码 '%s': %v\n", testData, err1)
            fmt.Printf("严格模式解码 '%s': %v\n", testData, err2)
    
            // 9. 编解码长度计算
            for _, n := range []int{1, 2, 3, 4, 5, 6, 10, 100} {
                    encLen := base64.StdEncoding.EncodedLen(n)
                    decLen := base64.StdEncoding.DecodedLen(encLen)
                    fmt.Printf("输入%3d字节 → 编码%4d字节 → 解码最多%3d字节\n", n, encLen, decLen)
            }
    }
    
  • Cron定时(Golang)

    Golang的日常定时任务是一种常见需求,例如定期清理缓存、发送通知或同步数据。robfig/cron是Go社区广泛使用的定时任务库,支持秒级精度的cron表达式,并提供了安全启动、停止以及任务查询的接口。本文将从cron表达式的基本原理出发,结合一段实际的生产级代码,详细说明如何封装一个可管理、可监控的定时任务模块,并分析其中关键的并发控制与生命周期管理细节。


    cron 表达式原理

    cron表达式起源于Unix系统的cron守护进程,用于定义任务的执行时间。标准格式通常为五个字段:分、时、日、月、周几,每个字段可指定具体数字、范围(如0-5)、步长(如/5)或通配符。robfig/cron扩展了标准格式,支持可选的秒级字段,即六字段表达式:秒、分、时、日、月、周几。例如”0 30 9 * * 1-5″表示每个工作日上午9点30分执行,其中秒为0。库内部通过解析表达式生成下一次触发时间,并利用定时器(time.Ticker)在精确时刻调用注册的回调函数。其核心是一个调度循环,不断计算最近的下一个执行时间,休眠到该时刻并运行任务,然后重复此过程。这种设计保证了定时任务在单进程内的可靠触发,且不依赖外部守护进程。


    代码整体结构

    # Golang
    import (
    	"net/http"
    	"sync"
    	"time"
    
    	"github.com/gin-gonic/gin"
    	"github.com/robfig/cron/v3"
    )
    
    type CronEntry struct {
    	Cron      *cron.Cron
    	EntryID   cron.EntryID
    	MU        sync.RWMutex
    	StopOnce  sync.Once
    	Remark    string
    	RunStatus string
    }
    
    var (
    	CronJobs   []*CronEntry
    	CronJobsMu sync.RWMutex
    	MyCron     = cron.New(cron.WithSeconds())
    )
    
    func NewCronEntry(spec, remark string, job func() error) (*CronEntry, error) {
    	ce := &CronEntry{
    		Cron:      MyCron,
    		Remark:    remark,
    		RunStatus: "等待中",
    	}
    	id, err := MyCron.AddFunc(spec, func() {
    		err := job()
    		ce.MU.Lock()
    		if err != nil {
    			ce.RunStatus = "失败"
    		} else {
    			ce.RunStatus = "成功"
    		}
    		ce.MU.Unlock()
    	})
    	if err != nil {
    		return nil, err
    	}
    	ce.EntryID = id
    	MyCron.Start()
    	CronJobsMu.Lock()
    	CronJobs = append(CronJobs, ce)
    	CronJobsMu.Unlock()
    	return ce, nil
    }
    func (ce *CronEntry) GetScheduleTime() (prev, next time.Time) {
    	entry := ce.Cron.Entry(ce.EntryID) // cron.Entry() 本身是并发安全的
    	return entry.Prev, entry.Next
    }
    func (ce *CronEntry) Stop() {
    	ce.StopOnce.Do(func() {
    		ce.MU.Lock()
    		defer ce.MU.Unlock()
    		if ce.Cron == nil {
    			return
    		}
    		ce.Cron.Remove(ce.EntryID) // 先移除 job
    		ctx := ce.Cron.Stop()      // 停止调度器
    		<-ctx.Done()
    		ce.Cron = nil // 标记已停
    	})
    }
    func StopAndRemoveCronEntry(ce *CronEntry) {
    	if ce == nil {
    		return
    	}
    	ce.Stop()
    	CronJobsMu.Lock()
    	defer CronJobsMu.Unlock()
    	for i, job := range CronJobs {
    		if job == ce {
    			CronJobs = append(CronJobs[:i], CronJobs[i+1:]...)
    			break
    		}
    	}
    }
    func (ce *CronEntry) GetStatus() string {
    	ce.MU.RLock()
    	defer ce.MU.RUnlock()
    	return ce.RunStatus
    }
    func GetCronJobs(c *gin.Context) {
    	type CronJobInfo struct {
    		Job       int    `json:"job_id"`
    		LastDate  string `json:"上一次执行日期"`
    		NextDate  string `json:"下一次执行日期"`
    		Remark    string `json:"备注"`
    		RunStatus string `json:"上一次执行状态"`
    	}
    	CronJobsMu.RLock()
    	var list []CronJobInfo
    	defer CronJobsMu.RUnlock()
    	for _, entry := range CronJobs {
    		prev, next := entry.GetScheduleTime()
    		prevStr := ""
    		if !prev.IsZero() { // 如果任务还没执行过,Prev 是零值
    			prevStr = prev.Format("2006-01-02 15:04:05")
    		}
    
    		nextStr := ""
    		if !next.IsZero() {
    			nextStr = next.Format("2006-01-02 15:04:05")
    		}
    
    		list = append(list, CronJobInfo{
    			Job:       int(entry.EntryID),
    			LastDate:  prevStr,
    			NextDate:  nextStr,
    			Remark:    entry.Remark,
    			RunStatus: entry.GetStatus(),
    		})
    	}
    	c.AbortWithStatusJSON(http.StatusOK, list)
    }

    代码中定义了一个CronEntry结构体,封装了单个定时任务的完整信息:指向全局调度器的Cron指针、任务在调度器中的唯一EntryID、用于同步的读写锁MU、保证只停止一次的StopOnce、备注 Remark 以及最近一次执行状态RunStatus。全局变量MyCron是使用cron.New(cron.WithSeconds())创建的调度器实例,启用了秒级支持,同时全局切片CronJobs存储所有被管理的任务指针,并由CronJobsMu读写锁保护。这种设计将任务管理与调度器解耦:调度器负责底层触发,而用户代码通过CronEntry获取状态、控制启停。

    创建任务:NewCronEntry

    NewCronEntry函数接收cron表达式(spec)、备注和实际执行函数(返回error)作为参数。函数内部首先创建一个CronEntry实例,初始化运行状态为“等待中”。然后通过MyCron.AddFunc注册任务——这是robfig/cron的核心方法,它解析表达式并在调度器中插入一个entry,返回唯一的EntryID。注册的回调函数先执行用户提供的job,然后根据返回的err通过ce.MU.Lock()安全更新RunStatus为“成功”或“失败”。注意加锁是为了防止后续GetStatus与这里形成数据竞争,因为GetStatus 使用读锁。任务注册成功后,立即启动调度器(MyCron.Start()),这意味着一旦调用NewCronEntry,调度循环就开始运行,后续添加的任务也会被已启动的调度器处理。Start()函数是幂等的,重复调用不会导致多次启动,因此放在每个新任务创建时是安全的。最后,将新entry追加到全局CronJobs切片中,并返回该 entry指针。这里存在一个潜在问题:Start()在每次添加任务时都会调用,虽然不会重复启动,但代码风格上更适合在初始化时仅调用一次。不过考虑到后续可能动态添加任务,如此实现也能工作。

    停止单个任务:Stop方法

    Stop方法使用sync.Once确保一个任务只被停止一次,防止重复调用导致panic或资源泄漏。内部先获取写锁,检查ce.Cron是否为nil(已停止状态),然后依次执行ce.Cron.Remove(ce.EntryID) 从调度器中移除该任务entry,再调用ce.Cron.Stop()停止整个调度器。这里有一个值得注意的细节:Stop()返回一个 channel ctx,调用者需要等待<-ctx.Done()以确保调度器完全停止并清空内部计时器。但问题在于,Stop()是全局行为,它会停止所有任务,而不仅仅是当前 entry。因此,如果一个模块只想停用自己管理的单个任务,使用MyCron.Stop() 会中断其他仍在运行的任务。这应当是设计上的简化,实际项目中往往需要更细粒度的控制,例如使用cron.Cron.Remove(entryID)移除条目后,其他任务仍能继续调度。这里先移除条目再停止整个调度器,可能意味着使用者期望在stop后不再有任何任务执行。如果后续需要单独停止单个任务而不影响其他,更好的做法是在Stop中只调用Remove,而保留调度器运行。但当前代码体现了作者对整体生命周期控制的考量——当某个关键任务停止时,整个调度器也同步停止,避免因剩余任务不再被管理而产生混乱。

    状态查询与调度时间获取

    GetScheduleTime方法通过ce.Cron.Entry(ce.EntryID)获取cron库内部维护的entry 信息,其中包括Prev和Next两个time.Time值,分别表示上一次和下一次执行时间。Cron.Entry()本身是并发安全的,因此无需额外加锁。如果任务尚未执行过,Prev为零值,代码在GetCronJobs中通过IsZero()判断并格式化为空字符串,这样 HTTP响应中就不会显示不存在的日期。GetStatus使用读锁返回当前RunStatus,与回调中写锁对应,保证了数据一致性。

    全局停止与移除:StopAndRemoveCronEntry

    该函数接收一个*CronEntry,先调用其Stop方法,然后从全局CronJobs切片中移除该指针。切片删除采用“先查找再重新拼接”的方式,这种线性搜索在任务数不多时没有问题。注意这里对CronJobsMu加写锁,与NewCronEntry中添加时的锁一致,避免了并发读写切片的风险。

    HTTP 接口:GetCronJobs

    这是一个Gin处理函数,用于返回所有定时任务的信息。它首先通过读锁读取CronJobs切片,遍历每个entry,获取其调度时间和状态,组装成JSON结构。响应中包含了job_id(即EntryID)、上一次执行日期、下一次执行日期、备注和上一次执行状态。日期格式使用config.TimeNowFormat,这应是项目中预定义的时间格式字符串,例如”2006-01-02 15:04:05″。函数最后使用c.AbortWithStatusJSON返回状态码200和序列化后的列表。注意这里使用了AbortWithStatusJSON而不是c.JSON,意味着该函数可能会作为Gin的中间件调用,或者作者希望确保后续中间件不再处理。按照Gin的约定,如果是路由处理函数,更常见的写法是c.JSON,但AbortWithStatusJSON也能正常工作,只是它会设置Abort标志,阻止之后的所有中间件执行。如果该函数是路由的最后一个处理器,两者效果一致;如果有后续中间件,则会导致它们被跳过。这点需要结合具体路由设置来判断。


    总结

    在Go中基于robfig/cron封装一个可管理的定时任务模块。通过CronEntry结构体整合了任务的生命周期(创建、启动、停止、移除)和运行状态,利用读写锁和sync.Once保证了并发安全。同时,提供了HTTP接口用于监控所有任务的状态。在实际使用中,需要注意全局调度器的停止操作会对所有任务产生影响,如果希望实现更细粒度的单任务停止,可以仅调用Remove而不调用Stop()。此外,NewCronEntry中每次添加任务都调用Start()虽然可行,但建议将Start()放在初始化阶段一次调用,使逻辑更清晰。整体设计为中小规模定时任务管理提供了一个良好的模板,具备扩展性,例如可在此基础上增加持久化、错误重试或任务依赖等高级特性。

  • webRTC(Golang)

    在视频监控、工业巡检以及边缘计算等场景中,常见的一个现实问题是协议割裂:前端设备通常通过RTSP推流,而浏览器原生并不支持直接播放RTSP。这就带来了一个工程上的关键挑战——如何在不引入高延迟和复杂转码的前提下,将设备侧的视频流高效地分发到浏览器端。

    一种更直接且高效的思路,是利用WebRTC作为浏览器侧的实时传输协议,同时在服务端完成协议层的桥接,将RTSP流转换为WebRTC可消费的RTP数据流。这种方式避免了传统转码链路(例如FFMPEG+纯接口转发)带来的性能损耗,同时保留了实时性的优势。

    通过golang程序实现一个典型的视频桥接架构:上游通过RTSP拉流(通常来自摄像头或推流工具),服务端将RTP包转发到WebRTC PeerConnection,下游浏览器通过 WebRTC 实时播放视频。整体链路为:RTSP → RTP → Go服务 → WebRTC → 浏览器。


    零、在本机启一个mediamtx作为RTSP服务端,再通过ffmpeg把本机摄像头推流到服务器来模拟摄像头的RTSP流

    ./mediamtx &
    ./ffmpeg -f v4l2 -i /dev/video0 \
    -vcodec libx264 -preset veryfast -tune zerolatency \
    -f rtsp -rtsp_transport tcp \
    rtsp://test:123456@127.0.0.1:8554/test

    一、golang程序入口main中首先确定RTSP地址,并开一个Gin HTTP服务,同时维护一个clients映射,用于保存每个WebRTC客户端对应的Track:

    每个客户端并不是单独拉流,而是共享同一 RTSP输入流,服务端通过fan-out(扇出)机制将RTP包写入多个WebRTC Track,从而实现“一路输入,多路输出”

    import (
    	"github.com/bluenviron/gortsplib/v5"
    	"github.com/bluenviron/gortsplib/v5/pkg/base"
    	"github.com/bluenviron/gortsplib/v5/pkg/description"
    	"github.com/bluenviron/gortsplib/v5/pkg/format"
    	"github.com/gin-gonic/gin"
    	"github.com/pion/rtp"
    	"github.com/pion/webrtc/v3"
    )
    
    rtspURL := os.Getenv("RTSP_URL")
    if rtspURL == "" {
    	rtspURL = "rtsp://test:123456@127.0.0.1:8554/test"
    }
    
    var clientsMu sync.Mutex
    clients := map[string]*webrtc.TrackLocalStaticRTP{}

    二、HTTP部分分为三个路由:

    1)“/”路由返回播放器页面
    2)“/offer”路由处理WebRTC SDP信令交换
    3)“/stats”路由返回实时码率与包速率

    前端页面核心逻辑如下:

    浏览器创建RTCPeerConnection → 生成Offer(SDP)→ 发送给服务端 → 服务端生成Answer → 浏览器设置远端描述。需要注意的是,这里没有使用STUN/TURN(iceServers 为空),意味着该方案默认运行在内网或可直连环境,否则无法穿透NAT。

    pc=new RTCPeerConnection({iceServers:[]});
    pc.addTransceiver('video',{direction:'recvonly'});
    
    const offer=await pc.createOffer();
    await pc.setLocalDescription(offer);
    
    const resp=await fetch('/offer',{
      method:'POST',
      headers:{'Content-Type':'application/json'},
      body:JSON.stringify({sdp:offer.sdp,type:offer.type})
    });
    
    const answer=await resp.json();
    await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp});

    服务端“/offer”路由处理逻辑是WebRTC的核心:

    这里做了两件关键事情:
    1)注册编解码器(H264/H265)
    2)创建 PeerConnection

    WebRTC本质上是RTP的增强版,但浏览器对编码格式要求严格,因此必须显式注册codec,否则SDP协商无法匹配。

    m := webrtc.MediaEngine{}
    _ = m.RegisterCodec(webrtc.RTPCodecParameters{
    	RTPCodecCapability: webrtc.RTPCodecCapability{
    		MimeType: webrtc.MimeTypeH264,
    		ClockRate: 90000,
    		SDPFmtpLine: "packetization-mode=1;profile-level-id=42e01f",
    	},
    	PayloadType: 96,
    }, webrtc.RTPCodecTypeVideo)
    
    api := webrtc.NewAPI(webrtc.WithMediaEngine(&m))
    pc, _ := api.NewPeerConnection(webrtc.Configuration{})

    创建Track,并绑定到PeerConnection:

    Track是WebRTC中媒体发送的抽象,本质上是RTP流的出口。这里使用TrackLocalStaticRTP,意味着可以手动写入RTP包

    track, _ := webrtc.NewTrackLocalStaticRTP(
    	webrtc.RTPCodecCapability{
    		MimeType: webrtc.MimeTypeH264,
    		ClockRate: 90000,
    	},
    	"video", "pion",
    )
    pc.AddTrack(track)

    信令交换部分:

    接收浏览器Offer → 设置远端 SDP → 生成 Answer → 返回给浏览器。GatheringCompletePromise用于等待ICE candidate收集完成,否则SDP不完整。

    offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: req.SDP}
    pc.SetRemoteDescription(offer)
    
    ans, _ := pc.CreateAnswer(nil)
    pc.SetLocalDescription(ans)
    
    <-webrtc.GatheringCompletePromise(pc)

    将Track存入clients,用于后续RTP分发:

    这里通过DESCRIBE + SETUP建立RTSP会话,并解析媒体格式(H264/H265)。

    clientsMu.Lock()
    clients[c.ClientIP()+"_"+time.Now().Format("150405")] = track
    clientsMu.Unlock()

    核心转发逻辑在OnPacketRTP:

    RTSP → 解复用 → 得到RTP包 → 广播写入所有WebRTC Track
    服务端本质上只是一个RTP转发器,并不进行转码,这带来两个重要特性:
    优点:延迟极低(基本无编码延迟)
    CPU占用极小
    架构简单稳定
    限制:浏览器必须支持该编码(通常是H264)
    RTSP输入必须与WebRTC codec兼容

    cli.OnPacketRTP(media, formaH264, func(pkt *rtp.Packet) {
    	clientsMu.Lock()
    	totalBytes += uint64(len(pkt.Payload))
    	totalPackets++
    
    	activeClients := make([]*webrtc.TrackLocalStaticRTP, 0, len(clients))
    	for _, t := range clients {
    		activeClients = append(activeClients, t)
    	}
    	clientsMu.Unlock()
    
    	for _, t := range activeClients {
    		t.WriteRTP(pkt)
    	}
    })

    统计接口“/stats”路由通过简单计数实现码率计算:

    前端每秒拉取一次,实现实时监控。

    kbps := uint64(float64(totalBytes*8) / 1024 / duration)
    pktps := uint64(float64(totalPackets) / duration)

    第一,WebRTC并不负责“获取视频”,它只负责“传输媒体流”。视频源可以来自 RTSP、文件、摄像头等。

    第二,WebRTC的关键不是API,而是:

    • SDP协商
    • ICE建连
    • RTP收发

    第三,这种架构属于典型的“边缘网关模式”:

    RTSP(设备侧协议) → WebRTC(浏览器协议)

    在工业监控、视频巡检、边缘计算中非常常见

  • Load AVG(Linux)

    Load Average】:代表机器在某一时间段内,处于“可运行状态”或“不可中断等待状态”的进程平均数量。

    三个数值统计跨度分别为:1分钟平均 5分钟平均 15分钟平均


    一台4核心的处理器的Load为 1.21 1.34 1.12

    1、CPU压力为 1.21 / 4 ≈ 0.30

    2、CPU压力为 1.34 / 4 ≈ 0.34

    3、CPU压力为 1.12 / 4 ≈ 0.28

    平均只有约1个任务在运行或等待CPU,而系统有4核心,完美胜任服务


    在Linux中,平均负载并非在每个时钟滴答时计算,而是由一个基于HZ频率设置的变量值驱动,并在每个时钟滴答时进行检测。该设置定义了内核时钟滴答速率(单位:赫兹,即每秒次数),默认值为100,对应10毫秒的滴答间隔。内核活动使用这些滴答数进行计时。具体来说,calc_load() 函数(位于loadavg.h文件,原为 sched.h)负责计算平均负载,它大约每LOAD_FREQ(5*HZ+1)个滴答运行一次,即略多于5秒。

  • TOPS和TFLOPS

    选择


    TOPS和TFLOPS代表了计算系统中不同的硬件性能。
    TOPS代表 Tera Operations Per Second
    TFLOPS 代表 Tera Floating Point Operations Per Second

    TOPS衡量芯片每秒能完成多少万亿次整数运算(整数的加法/乘法)。这对于吞吐量比最终精度更重要的任务尤为关键——例如,自动驾驶车辆中的神经处理单元(NPU)或RTX 5070显卡提供数千个TOPS以快速处理传感器数据。 相比之下,TFLOPS计算的是每秒可执行的万亿次浮点(十进制)计算。 浮点数学对于高精度工作至关重要,比如训练神经网络或科学模拟。


    如果工作目标是AI训练或任何需要高精度的任务,选择TFLOPS评分很高的GPU。
    如果是边缘或移动端的实时AI推理,选择高TOPS的NPU或GPU。

    关键区别


    精度与速度:浮点运算涉及小数且精度更高,因此优化TFLOPS(每秒万亿次浮点运算)的硬件用于对精度敏感的任务(如图形处理或气候模型)。整数运算(以TOPS,即每秒万亿次运算衡量)使用整数,更简单快速。正如资料所示,浮点运算“涉及小数点”,适用于高精度场景;而整数运算用于更简单的任务。实践中,GPU(英伟达、AMD独显等)为复杂计算强调TFLOPS,而NPU(神经网络处理器)和数字信号处理单元则强调TOPS以快速处理大量推理任务。

    硬件效率:整数运算比浮点运算简单,硬件通常能实现更高吞吐量。这就是NPU和其他推理加速器宣传极高TOPS值的原因。例如,现代PC CPU如英特尔酷睿Ultra系列或高通骁龙芯片集成了擅长整数运算的NPU,能以低功耗实现每秒数万亿次运算。相比之下,在相同制程下,GPU的浮点单元每秒运算次数少于NPU的整数单元,因为浮点运算更复杂。

    应用适用性:根据工作负载选择合适的指标。若进行AI训练或重型计算,需要高TFLOPS。数据中心GPU和加速器(英伟达A100/H100、AMD Instinct、谷歌TPU)使用FP32、FP16或BF16精度提供巨大的TFLOPS(通常达数十或数百)。TOPS与TFLOPS数值不能直接等同比较。例如,NPU的“130 TOPS”并不天然优于或劣于GPU的“65 TFLOPS”——它们反映不同类型的吞吐能力。TOPS反映通用(通常为低精度)运算,而TFLOPS反映十进制精度运算。高TOPS芯片可能擅长实时运行图像分类器,但在训练需要高浮点精度的模型时可能吃力。反之,高TFLOPS芯片能训练庞大模型,但在相同推理任务上可能功耗更高、成本更大。

  • WebSocket和HTTP Upgrade机制

    HTTP协议用于浏览器和服务器之间传输数据。每次请求是独立的,不记历史,HTTP来轮询服务器更新增加性能支出。

    WebSocket协议旨在取代现有利用HTTP作为传输层的双向通信技术,从而利用现有基础设施(代理、过滤、认证)。但WS是基于HTTP层,而不是为了取代HTTP。

    在HTTP协议中,“Upgrade”机制允许客户端告诉服务器把当前的HTTP连接升级到另一种协议。(RFC 2616)。

    WebSocket协议由开启握手和基础消息框架组成,分层叠加在TCP之上, 该技术的目标是为需要与服务器双向通信的浏览器应用提供一种机制,无需开启多个HTTP连接。WS通过HTTP升级协议握手。一条向服务器的WS握手:

    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Origin: http://example.com
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13

    一条向客户端的WS握手:

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    Sec-WebSocket-Protocol: chat

    一旦客户端和服务器都发送了握手,如果握手成功,数据传输阶段才开始。这是一个双向通信通道,双方可以独立地随意发送数据。成功握手后,客户端和服务器以本规范中称为“消息”的概念单元进行数据传递。 在线路上,一条消息由一个或多个组成框架。 WebSocket消息不一定对应于特定的网络层框架,因为分段消息可能被中介合并或拆分。

    Sec-WebSocket-Protocol: chat
    用于防止脚本在网页浏览器中使用 WebSocket API 时未经授权交叉使用该服务器。 服务器会被告知生成WebSocket连接请求的脚本来源。 如果服务器不愿意接受来自该源的连接,可以通过发送相应的HTTP错误代码来拒绝连接。 该头字段由浏览器客户端发送;对于非浏览器客户端,如果在客户端上下文中合理,可能会发送该头字段。

    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    对于该头部字段,服务器必须将头部字段中的值与全局唯一标识符字符串形式“258EAFA5-E914-47DA- 95CA-C5AB0DC85B11”串联起来,这通常不会被不理解WebSocket协议的网络端点使用。 该连接的SHA-1哈希值(160位),以base64编码,随后在服务器握手中返回。

    HTTP/1.1 101 Switching Protocols
    除了101以外的任何状态码,都表示WebSocket握手尚未完成,HTTP的语义仍然适用。头部紧跟状态码。

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

    WebSocket客户端会检查这些字段以查找脚本页面。如果 |Sec-WebSocket-Accept|如果缺少头字段,或者 HTTP 状态码不是 101,则连接无法建立,WebSocket帧也不会发送。


    通过升级器,http服务被升级为websocket服务,除非得到websocket握手,否则连接失败:

    #Go
    
    #An upgrader
    var upgrader = websocket.Upgrader{
    	CheckOrigin: func(r *http.Request) bool {
    		return true
    	},
    }
    
    var wsClients   = make(map[*websocket.Conn]bool)
    
    func wsHandler(c *gin.Context) {
    	conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
    	if err != nil {
    		return
    	}
    	defer conn.Close()
    	wsClients[conn] = true
    	for {
    		_, _, err := conn.ReadMessage()
    		if err != nil {
    			delete(wsClients, conn)
    			break
    		}
    	}
    }
    
    func main() {
            r := gin.Default()
            r.GET("/ws", wsHandler)
    }
  • CRC(cylic redundancy check)数据帧校验

    crc用于校验数据传输过程的完整性,crc码通常位于数据帧尾部,与数据帧同时传输。

    计算数据帧的crc码与接收的crc码来确认数据完整性:

    初始化一个寄存器为0xFFFF,逐步与每个数据帧进行异或操作。

    # golang
    func CalculateCRC(data []byte) uint16 {
    	crc := uint16(0xFFFF)
    	for _, b := range data {
    		crc ^= uint16(b)
    		for i := 0; i < 8; i++ {
    			if crc&0x0001 != 0 {
    				crc >>= 1
    				crc ^= 0xA001
    			} else {
    				crc >>= 1
    			}
    		}
    	}
    	return crc
    }
  • Golang Range

    Golang的range可以用来遍历array,slice,string,map或channel


    range array:

    func main() {
    	animals := [3]string{}
    	animals[0] = "tiger"
    	animals[1] = "cat"
    	animals[2] = "lion"
    	for index, animal := range animals {
    		fmt.Println(index, animal)
    	}
    }
    
    //output:
    //0 tiger
    //1 cat
    //2 lion

    range slice:

    func main() {
    	urls := []string{"ziyaun.work", "https://ziyuan.work"}
    	for index, url := range urls {
    		fmt.Println(index, url)
    	}
    }
    
    //output:
    //0 ziyaun.work
    //1 https://ziyuan.work

    range string:

    func main() {
    	url := "ziyuan.work"
    	for i, v := range url {
    		fmt.Println(i, v)
    	}
    }
    
    //output:
    //0 122
    //1 105
    //2 121
    //3 117
    //4 97
    //5 110
    //6 46
    //7 119
    //8 111
    //9 114
    //10 107

    range map:

    func main() {
    	userList := make(map[string]string)
    	userList["url"] = "ziyuan.work"
    	for key, value := range userList {
    		value = "I change myself" //改变的只是提取item的值,userlist不受影响
    		fmt.Println(userList[key])
    		fmt.Println(value)
    	}
    }
    
    // output:
    //ziyuan.work
    //I change myself

    range channel:

    func main() {
    	wg := sync.WaitGroup{}
    	wg.Add(1)
    
    	c := make(chan int)
    	go func() {
    		defer wg.Done()
    		for i := 0; i < 10; i++ {
    			c <- i
    		}
    	}()
    
    	go func() {
    		wg.Wait()
    		close(c)
    	}() // 当使用range对channel操作时,会无限遍历,最终变成死锁,因此需要关闭channel
    
    	for value := range c {
    		log.Println(value)
    	}
    }
    
    //output:
    //0
    //1
    //2
    //3
    //4
    //5
    //6
    //7
    //8
    //9
  • BLE应用层

    低功耗蓝牙(Bluetooth Low Energy,BLE)分为三个部分组成。应用(Application)+主机(Host)+控制器(Controller)。单模BLE设备的应用层包含:

    • 通用访问配置文件 Generic Access Profile(GAP)
    • 通用属性配置文件 Generic Attribute Profile(GATT)
    • 逻辑链路控制和适配协议 Logical Link Control and Adaption Protocol (L2CAP)
    • 属性协议 Attribute Protocol(ATT)
    • 安全管理 Security Manager(SM)
    • 主机控制器接口 Host Controller Interface(HCI)

    通用属性配置文件(Generic Attribute Profile,GATT)建立在属性协议(Attribute Protocol,ATT)之上,通过在其上层引入层级化的数据组织结构和抽象模型,实现了对BLE数据的统一建模与访问控制。在BLE协议体系中,GATT可视为应用层数据交互的核心框架,其定义了数据在不同设备应用之间的组织方式及交换机制。GATT定义了一组通用的数据对象,可被各类基于GATT的应用配置文件复用。其继承了ATT所采用的客户端–服务器(Client–Server)架构,但通过引入“服务(Service)”这一逻辑容器,对数据进行了更高层次的封装。每个服务由一个或多个特征(Characteristic)组成。特征用于描述具体的用户数据单元,其不仅包含实际数据值(Value),还可关联多种元数据(Metadata),例如访问权限、用户可读名称、单位、描述符等,从而为数据的发现、访问和解释提供支持。

    通用访问配置文件(Generic Access Profile,GAP)定义了设备在BLE协议栈中进行连接和控制交互的行为规范。GAP被视为BLE的顶层控制模型,描述了设备在不直接涉及具体数据内容的情况下,如何完成设备发现、连接建立、安全机制协商以及角色管理等流程,以确保不同厂商设备之间的互操作性。为实现设备间一致且可互操作的通信行为,GAP进一步规范了以下内容:

    • 设备角色及其交互关系
    • 设备可发现性与可连接性模型及其状态转换
    • 建立可靠通信所需的控制流程
    • 安全模式、安全级别及相关流程
    • 用于广播和扫描的非协议数据(如Advertising Data)的格式定义

    “蓝牙连接”分为广播(broadcasting)或者连接(connections)

    广播(broadcasting)

    在BLE中,广播机制定义了两种相互独立的角色:广播者(Broadcaster)和观察者(Observer)。广播者负责在预定义的广播信道上周期性地发送不可连接的广播数据(Advertising Data),这些数据可被任意处于监听状态的设备接收。广播过程中,广播者不与接收设备建立连接关系。观察者则持续扫描指定的广播信道,以接收当前正在广播的、不可连接的广播数据包。观察者仅被动接收信息,不参与任何连接或数据确认过程。

    广播机制在BLE体系中具有重要意义,因为它是BLE中唯一一种支持单个设备在同一时间向多个设备分发数据的通信方式。该机制充分利用了BLE的低功耗和无连接特性,适用于向周围设备周期性发布状态信息或少量数据的场景。

    标准BLE广播数据包包含最多31字节的有效负载(Payload),用于承载描述广播发送者身份、能力以及服务信息的数据,同时也可包含用户自定义的广播内容。当该负载空间不足以容纳所需信息时,BLE允许使用可选的扫描响应数据(Scan Response)。在此机制下,观察设备在接收到广播数据后,可向广播者发送扫描请求,从而获取额外的31字节扫描响应负载,使单次广播相关的数据总量达到62字节。

    由于广播方式具有实现简单、延迟低且支持多设备同时接收的特点,当需要按照固定周期推送少量数据,或向多个设备发布公共信息时,广播是一种高效且实用的选择。然而,与基于连接的通信方式相比,广播机制存在显著的安全与隐私局限性。广播数据在未加密的情况下发送,任何处于监听状态的观察设备均可接收并解析广播内容。因此,该方式通常不适用于传输敏感或私密数据。

    连接(connections)

    当需要在设备之间进行双向数据传输,或所需传输的数据量超过广播与扫描响应所能承载的容量时,应采用基于连接(Connection)的通信方式。BLE连接是一种在两个设备之间建立的持续通信关系,连接建立后,设备双方在约定的时间点周期性地交换数据。与广播相比,连接通信具有天然的隐私性,其数据仅在连接的两端设备之间传输,不会被其他设备接收,除非第三方设备通过非正常手段对无线信道进行嗅探。

    BLE连接模型中定义了两种通信角色:中心设备(Central)与外围设备(Peripheral)。中心设备通过周期性扫描广播信道,以发现可连接的广播数据包。当检测到符合条件的广播后,中心设备发起连接请求并建立连接。连接建立后,中心设备负责控制连接时序(Timing),并调度周期性的数据交换过程。外围设备则以周期性发送可连接的广播数据包为主要行为,并在接收到连接请求后接受并进入连接状态。在连接建立后,外围设备需遵循中心设备所控制的连接时序,在预定的连接事件(Connection Event)中与中心设备进行数据交换。

    需要注意的是,尽管中心设备负责连接的建立与时序控制,但在每一个连接事件中,数据的发送方向是双向对等的。中心设备与外围设备在数据吞吐量和访问优先级方面并不存在协议层面的强制限制。自蓝牙4.1版本起,BLE规范取消了对角色组合方式的限制,允许设备在同一时间承担多种角色。具体而言:

    • 单个设备可以同时作为中心设备和外围设备
    • 一个中心设备可以同时与多个外围设备建立连接
    • 一个外围设备也可以同时与多个中心设备建立连接

    相较于早期规范中对外围设备只能连接单一中心设备的限制,上述改动显著增强了BLE网络拓扑的灵活性。连接通信的最大优势在于其支持通过更高层协议对数据进行精细化组织和访问控制。BLE通过在连接之上引入通用属性配置文件(Generic Attribute Profile,GATT),将数据组织为服务(Service)和特征(Characteristic)等逻辑单元。服务可包含多个特征,每个特征均具备独立的访问权限及描述性元数据,从而构建出结构清晰、可扩展的数据模型。

    此外,基于连接的通信方式还具备更高的数据吞吐能力、支持安全加密链路的建立,并允许在连接建立过程中协商连接参数,以优化数据传输效率与功耗表现。在功耗方面,连接通信在某些场景下甚至可能优于广播方式。由于通信双方可预先确定连接事件的时间点,无线模块可在非通信时段进入休眠状态,从而降低整体能耗。相比之下,广播方式需要持续在固定频率下发送完整负载,且不区分是否存在接收设备,这在数据量较大或通信频率较高的情况下可能导致额外的能耗开销。


    通用访问配置文件(GAP)

    GAP是BLE设备实现互操作性的基础框架。GAP为所有BLE实现定义了一套统一的行为规范,要求设备能够以标准化的方式完成设备发现、广播数据、建立连接、协商安全机制以及执行其他基础控制操作。对GAP的深入理解至关重要,因为在许多BLE协议栈实现中,GAP通常被作为向上层应用暴露的最底层功能接口之一,为应用开发者提供设备控制和连接管理能力。

    BLE核心规范中,GAP主要从以下几个方面定义了设备之间的交互行为:

    • 角色(Roles)每个BLE设备可以同时承担一个或多个角色。不同角色对设备行为施加特定的约束,并规定了设备在交互过程中的强制行为要求。某些角色组合允许设备之间建立通信关系,而GAP则精确定义了这些角色之间的交互方式。尽管并非绝对如此,角色通常与特定类型的设备或应用场景相关联。在大多数实际实现中,设备角色往往在其生命周期内保持稳定,并不会频繁发生变化。
    • 模式(Modes)模式是在角色之上的进一步抽象,用于描述设备在特定时间段内为实现某一目标而进入的运行状态。模式通常对应某一具体流程的执行条件,用以允许或限制设备在该阶段的行为。模式的切换既可由用户操作触发,也可由协议栈根据运行需要自动触发,其发生频率通常高于角色的切换。
    • 流程(Procedures)流程是指设备为达成特定目标而执行的一系列操作步骤,通常涉及链路层控制或必要的数据交换。一个流程往往依赖于对端设备处于特定模式之中,因此流程与模式在实际交互中通常紧密关联、协同执行。
    • 安全性(Security)GAP在底层安全管理协议之上,通过定义安全模式和安全等级,规定了设备在不同数据交换需求下应采用的安全策略,并描述了这些安全等级的建立与强制执行流程。此外,GAP还定义了一些不依赖于特定模式或流程的通用安全特性,使设备能够根据具体应用需求灵活提升数据保护级别。
    • GAP相关数据格式。为支持设备发现和控制流程,GAP定义了用于广播和扫描的附加数据格式,用以描述设备的基本属性、能力以及服务信息,从而确保设备之间能够以统一且可互操作的方式完成初始交互。

    属性配置文件(GATT)

    通用属性配置文件(Generic Attribute Profile,GATT)定义了如何在蓝牙低功耗(BLE)连接之上交换应用数据和用户数据。与负责设备发现、连接建立及控制行为的通用访问配置文件(GAP)不同,GATT专注于实际数据的组织方式、访问规则以及数据交换流程。

    GATT同时为基于GATT的各类标准配置文件提供了统一的参考框架。这些配置文件由蓝牙SIG定义,用于覆盖特定的应用场景,并确保来自不同厂商的设备能够实现互操作性。所有标准的BLE应用配置文件均建立在GATT之上,并必须遵循其定义的数据模型和操作规则。因此,GATT构成了BLE规范中至关重要的组成部分,所有面向应用和用户的数据项都必须依据GATT的规则进行格式化、封装并传输。

    GATT通过属性协议(Attribute Protocol,ATT)作为底层传输机制,实现设备之间的数据交换。数据在逻辑上按照层级结构进行组织,其中一组相关属性被封装为服务(Service),而服务中与具体用户数据语义相关的最小功能单元被定义为特征(Characteristic)。这一数据组织方式构成了GATT的基本结构模型。

    GATT角色(Roles)

    与蓝牙规范中其他协议和配置文件一致,GATT通过定义参与通信的角色来描述设备间的交互方式。

    • 客户端(Client)GATT客户端与属性协议(ATT)中的客户端角色相对应。客户端负责向服务器发送请求并接收响应,包括读取、写入属性值以及接收由服务器发起的属性更新。初始状态下,GATT客户端并不了解服务器端所包含的属性结构,因此必须首先执行服务发现(Service Discovery)流程,以获取服务器上可用服务及其特征的相关信息。完成服务发现后,客户端即可对已发现服务中的属性进行访问,并接收来自服务器的通知或指示。
    • 服务器(Server)GATT服务器对应于ATT中的服务器角色,负责接收来自客户端的请求并返回相应的响应。在特定配置下,服务器还可以主动向客户端发送属性更新。作为服务器角色,设备需要负责存储和组织用户数据,并以属性的形式向客户端提供访问接口。所有已投入使用的BLE设备至少必须包含一个基本的GATT服务器,以便能够响应客户端请求,即便该响应仅为错误返回。

    需要强调的是,GATT角色与GAP角色之间不存在依赖关系。GATT客户端或服务器角色可以与任意GAP角色组合使用。因此,GAP中的中心设备或外围设备既可以作为GATT客户端,也可以作为GATT服务器,甚至在同一设备中同时承担两种GATT角色。

    UUID(Universally Unique Identifier)

    通用唯一标识符(Universally Unique Identifier,UUID)是一种128位(16字节)的标识符,用于在全球范围内唯一标识对象。UUID并非蓝牙专有,其格式、生成方式和使用规则由ITU-T Rec. X.667(亦即 ISO/IEC 9834-8:2005)进行规范。

    由于完整的128位UUID在BLE链路层中会占用较大的数据负载空间,BLE规范引入了两种压缩形式的UUID:16位UUID和32位UUID。这些缩短形式仅适用于由蓝牙SIG定义和分配的标准UUID,用于标识标准服务、特征及相关类型。缩短形式的UUID可通过嵌入至蓝牙基础UUID(Bluetooth Base UUID)中恢复为完整的128位UUID,其格式如下:

    xxxxxxxx-0000-1000-8000-00805F9B34FB

    其中,xxxxxxxx表示16位或32位的SIG分配值(高位补零)。

    蓝牙SIG为所有标准服务、特征及其定义的配置文件分配了对应的UUID。当应用需求无法由现有标准UUID满足,或需要实现规范之外的自定义功能时,可生成厂商自定义(Vendor-Specific)的UUID。此类UUID必须使用完整的128位形式,并通常通过ITU所规定的UUID生成机制生成。对于不基于蓝牙基础UUID的厂商自定义UUID,不允许使用16位或32位的缩短形式,必须在整个协议交互过程中始终使用完整的128位UUID。


    启动一个广播服务

    // golang
    
    import (
        "github.com/muka/go-bluetooth/api"
        "github.com/muka/go-bluetooth/bluez/profile/adapter"
    )
    
    func main() {
        a, err := adapter.GetDefaultAdapter()
        if err != nil {
            panic(err)
        }
    
        a.SetPowered(true)
        a.SetDiscoverable(true)
        a.SetAlias("My-BLE-Device")
    
        select {}
    }