我使用go写了一个http转WebSocket服务,这是一个命令行程序,双击可以直接运行,今天,有个新的需求,需要将这个命令行程序转为dll,然后供第三方使用。

1,改造程序

这个命令行程序很小,核心逻辑就是启动了一个HTTP服务,然后接收连接,然后通过WebSocket将内容转发出去。所以,在调整为dll时,仅仅需要导出两个函数即可。

1,StartServer,启动http服务,监听端口地址。因为不能阻塞,所以需要在监听服务时使用go方法启动一个携程。
2,StopServer,关闭http服务,需要提供给第三方,当它关闭时,需要调用它,释放监听地址等资源。

下面开始改造代码:

package main

import "C" // 必须导入 C 包
import (
    // ... 原有导入 ...
)

// 保持原有的全局变量和函数逻辑 (例如transDataGet, connWs 等)
// 需要注意下面的//export这中间不能有空格,否则无法导出头文件

//export StartServer
func StartServer() {
    // 将原本 main 函数里的逻辑放在这里
    gin.SetMode(gin.ReleaseMode)
    r := gin.New()
    // ... 注册路由 ...
    r.Run("127.0.0.1:9988")
}

// 必须保留一个空的 main 函数
func main() {}

// 其它参考StartServer,如果需要导出,一定要添加//export

2,编译命令

我是在windows环境,需要安装cgo环境,我安装了Mingw-w64支持C编译环境,执行以下命令:

go build -buildmode=c-shared -o trans.dll main.go 

执行后会生成trans.h和trans.dll两个文件。

3,验证dll

除了一些可以查看dll的工具外,还可以使用第三方语言进行测试。例如我这里使用Python。

import ctypes
import time

lib = ctypes.CDLL("./trans.dll")
lib.StartServer()
print("服务已在后台启动...")

time.sleep(10) # 模拟第三方程序运行

lib.StopServer()
print("服务已关闭")

4,再次优化代码

当实际测试的时候,我发现它会阻塞调用者,为了解决这个问题,我们需要再次改造程序。将启动服务改为非阻塞模式。将Gin的启动逻辑放入协程,并利用http.Server提供的Shutdown方法来实现优雅退出。

package main

import "C"
import (
    // 原来的导入库
)
	 

var (
	srv *http.Server
)

//export StartServer
func StartServer() {
	if srv != nil {
		return // 避免重复启动
	}

	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	
	// 保持你原有的路由配置
	srv = &http.Server{
		Addr:    "127.0.0.1:9988",
		Handler: r,
	}

	// 关键:在协程中启动,不会阻塞调用方的 DLL 加载线程
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			// 实际生产中可以考虑将错误日志记录到文件
		}
	}()
}

//export StopServer
func StopServer() {
	if srv != nil {
		// 优雅关闭:给 5 秒时间处理未完成的请求
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		
		srv.Shutdown(ctx)
		disConnWs() // 关闭你的 WebSocket 连接
		srv = nil
	}
}

func main() {} // 必须保留空 main

改造完毕后,使用上面的编译命令再次编译。

这里解释一下:

  • StartServer:调用后立即返回,Gin 在后台运行。
  • StopServer:停止 HTTP 服务并清理 WebSocket。第三方程序在退出前必须调用此函数,否则可能导致进程残留。

继续使用Python验证,满足需求。

5,减少体积

当功能满足需求时,发现这个dll文件非常大,为了解决体积大的问题,可以在编译时添加这个参数:

go build -ldflags="-s -w" -buildmode=c-shared -o trans.dll main.go

这个可以减少dll文件大小。如果还嫌尺寸大,可以使用upx -9 trans.dll再次减少体积。

6,如何实现多实例

需求又增加了,需要开启多个服务。那么对于C或者第三方来说,就需要交换数据,实际的需求就是在DLL开发中,将Go对象(如WebSocket连接、HTTP Server等)以句柄(Handle)的形式返回给C/第三方,这是实现多实例管理、长期控制的专业做法。

其底层核心逻辑是:Go内部维护一个Map,Key为递增的整数(句柄),Value为对象指针。

1,句柄管理器的实现

需要建立一个全局的注册表,并配合读写锁(确保多线程安全)。

