Go и плагины
Не нужны эти ваши плагины
Go многими воспринимается как средство для написания микросервисов, тем не менее, сам он является языком общего назначения и позволяет писать приложения любого рода, в том числе, поддерживающие модули расширения.
Примерно год назад передо мной встала задача добавить поддержку плагинов в приложение на Go. Задача стояла не особо остро, и руки до решения дошли только сейчас. Ответ, однако, оказался не столь очевидным, сколь хотелось бы. Идеального решения, как по мне, нет, поэтому попытаюсь максимально объективно рассмотреть все три имеющихся.
Содержание
Публикация вышла довольно объемная, поэтому для тех, кому интересны только общее описание, сравнения и выводы, прилагается содержание.
Стандартный пакет plugin
Итого
RPC при помощи hashicorp/go-plugin
Итого
Динамическая подгрузка библиотек C
Темная сторона
CGO
Вызов C-функций из Go
Преобразование типов
Передача указателей
Контракт C
Реализация плагина на Go
Реализация основного приложения
Итого
Выводы
Стандартный пакет plugin
Гейтс о работе стандартных плагинов в Go
Думаю, что всем, кто пытался добавить поддержку системы плагинов для своего приложения на Go, первым в поиске попадался стандартный пакет plugin. К сожалению, он применим только в двух случаях:
Вы не всегда хотите включать все модули приложения в поставку
Вы хотите динамически подгружать нужные модули по мере необходимости
Собственно, все. Описывать работу с пакетом не вижу смысла — официальная документация снабдит отличными примерами и даст исчерпывающие ответы на все возможные вопросы. Как по мне, путаницу вносит само название пакета, ведь плагин, в привычном понимании, при помощи него создать невозможно. На нас действует серьезное ограничение: плагин должен быть собран тем же окружением, что и основное приложение. Имеются различия в версии компилятора — до свидания, версия плагина отличается от ожидаемой версии приложения — счастливого пути. Фактически, плагин может собрать только сам разработчик основного приложения, что убивает на корню все затею. Также очевидно, что плагин не может быть написан на другом языке. Тем не менее, есть и плюсы: пакет стандартный, поддерживается рантаймом самого языка.
Приложение с использованием стандарного пакета plugin
, аналогичное по функционалу тому, что приведено в последней части этой статьи, можно посмотреть здесь.
Итого
Плюсы:
Входит в стандартную поставку
Поддерживается внутренними средствами языка
Позволяет передавать указатели
Используется единый рантайм
Плагин и приложение работают в едином адресном пространстве, не создается дополнительных процессов
Минусы:
Использует CGO, а потому потребует наличия кросс-компилятора C для сборки под другую платформу/архитектуру
Работает только на Linux, FreeBSD, macOS
Не дает возможности собирать плагины в другом окружении и другими разработчиками
Не дает возможности подключать плагины, собранные для другой версии приложения
Не дает возможности писать плагины на другом языке
Не позволяет динамически отключать подключенные плагины
RPC при помощи hashicorp/go-plugin
Если нельзя создать плагин — не используй плагины
Как оказалось, вопросом подключения сторонних модулей к Go-приложению задались в HashiCorp более четырех лет назад. Выход из ситуации они нашли весьма очевидный и странный одновременно: если Go хорош для микросервисов — пусть плагины будут микросервисами!
Суть идеи довольно проста:
Компилируем отдельно основное приложение и отдельно сервисы плагинов с единым контрактом net/rpc или gRPC
Основное приложение запускает процессы с плагинами через exec.Command и устанавливает с ними связь по TCP через сокет
Взаимодействие происходит через удаленный вызов процедур
Любое приложение с плагином состоит, как минимум, из трех-четырех пакетов, поэтому размещать здесь листинги считаю излишним. За официальными примерами кода можно проследовать сюда. За неофициальными — сюда и сюда.
Итого
Плюсы:
Не использует CGO, а значит, не имеет проблем с кроссплатформой
Позволяет писать и собирать плагины отдельно от основого приложения
Позволяет писать плагины на других языках (правда, нормальной документации на эту тему нет, так что придется поковырять исходники)
Позволяет динамически отключать подключенные плагины
Минусы:
Передача данных поверх TCP имеет существенные накладные расходы, а gRPC в моих тестах сработал даже медленнее net/rpc
Плагин запускается в виде отдельного процесса и взаимодействует с приложением через сокет
Динамическая подгрузка библиотек C
Я твой отец
Компилятор Go умеет собирать не только исполняемые файлы и плагины, но и библиотеки C, а раз он умеет их собирать, то должен уметь и подгружать. Вырисовывается решение: используем динамически подключаемые библиотеки для взаимодействия с плагинами, получаем возможность собирать и поставлять плагины отдельно от основного приложения и вообще писать их на других языках. Звучит здорово, не правда ли? Не совсем… О том, как написать плагин в виде подключаемой библиотеки, не выстрелив себе в ногу, и порассуждаю ниже.
Как человек, исповедующий инженерный подход, я честно сперва пытался найти готовый пакет, позволяющий загружать динамические библиотеки, но ничего подходящего мне не попалось (если вам есть что посоветовать — жду в комментариях). Да, решения есть, но те, что удалось обнаружить, либо очень грубо реализуют обертки над функциями C, либо просто являются примерами с преамбулой на C (комментарий с C-кодом, расположенный перед импортом псевдо-пакета C
). И все они используют libdl, то есть работают только в POSIX-совместимых системах. Мне же хотелось получить что-то похожее на работу стандартного пакета plugin
, что-то, что будет инкапсулировать в себе хотя бы процессы загрузки и выгрузки библиотек.
В итоге, на основе кодовой базы стандартного пакета plugin
, был реализован собственный пакет dlplugin. Этот пакет имеет достаточно простой интерфейс и реализует лишь несколько основных функций:
Подгружает динамические C-библиотеки
Предоставляет функцию поиска символов (symbol) в библиотеке для инициализации интерфейса плагина
Выгружает динамические C-библиотеки
Также есть наброски реализации для MS Windows, но попытка запустить собранную при помощи кросс-компиляции пару (приложение и библиотека) не увенчалась успехом. Так как сам я под Windows не пишу, и разворачивать окружение на целевой платформе для отладки одного пакета было лень, то на данный момент реализация для Windows остается под вопросом, поэтому, если у кого-то возникнет желание доделать начатое — буду рад принять pull request на github.
Будет справедливо заметить, что библиотека должна не только загружаться и выгружаться, но еще и выполнять какую-то полезную работу. Понятное дело, универсальный пакет не может ничего знать об интерфейсе конкретного плагина, а, следовательно, не может его самостоятельно инициализировать. (На самом деле, мог бы — задача решается кодогенерацией, но это — тема для отдельной статьи.) Разработчику приложения в любом случае придется иметь дело с CGO.
Темная сторона
Итак, добро пожаловать на темную сторону. Я постараюсь продемонстрировать процесс написания приложения и библиотеки с плагином на синтетическом примере, с которым можно ознакомиться по ссылке.
Предположим, что наше приложение работает с некими устройствами (Device
), эти устройства хранят в себе значения типа int32
имеют методы для изменения, чтения и печати значения, также есть методы, позволяющие получить сериализованное состояние устройства в бинарном виде или в JSON:
type GenericDevice interface {
MarshalBinary() ([]byte, error)
MarshalJSON() ([]byte, error)
Value() int32
SetValue(value int32)
Print()
}
Кроме того, нам понадобятся функции, которые будут создавать экземпляр устройства и освобождать его, когда оно нам более не потребуется:
func CreateDevice() *Device
func FreeDevice(ptr *Device)
Реализации могут быть разными и должны подключаться динамически в виде библиотек, а это значит, что нам нужно спроектировать C-интерфейс библиотеки. Для начала представим, как подобный интерфейс мог бы выглядеть в Go:
type GenericDevicePlugin interface {
func CreateDevice() *Device
func FreeDevice(ptr *Device)
func GetDevice(ptr *Device, useJSON bool) ([]byte, error)
func Device_Value(self *Device) int32
func Device_SetValue(self *Device, value int32)
func Device_Print(self *Device)
}
Так как внутреннее представление типа Device
может отличаться от библиотеки к библиотеке, а тип interface{}
невозможно передать в C, будем использовать вместо него числовой идентификатор, для этих целей отлично подойдет тип uintptr
. Дело в том, что передавать в C можно только те Go-указатели, которые не хранят в себе других Go-указателей. Любой интерфейс всегда содержит указатель на исходное значение, а значит не может быть передан в C, многомерные массивы ([][]int
) и структуры, содержащие указатели (type struct { v *int }
), также не могут быть переданы. (Если я в чем-то ошибся при штудировании документации к CGO — прошу более сведущих в вопросе поправить меня.) Также учтем, что может быть передан некорректный идентификатор в любой из методов интерфейса плагина, а значит, может быть возвращена ошибка. Учтем все сказанное выше:
type GenericDevicePlugin interface {
func CreateDevice() uintptr
func FreeDevice(ptr uintptr) error
func GetDevice(ptr uintptr, useJSON bool) ([]byte, error)
func Device_Value(self uintptr) (int32, error)
func Device_SetValue(self uintptr, value int32) error
func Device_Print(self uintptr) error
}
Такой интерфейс допустим для Go, но не для C. C не может возвращать несколько значений (можно было бы завернуть их в структуру, но лучше поступить более традиционным способом — передать указатель на значение, которое необходимо заполнить). Именно здесь мы ступаем на шаткую дорожку, держа в руке заряженный пистолет, направленный в собственную ногу. Начнем с того, что встроенный тип Go int
и тип C.int
— разные типы, потому, если функция C ожидает типа int *
, то из Go нужно передавать *C.int
, привести int
к C.int
и наоборот нет проблем, а вот приведение типов указателей уже так просто не провернуть, но мы вполне можем провернуть следующий трюк при вызове C-функции: (*C.int)(unsafe.Pointer(&intVal))
. В C unsafe.Pointer
эквивалентен типу void *
, то есть — нетипизированный указатель, поэтому можно привести любой указатель к unsafe.Pointer
, а unsafe.Pointer
привести к любому указателю. Здесь, пожалуй, стоит остановиться и кратко рассмотреть работу с CGO.
CGO
Я настоятельно рекоммендую самостоятельно тщательно ознакомиться с официальной документацией к CGO, здесь же лишь попытаюсь продемонстрировать несколько примеров и указать на узкие моменты. Тем же, кто хорошо знаком с нюансами работы с CGO предлагаю перейти сразу к следующей части.
Весь C-код, используемый в пакете, должен быть размещен в виде С-преамбулы (понятие из официальной документации) — комментария перед импортом псевдо-пакета C
, который не может быть сгруппирован с другими импортами. Это, конечно, не означает того, что можно работать только с кодом из комментария, никто не запрещает подключать любые заголовки и библиотеки, причем, если разместить C-код отдельно и включить его при помощи директивы #include
в преамбуле Go-пакета, то C-код из этой же директории должен быть автоматически скомпилирован при пересборке пакета. Для обращения к функциям и типам C нужно использовать имя псевдо-пакета: C.
.
Вызов C-функций из Go
Рассмотрим небольшой пример:
package main
/*
#include
void print_argc(int l)
{
printf("%d\n", l);
}
*/
import "C"
import "os"
func main() {
C.print_argc(C.int(len(os.Args)))
}
Альтернативный вариант, код C вынесен в отдельные файлы:
// file: print_argc.h
#ifndef PRINT_ARGC_H
#define PRINT_ARGC_H
void print_argc(int l);
#endif
// file: print_argc.c
#include
#include "print_argc.h"
void print_argc(int l)
{
printf("%d\n", l);
}
// file: main.go
package main
// #include "print_argc.h"
import "C"
import "os"
func main() {
C.print_argc(C.int(len(os.Args)))
}
ПРИМЕЧАНИЕ: Для запуска или сборки второго примера нужно указывать не main.go
, а путь к директории или .
: go run .
, в противном случае C-код не будет скопилирован, и компилятор выдаст ошибку на этапе линковки.
При вызове C-функций из Go действуют два главных ограничения:
Нельзя вызывать функции, принимающие переменное число аргументов (varargs)
Нельзя вызывать указатели на функции, то есть переменная, хранящая указатель на функцию не может быть вызвана напрямую из Go-кода как функция
Преобразование типов
Стоит отметить, что все типы C являются приватными, а значит не могут быть использованы за пределами пакета, в котором происходит взаимодействие с C. Так тип C.int
в пакете a
и тип C.int
в пакете b
— разные типы данных. Рассматривать приложения, состоящие из одного пакета не будем, а значит, необходимо преобразовывать типы для экспортируемых значений. Вообще, таким образом, создатели Go намекают нам, что нужно четко обозначить водораздел — пакет, в котором происходит интеграция с C-кодом. Можно считать это, в некотором смысле, транспортным уровнем архитектуры.
C-структуру нельзя присвоить Go-структуре, придется делать это вручную:
typedef struct {
int x
int y
} MyStruct;
void init_my_struct(struct MyStruct *ms, int x, int y)
{
ms->x = x;
ms->y = y;
}
type MyStruct struct {
x int
y int
}
// ... где-то внутри функции
var cms C.MyStruct
C.init_my_struct(&cms, 10, 10)
var goms MyStruct
goms.x = int(cms.x)
goms.y = int(cms.y)
Несколько иначе дело обстоит с массивами и строками. Для работы со строками и массивами байт вообще имеется несколько специальных функций:
// Возвращает копию Go-строки в C-строке.
func C.CString(string) *C.char
// Возвращает копию среза байт Go в виде массиса C с типом void *.
func C.CBytes([]byte) unsafe.Pointer
// Возвращает копию C-строки в Go-строке.
func C.GoString(*C.char) string
// Возвращает Go-строку из массива C с указанием длины
func C.GoStringN(*C.char, C.int) string
// Возвращает срез байт из массива C с указанием длины
func C.GoBytes(unsafe.Pointer, C.int) []byte
ПРИМЕЧАНИЕ: Под C-строкой подразумевается массив байт, оканчивающийся нулевым символом ('\0'
).
C.CString()
и C.CBytes()
выделяют память в куче при помощи malloc
, поэтому необходимо вызывать C.free
для особождения памяти (при этом, в преамбуле C должен быть подключен заголовок stdlib.h
).
Для массивов таких специальных функций не предусмотрено, зато для преобразования массива или среза в C-массив достаточно привести указатель на первый элемент массива к unsafe.Pointer
, а затем — к нужному типу C-указателя, например:
arr := []int{1, 2, 3}
C.do_something((*C.int)(unsafe.Pointer(&arr[0])))
Передача указателей
ПРИМЕЧАНИЕ: На самом деле типы Go могут быть переданы в C, для них будет генерироваться отдельное определение типа, например, int
станет GoInt
. Однако с точки зрения построения приложения с плагинами, которые могут быть реализованы на других языках, протекание Go-типов в C-код — не лучшая идея.
Вот мы и подошли вплотную к самой главной сложности взаимодействия между Go и C. Здесь нужно понимать главную разницу между C-указателями и Go-указателями: первые контролируются разработчиком и, если память выделена на стеке, она автоматически будет освобождена при выходе из соответствующей функции, если же память выделена в куче при помощи *alloc
-функции, то особождение памяти должно производиться вручную, вызовом free
; за выделение и особождение памяти в Go отвечает сборщик мусора, который работает независимо от основного кода приложения. Таким образом, возможна следующая ситуация:
arr := []int{1, 2, 3}
cArr := (*C.char)(unsafe.Pointer(&arr[0]))
// где-то здесь отработал сборщик мусора
C.do_something(cArr) // Уппс, эта строчка может вызвать падение приложения
Дело в том, что приведение типа к unsafe.Pointer
разрывает связь с исходным массивом, на который ссылается срез, и, если срез более нигде в функции не используется, то память может быть очищена сборщиком мусора в любой момент. Таким образом, наличие переменной cArr
не гарантирует того, что память не будет особождена. Однако, здесь мы можем использовать следующий трюк: если произвести приведение типа непосредственно при вызове функции, то сборщик мусора не осободит память тех переменных, что переданы в качестве аргументов, до завершения исполнения вызываемой функции. (Опять же, если есть мнение, что я неправильно понял данный нюанс — просьба оповестить в комментариях.) Безопасный вариант передачи будет выглядеть так:
arr := []int{1, 2, 3}
C.do_something((*C.char)(unsafe.Pointer(&arr[0])))
Также не стоит забывать о том, что массивы C, в отличие от массивов и срезов Go, не хранят в себе длину, ее нужно передавать отдельно:
arg := []int{1, 2, 3}
C.do_something((*C.char)(unsafe.Pointer(&arr[0])), C.size_t(len(arg)))
В тех случаях, когда переменная используется и после вызова C-функции, таких проблем не возникает. Также имеется специальная функция пакета runtime
: KeepAlive. Смысл в том, чтобы вызвать runtime.KeepAlive
и передать ей в качестве аргумента переменную, которая с этого момента может быть освобождена:
arr := []int{1, 2, 3}
cArr := (*C.char)(unsafe.Pointer(&arr[0]))
C.do_something(cArr) // теперь все хорошо, вызов KeepAlive расположен ниже
runtime.KeepAlive(arr) // после этого момента память может быть освобождена
C.do_something(cArr) // а вот здесь вновь нет гарантий, что память еще не освобождена - можем "запаниковать"
Кроме того, если требуется передать указатель надолго, и гарантировать, что память, на которую он указывает, не будет особождена, можно самостоятельно гарантировать сохранность объекта в памяти, скажем, помещая его в глобальную переменную/срез/карту, либо, используя cgo.NewHadle (), но работу с этой возможностью рассмотрим ниже при организации работы функции обратного вызова (callback).
Все сказанное выше касается и любых типов указателей, не только указателей на массивы и срезы.
Контракт C
Вернемся к нашему интерфейсу плагина.
type GenericDevicePlugin interface {
func CreateDevice() uintptr
func FreeDevice(ptr uintptr) error
func GetDevice(ptr uintptr, useJSON bool) ([]byte, error)
func Device_Value(self uintptr) (int32, error)
func Device_SetValue(self uintptr, value int32) error
func Device_Print(self uintptr) error
}
Так как интерфейс должен быть представлен не в Go, а в C, то оформим его для себя визуально в виде соответствующего заголовочного файла. Встроенного типа ошибок в C нет, поэтому, для простоты будем возвращать целочисленный код ошибки. Также, несмотря на наличие типа bool
в стандартной библиотеке C, мне не удалось использовать его, подключив соответствующий заголовок (stddef.h
), поэтому в качестве булевого параметра будем использовать тип char
:
extern uintptr_t create_device();
extern int free_device(uintptr_t ptr);
extern int get_device(uintptr_t ptr, char use_json, char *buf);
extern int device__value(uintptr_t self, int32_t *value);
extern int device__set_value(uintptr_t self, int32_t value);
extern int device__print(uintptr_t self);
Выглядит неплохо, если не считать одного момента: передавая указатель на буфер мы не можем знать — какой именно объем памяти необходимо выделить. Можно было бы решить эту проблему, передавая в качестве дополнительного аргумента функцию-аллокатор, либо предванительно запрашивая необходимый размер буфера. В данном случае поступим иначе — пускай плагин сам выделяет память под буфер, а со стороны приложения передадим коллбек. Впомним, что нужно не просто вызвать какой-то коллбек, но и привязать его к конкретному экземпляру устройства. К сожалению, указатель на функцию Go нельзя напрямую передать в C, а C не поддерживает лямбда-функции, поэтому передадим, кроме того, ссылку-идентификатор на нужную лямбда-функцию Go в качесте дополнительного агумента (ниже рассмотрим способ создания и работы с такими идентификаторами). Итоговый вариант нашего контракта будет следующим:
// определим тип указателя на C-коллбек
typedef void (*get_device_callback_t)(uintptr_t, char *, size_t);
extern uintptr_t create_device();
extern int free_device(uintptr_t ptr);
extern int get_device(uintptr_t ptr, uintptr_t cb_id, char use_json, get_device_callback_t callback);
extern int device__value(uintptr_t self, int32_t *value);
extern int device__set_value(uintptr_t self, int32_t value);
extern int device__print(uintptr_t self);
Реализация плагина на Go
Начнем с наиболее простой части — реализации плагина на Go. Для начала опишем пакет device
, который будет содержать реализацию устройства:
Код пакета device
// file: device/device.go
package device
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"unsafe"
)
type Device struct {
val int32
}
// Возвращает экземпляр устройства.
func NewDevice() *Device {
return &Device{}
}
// Возвращает текущее значение.
func (d *Device) Value() int32 {
return d.val
}
// Меняет теущее значение на заданное.
func (d *Device) SetValue(v int32) {
d.val = v
}
// Выводит текущее значение на экран.
func (d *Device) Print() {
fmt.Println(d.val)
}
// Сериализует значение в бинарный буфер и возвращает его.
func (d Device) MarshalBinary() ([]byte, error) {
b := make([]byte, unsafe.Sizeof(d.val))
binary.LittleEndian.PutUint32(b, uint32(d.val))
return b, nil
}
// Десериализует значение из бинарного буфера.
func (d *Device) UnmarshalBinary(data []byte) error {
if len(data) != int(unsafe.Sizeof(d.val)) {
return errors.New("incompatible data size")
}
d.val = int32(binary.LittleEndian.Uint32(data))
return nil
}
// Сериализует значение в json-строку и возвращает в виде буфера.
func (d *Device) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`{"val":%d}`, d.val)), nil
}
// Десериализует значение из json-строки.
func (d *Device) UnmarshalJSON(data []byte) error {
// поленимся и воспользуемся рефлексией, чтобы не писать декодирование вручную
type tmpt struct {
Val int32 `json:"val"`
}
var tmp tmpt
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
d.val = tmp.Val
return nil
}
Теперь приступим к написанию основного пакета плагина (main
). Для того, чтобы Go-функции были доступны в качестве C-функций библиотеки, достаточно предварить их заголовок комментарием //export
, список функций и их имен уже определен выше в виде C-заголовка, поэтому приступим к реализации.
Первая задача, которую необходимо решить — создание и удаление экземпляров устройств. Мы могли бы завести глобальную карту map[uintptr]*Device
и хранить экземпляры там, но воспользуемся более простым методом — используем cgo.Handle
. При каждом вызове cgo.NewHandle
с передачей переменной в качестве параметра создается и возвращается идентификатор на ресурс, хранящийся в переменной, а сам ресурс становится достижим, а, следовательно, не будет удален сборщиком мусора, до момента удаления всех идентификаторов посредством метода h.Delete()
и прямых ссылок на этот ресурс.
ПРИМЕЧАНИЕ: Множество вызовов cgo.NewHandle
может возвращать разные значения.
Реализуем функции плагина для создания и удаления экзепляра устройства:
// глобальный мьютекс для поддержки параллельного выполнения
var mx sync.RWMutex
//export create_device
func create_device() C.uintptr_t {
dev := device.NewDevice() // создаем экземпляр устройства
// получаем идентификатор ресурса и предохраняем экземпляр
// от удаления сборщиком мусора
h := cgo.NewHandle(dev)
return C.uintptr_t(h) // возвращаем числовой идентификатор
}
//export free_device
func free_device(ptr C.uintptr_t) C.int {
mx.Lock() // блокируем взаимодействие с устройствами для других сопрограмм/горутин
h, _, err := getDeviceHandle(ptr) // получаем идентификатор устройства
if err != nil { // если идентификатор устройства не найден, то
mx.Unlock() // разблокируем доступ к устройствам для других сопрограмм
return -1 // возвращаем код ошибки
}
h.Delete() // удаляем идентификатор, теперь память может быть освобождена
mx.Unlock() // разблокируем доступ к устройствам для других сопрограмм
return 0 // возвращаем нулевой код ошибки
}
Если сами функции create_device
и free_device
довольно просты и не требует пояснений, то реализацию функции getDeviceHandle
стоит рассмотреть подробней:
func getDeviceHandle(ptr C.uintptr_t) (cgo.Handle, *device.Device, error) {
h := cgo.Handle(ptr) // приводим числовой идентификатор к типу cgo.Handle
var dev *device.Device
var err error
// пытаемся получить доступ к экземпляру устройства по идентификатору
func() {
// так как метод Value типа cgo.Handle вызывает панику при попытке получить
// значение неверного идентификатора, то перехватим подобную ситуацию
defer func() {
if msg := recover(); msg != nil {
err = fmt.Errorf("%v", msg)
}
}()
var ok bool
dev, ok = h.Value().(*device.Device) // восстанавливаем значение ресурса по идентификатору
// и приводим его к типу указателя на экземпляр устройства
if !ok { // если тип значения ресурса не совпадает с типом *device.Device
err = fmt.Errorf("unexpected value type") // задаем значение ошибки
}
}()
// возвращаем идентификатор, указатель на экземпляр устройства и ошибку
if err != nil {
return h, nil, err
}
return h, dev, nil
}
Для того, чтобы наш плагин не вызывал панику при передаче неверного идентификатора экземпляра устройства при вызове какого-либо метода, обернем вызов h.Value()
в функцию с возможностью восстановить работу после паники.
В остальных функциях плагина сам идентификатор нам не требуется, поэтому добавим еще одну функцию, упрощающую получение указателя на экземпляр устройства по числовому идентификатору:
func getDevice(ptr C.uintptr_t) (*device.Device, error) {
_, dev, err := getDeviceHandle(ptr)
return dev, err
}
Реализация всех функций нашего контракта тривиальна, за исключением get_device
, потому ее мы детально рассмотрим сейчас, а код остальных функций можно будет посмотреть в итоговом листинге основного пакета плагина.
Итак, функция get_device
, согласно контракту, должна иметь следующую сигнатуру:
typedef void (*get_device_callback_t)(uintptr_t, char *, size_t);
extern int get_device(uintptr_t dev, uintptr_t cb_id, char use_json, get_device_callback_t callback);
Go не может вызвать C-функцию по указателю, а вот сам C может, поэтому напишем функцию-обертку, принимающую указатель на коллбек и список его параметров и вызывающую его внутри. Модификатор static
говорит о том, что функция не будет видна за пределами самого объектного файла. Напишем преамбулу C, а затем включим ее в итоговый листинг:
// подключаем стандантные типы, такие как size_t и uintptr_t
#include
#include
// объявляем тип указателя на коллбек
typedef void (*get_device_callback_t)(uintptr_t id, char *, size_t);
// определяем C-функцию, которая вызывает коллбек через указатель
static void call_back(get_device_callback_t cb, uintptr_t id, char * data, size_t size)
{
cb(id, data, size);
}
Теперь напишем саму функцию get_device
. Идея проста: получаем устройство по числовому идентификатору, сериализуем его в бинарный вид или JSON-строку в зависимости от значения аргумента useJSON
, вызываем обертку call_back
, в которая, в свою очередь, вызовет реальный коллбек:
//export get_device
func get_device(ptr C.uintptr_t, cbID C.uintptr_t, useJSON C.char, callback C.get_device_callback_t) C.int {
mx.RLock() // блокируем взаимодействие с устройствами для других сопрограмм/горутин
dev, err := getDevice(ptr) // получаем указатель на экземпляр устройства по числовому идентификатору
if err != nil { // если указатель не удалось получить
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return -1 // возвращаем код ошибки
}
var encoded []byte
if useJSON != 0 {
encoded, err = dev.MarshalJSON() // сериализуем в json-строку
} else {
encoded, err = dev.MarshalBinary() // сериализуем в бинарный вид
}
if err != nil { // если возникла ошибка
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return -2 // возвращаем код ошибки
}
// доступ к экземпляру устройства более не требуется
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
// вызываем обертку над коллбеком и передаем ей указатель на фактический коллбек,
// указатель на буфер с сериализованными данными и длину буфера в байтах
C.call_back(callback, cbID, (*C.char)(unsafe.Pointer(&encoded[0])), C.size_t(len(encoded)))
return 0 // возвращаем нулевой код ошибки
}
Полный листинг пакета main плагина:
C-преамбула вынесена здесь отдельно для отображения с подсветкой синтаксиса.
// file: goplug/main.go:c-preamble
// подключаем стандантные типы, такие как size_t и uintptr_t
#include
#include
// объявляем тип указателя на коллбек
typedef void (*get_device_callback_t)(uintptr_t id, char *, size_t);
// определяем C-функцию, которая вызывает коллбек через указатель
static void call_back(get_device_callback_t cb, uintptr_t id, char * data, size_t size)
{
cb(id, data, size);
}
// file: goplug/main.go
package main
/*
// сюда подставляется код C-преамбулы
*/
import "C"
import (
"fmt"
"runtime/cgo"
"sync"
"unsafe"
// подключаем пакет device, у вас будет иметь другой путь
"github.com/Devoter/dlplugin_multilib_example/device"
)
// глобальный мьютекс для поддержки параллельного выполнения
var mx sync.RWMutex
//export create_device
func create_device() C.uintptr_t {
dev := device.NewDevice() // создаем экземпляр устройства
// получаем идентификатор ресурса и предохраняем экземпляр
// от удаления сборщиком мусора
h := cgo.NewHandle(dev)
return C.uintptr_t(h) // возвращаем числовой идентификатор
}
//export free_device
func free_device(ptr C.uintptr_t) C.int {
mx.Lock() // блокируем взаимодействие с устройствами для других сопрограмм/горутин
h, _, err := getDeviceHandle(ptr) // получаем идентификатор устройства
if err != nil { // если идентификатор устройства не найден, то
mx.Unlock() // разблокируем доступ к устройствам для других сопрограмм
return -1 // возвращаем код ошибки
}
h.Delete() // удаляем идентификатор, теперь память может быть освобождена
mx.Unlock() // разблокируем доступ к устройствам для других сопрограмм
return 0 // возвращаем нулевой код ошибки
}
//export get_device
func get_device(ptr C.uintptr_t, cbID C.uintptr_t, useJSON C.char, callback C.get_device_callback_t) C.int {
mx.RLock() // блокируем взаимодействие с устройствами для других сопрограмм/горутин
dev, err := getDevice(ptr) // получаем указатель на экземпляр устройства по числовому идентификатору
if err != nil { // если указатель не удалось получить
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return -1 // возвращаем код ошибки
}
var encoded []byte
if useJSON != 0 {
encoded, err = dev.MarshalJSON() // сериализуем в json-строку
} else {
encoded, err = dev.MarshalBinary() // сериализуем в бинарный вид
}
if err != nil { // если возникла ошибка
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return -2 // возвращаем код ошибки
}
// доступ к экземпляру устройства более не требуется
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
// вызываем обертку над коллбеком и передаем ей указатель на фактический коллбек,
// указатель на буфер с сериализованными данными и длину буфера в байтах
C.call_back(callback, cbID, (*C.char)(unsafe.Pointer(&encoded[0])), C.size_t(len(encoded)))
return 0 // возвращаем нулевой код ошибки
}
//export device__print
func device__print(self C.uintptr_t) C.int {
mx.RLock() // блокируем взаимодействие с устройствами для других сопрограмм/горутин
dev, err := getDevice(self) // получаем указатель на экземпляр устройства по числовому идентификатору
if err != nil { // если указатель не удалось получить
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return -1 // возвращаем код ошибки
}
dev.Print() // выводим значение, хранящееся в экземпляре на экран
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return 0 // возвращаем нулевой код ошибки
}
//export device__value
func device__value(self C.uintptr_t, value *C.int32_t) C.int {
mx.RLock() // блокируем взаимодействие с устройствами для других сопрограмм/горутин
dev, err := getDevice(self) // получаем указатель на экземпляр устройства по числовому идентификатору
if err != nil { // если указатель не удалось получить
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return -1 // возвращаем код ошибки
}
*value = C.int32_t(dev.Value()) // записываем в результат значение, хранящееся в экземпляре
mx.RUnlock() // разблокируем доступ к устройствам для других сопрограмм
return 0 // возвращаем нулевой код ошибки
}
//export device__set_value
func device__set_value(self C.uintptr_t, value C.int32_t) C.int {
mx.Lock() // блокируем взаимодействие с устройствами для других сопрограмм/горутин
dev, err := getDevice(self) // получаем указатель на экземпляр устройства по числовому идентификатору
if err != nil { // если указатель не удалось получить
mx.Unlock() // разблокируем доступ к устройствам для других сопрограмм
return -1 // возвращаем код ошибки
}
dev.SetValue(int32(value)) // записываем указанное значение в экземпляр устройства
mx.Unlock() // разблокируем доступ к устройствам для других сопрограмм
return 0 // возвращаем нулевой код ошибки
}
func getDeviceHandle(ptr C.uintptr_t) (cgo.Handle, *device.Device, error) {
h := cgo.Handle(ptr) // приводим числовой идентификатор к типу cgo.Handle
var dev *device.Device
var err error
// пытаемся получить доступ к экземпляру устройства по идентификатору
func() {
// так как метод Value типа cgo.Handle вызывает панику при попытке получить
// значение неверного идентификатора, то перехватим подобную ситуацию
defer func() {
if msg := recover(); msg != nil {
err = fmt.Errorf("%v", msg)
}
}()
var ok bool
dev, ok = h.Value().(*device.Device) // восстанавливаем значение ресурса по идентификатору
// и приводим его к типу указателя на экземпляр устройства
if !ok { // если тип значения ресурса не совпадает с типом *device.Device
err = fmt.Errorf("unexpected value type") // задаем значение ошибки
}
}()
// возвращаем идентификатор, указатель на экземпляр устройства и ошибку
if err != nil {
return h, nil, err
}
return h, dev, nil
}
func getDevice(ptr C.uintptr_t) (*device.Device, error) {
_, dev, err := getDeviceHandle(ptr)
return dev, err
}
Для сборки Go-плагина достаточно выполнить следующую команду:
go build -buildmode=c-shared -o libgoplug.so .
На этом процедура создания плагина законечена.
Реализация основного приложения
Библиотека с плагином готова, осталось ее к чему-нибудь подключить. Определим задачи приложения: оно должно динамически подгружать плагин, создавать устройство и проверять работу его методов.
В более сложных приложениях можно было бы наплодить дополнительных уровней абстрации: создать интерфейс (GenericDevice
) и структуру-обертку, которая вызывала бы непосредственные функции интерфейса плагина, реализуя этот интерфейс. Мы же, для простоты и наглядности примера, поступим проще, и реализуем лишь интерфейс самого плагина в виде структуры и ее методов. Для этого создадим пакет papi
(Plugin API).
Можно было бы определить отдельно интерфейс плагина, который выглядит следующим образом (но, в данном случае, он нам не понадобится и приведен здесь для наглядности):
type DevicePluginAPI interface {
CreateDevice() uintptr
FreeDevice(ptr uintptr) error
GetDevice(ptr uintptr, useJson bool) (encoded []byte, err error)
Device_Print(self uintptr) error
Device_Value(self uintptr) (value int32, err error)
Device_SetValue(self uintptr, value int32) error
}
Поэтому сразу определим его реализацию в виде структуры DevicePlugin
и ее методов:
type DevicePlugin struct {
createDevice func() uintptr
freeDevice func(ptr uintptr) error
getDevice func(ptr uintptr, useJson bool) (encoded []byte, err error)
device_Print func(self uintptr) error
device_Value func(self uintptr) (value int32, err error)
device_SetValue func(self uintptr, value int32) error
}
func (dev *DevicePlugin) CreateDevice() uintptr
func (dev *DevicePlugin) FreeDevice(ptr uintptr) error
func (dev *DevicePlugin) GetDevice(ptr uintptr, useJson bool) (encoded []byte, err error)
func (dev *DevicePlugin) Device_Print(self uintptr) error
func (dev *DevicePlugin) Device_Value(self uintptr) (value int32, err error)
func (dev *DevicePlugin) Device_SetValue(self uintptr, value int32) error
Поля структуры содержат функции-обертки, вызывающие непосредственно C-функции плагина, а методы просто вызывают их (тела методов будут продемонстрированы в полном листинге). Но прежде чем использовать подобную структуру, ее необходимо инициализров