package main

import "C"
import (
	"sync"
	"net/http"
)

var (
	// 存储句柄与 Server 的对应关系
	instanceMap = make(map[int]*http.Server)
	instanceMu  sync.RWMutex
	nextHandle  = 1
)

// 内部函数:注册实例
func registerInstance(s *http.Server) int {
	instanceMu.Lock()
	defer instanceMu.Unlock()
	h := nextHandle
	instanceMap[h] = s
	nextHandle++
	return h
}

// 内部函数:获取实例
func getInstance(h int) *http.Server {
	instanceMu.RLock()
	defer instanceMu.RUnlock()
	return instanceMap[h]
}

// 内部函数:释放实例
func removeInstance(h int) {
	instanceMu.Lock()
	defer instanceMu.Unlock()
	delete(instanceMap, h)
}

2,修改导出函数返回句柄

将StartServer修改为返回一个int,供第三方保存。

//export StartServer
func StartServer() int {
	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	// ... 配置你的路由 ...

     
	s := &http.Server{
		Addr:    "127.0.0.1:9988",
		Handler: r,
	}

	go s.ListenAndServe()

	// 注册并返回句柄给 C
	return registerInstance(s)
}

//export StopServer
func StopServer(handle int) int {
	s := getInstance(handle)
	if s == nil {
		return -1 // 句柄无效
	}

	// 执行关闭逻辑
	s.Shutdown(context.Background())
	removeInstance(handle)
	return 0 // 成功
}

上面的问题是固定的端口地址会启动失败,需要调整:

package main

import "C"
import (
	// 导入库
)

var (
	// 使用 sync.Map 存储句柄与 http.Server 的映射,确保并发安全
	serverMap  sync.Map
	nextHandle int64 = 1
	mu         sync.Mutex
)

// 使用了C的数据类型

//export StartServer
func StartServer(port C.int) C.int {
	p := int(port)
	gin.SetMode(gin.ReleaseMode)
	
	r := gin.New()
	// 路由配置(此处复用你之前的逻辑)
	
	srv := &http.Server{
		Addr:    fmt.Sprintf("127.0.0.1:%d", p),
		Handler: r,
	}

	// 启动服务
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			fmt.Printf("Port %d error: %v\n", p, err)
		}
	}()

	// 生成并存储句柄
	mu.Lock()
	h := nextHandle
	nextHandle++
	mu.Unlock()

	serverMap.Store(h, srv)
	return C.int(h)
}

//export StopServer
func StopServer(handle C.int) C.int {
	h := int64(handle)
	val, ok := serverMap.Load(h)
	if !ok {
		return -1 // 句柄不存在
	}

	srv := val.(*http.Server)
	
	// 优雅关闭
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		return -2
	}

	serverMap.Delete(h)
	return 0 // 成功
}

func main() {}

这里使用C.int是为了类型安全,Go的int长度在64位系统上是8字节,而C语言的int通常固定为4字节。直接使用Go的int可能会导致内存对齐错误或数据截断。C.int会确保Go编译器生成的.h头文件使用标准C的int类型,这样第三方语言才能准确对应。

3,编译

确保使用64位编译以匹配Python环境:

set GOARCH=amd64
set CGO_ENABLED=1
go build -ldflags="-s -w" -buildmode=c-shared -o myserver.dll main.go

4,第三方(Python)如何使用句柄

第三方程序现在可以像控制窗口句柄一样控制你的服务:

import ctypes
import time
import requests

# 1. 加载 DLL
try:
    # 使用 CDLL 加载,cdecl 约定
    lib = ctypes.CDLL("./myserver.dll")
except Exception as e:
    print(f"加载失败: {e}")
    exit()

# 2. 启动两个不同端口的服务
print("--- 启动实例 1 ---")
handle1 = lib.StartServer(9988)
print(f"实例 1 启动,句柄 ID: {handle1}")

print("\n--- 启动实例 2 ---")
handle2 = lib.StartServer(9989)
print(f"实例 2 启动,句柄 ID: {handle2}")

time.sleep(1)  # 等待服务就绪

# 3. 验证服务是否独立运行
for port in [9988, 9989]:
    try:
        r = requests.get(f"http://127.0.0.1:{port}/")
        print(f"端口 {port} 响应: {r.text}")
    except Exception as e:
        print(f"端口 {port} 访问失败")

# 4. 根据句柄关闭实例 1
print("\n--- 停止实例 1 ---")
res = lib.StopServer(handle1)
print(f"停止操作返回码: {res} (0表示成功)")

# 5. 最终验证
try:
    requests.get("http://127.0.0.1", timeout=1)
except:
    print("端口 9988 已成功释放")

try:
    r = requests.get("http://127.0.0.1")
    print(f"端口 9989 依然存活: {r.text}")
    lib.StopServer(handle2)
    print("实例 2 已手动清理")
except:
    pass

使用ctypes加载DLL,并使用句柄控制两个不同端口的服务。

5,为什么使用映射表而不直接传指针?

  • 安全性:直接把 Go 的内存地址(指针)给 C 非常危险。一旦 Go 的垃圾回收(GC)移动了对象,指针就失效了。使用 整数 ID 作为句柄是工业界的标准做法。
  • 多实例支持:第三方可以同时启动多个 WS 转发服务(只要端口不同),并通过句柄分别控制。

6,关于 WebSocket 连接的句柄
同样的逻辑也可以应用在 wsConn 上。你可以提供一个 ConnectWS(url) 返回 connHandle,然后 SendMessage(connHandle, data)。这样你的 DLL 就不再是单例的,而是变成了一个功能强大的类库。

这样做的好处是你的 DLL 彻底变成了“无状态”的,所有的状态都由第三方通过句柄维护。

7,扩展内容,字符串处理

对于端口(数字),使用C.int很简单,但如果之后需要传入IP地址或URL(字符串),
需要在入参时使用*C.char,并用C.GoString(param)转换。
由第三方分配的字符串指针,Go只读不改是安全的。

那么如果Go要返回字符串给第三方呢?

这是最麻烦的地方,因为Go分配的内存地址会被Go GC回收。
推荐的方法是,不要让Go返回字符串,而是让第三方传入一个预分配好的缓冲区(Buffer),
让Go往里写。

这是一个示例:

package main

/*
#include <string.h>
*/
import "C"
import (
	"unsafe"
)

//export GetWsMessage
// handle: 句柄ID
// buffer: 调用方(Python)准备好的空字节数组指针
// bufSize: 缓冲区的大小
func GetWsMessage(handle C.int, buffer *C.char, bufSize C.int) C.int {
	// 1. 模拟获取数据(实际中你会从你的 map/chan 中读取)
	msg := "I am ok, now is " + "2026-01-02 15:04:05"
	
	// 2. 将 Go 字符串转为字节切片
	msgBytes := []byte(msg)
	msgLen := len(msgBytes)

	// 3. 检查缓冲区是否够大 (留 1 字节给 C 字符串的终止符 \0)
	if int(bufSize) <= msgLen {
		return C.int(-1) // 告诉调用方:空间不足
	}

	// 4. 将数据拷贝到 C 内存空间
	// 使用 unsafe 获取 C 指针对应的切片进行操作
	ptr := unsafe.Pointer(buffer)
	cSlice := (*[1 << 30]byte)(ptr)[:int(bufSize):int(bufSize)]
	
	copy(cSlice, msgBytes)
	cSlice[msgLen] = 0 // 必须手动加上 C 字符串的结束符

	return C.int(msgLen) // 返回实际写入的长度
}

func main() {}

需要接受一个缓冲区指针(C.char)和缓冲区的大小(C.int)。
其中ptr:=unsafe.Pointer(buffer)是将
C.char转换为unsafe.Pointer,
在Go中,unsafe.Ponter是所有指针的中转站,只有转成它,才能进行后续的强制类型转换。
(*[1<<30]byte)是定义了一个极大的数组类型即1GB。(ptr)是一个指针转换。
它的意思是:“告诉编译器,把ptr指向的地址看作是一个容量为1GB的数组的开头”。
这里设置这么大,是因为在编译阶段,Go需要知道这个数组的类型。由于C传过来的缓冲区大小是动态的,
由bufSize决定,我们在写代码时无法确定具体长度。于是我们声明一个极大的上限,确保后续的切片操作
不会超过这个定义的边界。注意,这个是类型声明,并不会真的分配1GB内存。
[:int(bufSize):int(bufSize)]这是Go的三元切片表达式,[low:high:max]
分别为从0开始,切片的长度,切片的容量。这步操作就是将那个虚构的1GB数组裁剪成了实际传入的bufSize大小的切片。这样,通过cSlice写入数据时,Go就能提供越界检查,确保安全。
如果使用Go1.17版本以上,可以使用标准库的unsafe.Slice,它完全替代了上面那行晦涩的代码,更安全也更好懂:

import "unsafe"

// 替代那行黑魔法
cSlice := unsafe.Slice((*byte)(unsafe.Pointer(buffer)), int(bufSize))

这就话就是告诉Go,从这个指针开始,帮我创建一个长度为bufSize的切片。
然后我们就可以使用copy将内容复制进去。

这里没有使用C.GoBytes是因为它会创建一个新的内存副本,而我们这里的需求是直接使用第三方提供的内存,所以直接操作指针。

下面是Python验证程序:

import ctypes

lib = ctypes.CDLL("./myserver.dll")

# 1. 准备一个 1024 字节的缓冲区
buf_size = 1024
buffer = ctypes.create_string_buffer(buf_size)

# 2. 调用 Go 函数
# 假设句柄为 1
actual_len = lib.GetWsMessage(1, buffer, buf_size)

if actual_len > 0:
    # 3. 从缓冲区读取内容 (.value 会根据 \0 自动截断)
    message = buffer.value.decode('utf-8')
    print(f"收到消息: {message}, 长度: {actual_len}")
elif actual_len == -1:
    print("缓冲区太小了!")
else:
    print("没有新消息")

我们再来一个更有难度的,如果处理二进制数据,那么它和普通字符串的最大区别在于:
不能依赖\0(Null Terminator)作为结束符。

在二进制模式下,必须通过显示返回数据的实际字节长度,让第三方根据长度来截取数据,
而不是读取到\0停止。

这是一个使用unsafe.Slice来操作内容的内容,在代码里,我们构造了一个包含0x00的消息。

package main

import "C"
import (
	"unsafe"
)

//export GetWsBinaryMessage
func GetWsBinaryMessage(handle C.int, buffer *C.char, bufSize C.int) C.int {
	// 1. 模拟二进制数据:[72, 101, 0, 108, 108, 111] -> "He\0llo"
	// 传统的 C 字符串只能读到 "He",剩下的 "llo" 会丢失
	binaryData := []byte{0x48, 0x65, 0x00, 0x6C, 0x6C, 0x6F, 0xFF, 0x00, 0xAA}
	dataLen := len(binaryData)

	// 2. 空间检查
	if int(bufSize) < dataLen {
		return -1 // 缓冲区不足
	}

	// 3. 直接操作内存(不拷贝,直接写)
	// 将 *C.char 转为 []byte 切片
	cSlice := unsafe.Slice((*byte)(unsafe.Pointer(buffer)), int(bufSize))
	
	// 4. 拷贝数据
	copy(cSlice, binaryData)

	// 5. 关键:返回实际的字节长度
	// 第三方必须依赖这个返回值来读取内存
	return C.int(dataLen)
}

func main() {}

在Python中,不能使用.value,因为.value会在第一个\0处截断。必须使用切片预防截取指定长度。

import ctypes

lib = ctypes.CDLL("./myserver.dll")

# 1. 准备缓冲区
buf_size = 1024
buffer = ctypes.create_string_buffer(buf_size)

# 2. 调用并获取实际长度
data_len = lib.GetWsBinaryMessage(1, buffer, buf_size)

if data_len > 0:
    # 3. 关键:使用 raw 属性并根据长度切片
    # .raw 返回整个缓冲区的原始字节流,我们只取前 data_len 个
    result_bytes = buffer.raw[:data_len]
    
    print(f"收到二进制长度: {len(result_bytes)}")
    print(f"原始字节 (Hex): {result_bytes.hex().upper()}")
    # 你会发现 00 成功传过来了,没有被截断
else:
    print("错误或无数据")

今天就到这里,消化一下。