Не нужны эти ваши плагины
Не нужны эти ваши плагины

Go многими воспринимается как средство для написания микросервисов, тем не менее, сам он является языком общего назначения и позволяет писать приложения любого рода, в том числе, поддерживающие модули расширения.

Примерно год назад передо мной встала задача добавить поддержку плагинов в приложение на Go. Задача стояла не особо остро, и руки до решения дошли только сейчас. Ответ, однако, оказался не столь очевидным, сколь хотелось бы. Идеального решения, как по мне, нет, поэтому попытаюсь максимально объективно рассмотреть все три имеющихся.

Содержание

Публикация вышла довольно объемная, поэтому для тех, кому интересны только общее описание, сравнения и выводы, прилагается содержание.

  1. Стандартный пакет plugin

    1. Итого

  2. RPC при помощи hashicorp/go-plugin

    1. Итого

  3. Динамическая подгрузка библиотек C

    1. Темная сторона

    2. CGO

      1. Вызов C-функций из Go

      2. Преобразование типов

      3. Передача указателей

    3. Контракт C

    4. Реализация плагина на Go

    5. Реализация основного приложения

    6. Итого

  4. Выводы

Стандартный пакет plugin

Гейтс о работе стандартных плагинов в Go
Гейтс о работе стандартных плагинов в Go

Думаю, что всем, кто пытался добавить поддержку системы плагинов для своего приложения на Go, первым в поиске попадался стандартный пакет plugin. К сожалению, он применим только в двух случаях:

  1. Вы не всегда хотите включать все модули приложения в поставку

  2. Вы хотите динамически подгружать нужные модули по мере необходимости

Собственно, все. Описывать работу с пакетом не вижу смысла - официальная документация снабдит отличными примерами и даст исчерпывающие ответы на все возможные вопросы. Как по мне, путаницу вносит само название пакета, ведь плагин, в привычном понимании, при помощи него создать невозможно. На нас действует серьезное ограничение: плагин должен быть собран тем же окружением, что и основное приложение. Имеются различия в версии компилятора - до свидания, версия плагина отличается от ожидаемой версии приложения - счастливого пути. Фактически, плагин может собрать только сам разработчик основного приложения, что убивает на корню все затею. Также очевидно, что плагин не может быть написан на другом языке. Тем не менее, есть и плюсы: пакет стандартный, поддерживается рантаймом самого языка.

Приложение с использованием стандарного пакета plugin, аналогичное по функционалу тому, что приведено в последней части этой статьи, можно посмотреть здесь.

Итого

Плюсы:

  • Входит в стандартную поставку

  • Поддерживается внутренними средствами языка

  • Позволяет передавать указатели

  • Используется единый рантайм

  • Плагин и приложение работают в едином адресном пространстве, не создается дополнительных процессов

Минусы:

  • Использует CGO, а потому потребует наличия кросс-компилятора C для сборки под другую платформу/архитектуру

  • Работает только на Linux, FreeBSD, macOS

  • Не дает возможности собирать плагины в другом окружении и другими разработчиками

  • Не дает возможности подключать плагины, собранные для другой версии приложения

  • Не дает возможности писать плагины на другом языке

  • Не позволяет динамически отключать подключенные плагины

RPC при помощи hashicorp/go-plugin

Если нельзя создать плагин - не используй плагины
Если нельзя создать плагин - не используй плагины

Как оказалось, вопросом подключения сторонних модулей к Go-приложению задались в HashiCorp более четырех лет назад. Выход из ситуации они нашли весьма очевидный и странный одновременно: если Go хорош для микросервисов - пусть плагины будут микросервисами!

Суть идеи довольно проста:

  1. Компилируем отдельно основное приложение и отдельно сервисы плагинов с единым контрактом net/rpc или gRPC

  2. Основное приложение запускает процессы с плагинами через exec.Command и устанавливает с ними связь по TCP через сокет

  3. Взаимодействие происходит через удаленный вызов процедур

Любое приложение с плагином состоит, как минимум, из трех-четырех пакетов, поэтому размещать здесь листинги считаю излишним. За официальными примерами кода можно проследовать сюда. За неофициальными - сюда и сюда.

Итого

Плюсы:

  • Не использует CGO, а значит, не имеет проблем с кроссплатформой

  • Позволяет писать и собирать плагины отдельно от основого приложения

  • Позволяет писать плагины на других языках (правда, нормальной документации на эту тему нет, так что придется поковырять исходники)

  • Позволяет динамически отключать подключенные плагины

Минусы:

  • Передача данных поверх TCP имеет существенные накладные расходы, а gRPC в моих тестах сработал даже медленнее net/rpc

  • Плагин запускается в виде отдельного процесса и взаимодействует с приложением через сокет

Динамическая подгрузка библиотек C

Я твой отец
Я твой отец

Компилятор Go умеет собирать не только исполняемые файлы и плагины, но и библиотеки C, а раз он умеет их собирать, то должен уметь и подгружать. Вырисовывается решение: используем динамически подключаемые библиотеки для взаимодействия с плагинами, получаем возможность собирать и поставлять плагины отдельно от основного приложения и вообще писать их на других языках. Звучит здорово, не правда ли? Не совсем... О том, как написать плагин в виде подключаемой библиотеки, не выстрелив себе в ногу, и порассуждаю ниже.

Как человек, исповедующий инженерный подход, я честно сперва пытался найти готовый пакет, позволяющий загружать динамические библиотеки, но ничего подходящего мне не попалось (если вам есть что посоветовать - жду в комментариях). Да, решения есть, но те, что удалось обнаружить, либо очень грубо реализуют обертки над функциями C, либо просто являются примерами с преамбулой на C (комментарий с C-кодом, расположенный перед импортом псевдо-пакета C). И все они используют libdl, то есть работают только в POSIX-совместимых системах. Мне же хотелось получить что-то похожее на работу стандартного пакета plugin, что-то, что будет инкапсулировать в себе хотя бы процессы загрузки и выгрузки библиотек.

В итоге, на основе кодовой базы стандартного пакета plugin, был реализован собственный пакет dlplugin. Этот пакет имеет достаточно простой интерфейс и реализует лишь несколько основных функций:

  1. Подгружает динамические C-библиотеки

  2. Предоставляет функцию поиска символов (symbol) в библиотеке для инициализации интерфейса плагина

  3. Выгружает динамические 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 <stdio.h>

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 <stdio.h>

#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 действуют два главных ограничения:

  1. Нельзя вызывать функции, принимающие переменное число аргументов (varargs)

  2. Нельзя вызывать указатели на функции, то есть переменная, хранящая указатель на функцию не может быть вызвана напрямую из 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 <func_name>, список функций и их имен уже определен выше в виде 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 <stddef.h>
#include <stdint.h>

// объявляем тип указателя на коллбек
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 <stddef.h>
#include <stdint.h>

// объявляем тип указателя на коллбек
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-функции плагина, а методы просто вызывают их (тела методов будут продемонстрированы в полном листинге). Но прежде чем использовать подобную структуру, ее необходимо инициализровать. Для этого необходимо реализовать интерфейс dlplugin.PluginInitializer:

type PluginInitializer interface {
  Init(lookup func(symName string) (uintptr, error)) error
}

Поэтому добавим метод Init к нашей структуре с соответствующей сигнатурой. Смысл в том, чтобы получить символ из библиотеки (в нашем случае, указатель на функцию) и обернуть его вызов.

Рассмотрим функцию free_device. Мы можем получить указатель на нее из библиотеки при помощи функции lookup, которая будет передана пакетом dlplugin в метод Init нашей структуры, но это указатель на C-функцию, а, как уже известно, указатель на C-функцию нельзя вызвать напрямую из Go. Следовательно, добавим C-преамбулу в наш пакет papi и напишем обертку для вызова free_device по указателю:

// подключаем заголовок с определением типа uintptr_t
#include <stdint.h>

// назовем функцию-обертку тем же именем для простоты
// здесь r - указатель на функцию библиотеки, а ptr - идентификатор экземпляра устройства
static int free_device(uintptr_t r, uintptr_t ptr)
{
  typedef int (*free_device_t)(uintptr_t); // определим тип указателя на функцию free_device из контракта плагина

  return ((free_device_t)r)(ptr); // приведем r к определенному выше типу и вызовем как функцию
}

Для краткости уберем опреление типа:

static int free_device(uintptr_t r, uintptr_t ptr)
{
  return ((int (*)(uintptr_t))r)(ptr);
}

Теперь инициализируем соответствующую функцию в структуре:

func (dev *DevicePlugin) Init(lookup func(symName string) (uintptr, error)) error {
  freeDevicePtr, err := lookup("free_device") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }

  dev.freeDevice = func(ptr uintptr) error {
    // вызываем C-обетку для вызова функции плагина по указателю
    cErr := int(C.free_device(C.uintptr_t(freeDevicePtr), C.uintptr_t(ptr))) 

    // обрабатываем код ошибки
    switch cErr {
    case 0:
      return nil
    case -1:
      return fmt.Errorf("could not access a device with id %d", ptr)
    default:
      return fmt.Errorf("unexpected error with a code %d", cErr)
    }
  }

  return nil
}

Остальные функции реализуются по аналогиии, за исключением get_device, которую рассмотрим подробней. Эта функция использует коллбек и передает в него буфер с данными, но, как мы знаем, нет никаких гарантий того, что буфер будет жив после окончания вызова коллбека, а это значит, что придется копировать данные из переданного буфера в локальный, контролируемый местным сборщиком мусора. Кроме того, мы знаем, что нельзя передать указатель на Go-функцию в C, поэтому применим cgo.Handle для обхода данного ограничения.

Идея работы с коллбеками состоит в том, чтобы отправлять на другую сторону (в плагин или из него) указатель на C-функцию, который не может измениться во время работы приложения, и числовой идентификатор, по которому можно будет получить доступ к Go-функции-коллбеку, но только на вызывающей стороне. Таким образом, мы не передаем реальный указатель на Go-функцию, но имеем возможность достать его из хранилища (cgo.Handle) во время вызова коллбека с другой стороны.

Начнем с определения типа для функции-коллбека:

type getDeviceCallbackFn func(data *C.char, size C.size_t)

Теперь определим обертку, которая будет вызывать наш коллбек. Из C нельзя вызывать функции Go напрямую, но ничего не мешает нам экспортировать Go-функции в C, как мы это делали с функциями плагина, и вызывать уже экспортированную функцию:

import "C"

// так как функция должна вызываться из C, типы ее аргуметов должны быть C-типами
//export GetDeviceCallback
func GetDeviceCallback(h C.uintptr_t, data *C.char, size C.size_t) {
  // приводим полученный числовой идентицикатор к типу cgo.Handle,
  // получаем значение идентификатора и приводим его к типу указателя на функцию-коллбек
  callback := cgo.Handle(h).Value().(getDeviceCallbackFn)

  callback(data, size) // вызываем Go-коллбек с соответствующими аргументами
}

Здесь также стоило бы добавить обертку для предотвращения паники, но оставим все как есть для простоты примера. Таким образом, функция GetDeviceCallback подразумевает, что до ее вызова уже был сгенерирован идентификатор, хранящий ссылку на указатель функции-коллбека. Со стороны C нам понадобится определить функцию-обертку, которая будет вызывать библиотечную функцию по указателю и передавать ей указатель на экспортированную Go-функцию в качестве аргумента-коллбека. Запишем это в преамбулу:

#include <stddef.h> // определяет тип size_t
#include <stdint.h> // определяет тип uintptr_t
#include <string.h> // определяет функцию memcpy (понадобится позднее)

// объявляем для C экспортированную Go-функцию, теперь ее можно вызывать из C-кода
extern void GetDeviceCallback(uintptr_t h, char *data, size_t size);

// назовем функцию-обертку тем же именем, что и функция плагина
// r - указатель на функцию библиотеки (плагина)
// ptr - идентификатор экземпляра устройства
// use_json - признак формата сериализации: бинарный или json-строка
// callback - идентификатор Go-функции-коллбека
static int get_device(uintptr_t r, uintptr_t ptr, char use_json, uintptr_t callback)
{
  typedef void (*get_device_callback_t)(uintptr_t h, char *, size_t); // определяем тип указателя на функцию-коллбек

  // вызываем библиотечкую функцию по указателю, передавая ей, в том числе,
  // указатель на экспортированную Go-функцию и идентификатор фактической Go-функции-коллбека
  return ((int (*)(uintptr_t, uintptr_t, char, get_device_callback_t))r)(ptr, callback, use_json, GetDeviceCallback);
}

Теперь добавим код в функцию инициализации, позволяющий прозрачно вызывать функцию плагина get_device:

func (dev *DevicePlugin) Init(lookup func(symName string) (uintptr, error)) error {
  // ...
  getDevicePtr, err := lookup("get_device") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }
  // ...

  dev.getDevice = func(ptr uintptr, useJson bool) ([]byte, error) {
    var encoded []byte

    // определяем лямбду-коллбек
    var cb getDeviceCallbackFn = func(data *C.char, size C.size_t) {
      encoded = make([]byte, size) // выделяем память нужного размера под буфер

      // копируем данные из буфера, переданного плагином, в локальный, управляемый местным сборщиком мусора
      C.memcpy(unsafe.Pointer(&encoded[0]), unsafe.Pointer(data), size)
    }

    cbHandle := cgo.NewHandle(cb) // создаем идентификатор указателя на лямбду
    defer cbHandle.Delete() // добавляем отложенный вызов, удаляющий идентификатор указателя на лямбду
    // это позволит сборщику мусора очистить память, когда лямбда-коллбек более не будет нужен

    var jsonMode C.char = 0 // из-за невозможности использовать C тип bool, будем использовать char

    if useJson {
      jsonMode = 1
    }

    // вызываем C-обетку для вызова функции плагина по указателю
    cErr := C.get_device(C.uintptr_t(getDevicePtr), C.uintptr_t(ptr), jsonMode, C.uintptr_t(cbHandle))

    // если результат меньше нуля - это код ошибки, если больше - размер буфера в байтах
    if cErr < 0 {
      // обрабатываем код ошибки
      switch cErr {
      case -1:
        return nil, fmt.Errorf("could not access a device with id %d", ptr)
      case -2:
        return nil, fmt.Errorf("could not encode a device with id %d", ptr)
      default:
        return nil, fmt.Errorf("unexpected error with a code %d", cErr)
      }
    }

    // возвращаем сериализованное состояние
    return encoded, nil
  }

  // ...
  return nil
}
Итоговый листинг пакета papi

C-преамбула вынесена здесь отдельно для отображения с подсветкой синтаксиса.

// file: papi/device_plugin.go:c-preamble

#include <stddef.h> // определяет тип size_t
#include <stdint.h> // определяет тип uintptr_t
#include <string.h> // определяет функцию memcpy (понадобится позднее)

// объявляем для C экспортированную Go-функцию, теперь ее можно вызывать из C-кода
extern void GetDeviceCallback(uintptr_t h, char *data, size_t size);

static uintptr_t create_device(uintptr_t r)
{
  return ((uintptr_t (*)())r)();
}

// назовем функцию-обертку тем же именем для простоты
// здесь r - указатель на функцию библиотеки, а ptr - идентификатор экземпляра устройства
static int free_device(uintptr_t r, uintptr_t ptr)
{
  return ((int (*)(uintptr_t))r)(ptr);
}

// назовем функцию-обертку тем же именем, что и функция плагина
// r - указатель на функцию библиотеки (плагина)
// ptr - идентификатор экземпляра устройства
// use_json - признак формата сериализации: бинарный или json-строка
// callback - идентификатор Go-функции-коллбека
static int get_device(uintptr_t r, uintptr_t ptr, char use_json, uintptr_t callback)
{
  typedef void (*get_device_callback_t)(uintptr_t h, char *, size_t); // определяем тип указателя на функцию-коллбек

  // вызываем библиотечкую функцию по указателю, передавая ей, в том числе,
  // указатель на экспортированную Go-функцию и идентификатор фактической Go-функции-коллбека
  return ((int (*)(uintptr_t, uintptr_t, char, get_device_callback_t))r)(ptr, callback, use_json, GetDeviceCallback);
}

static int device__print(uintptr_t r, uintptr_t self)
{
  return ((int (*)(uintptr_t))r)(self);
}

static int device__value(uintptr_t r, uintptr_t self, int32_t* value)
{
  return ((int (*)(uintptr_t, int32_t*))r)(self, value);
}

static int device__set_value(uintptr_t r, uintptr_t self, int32_t value)
{
  return ((int (*)(uintptr_t, int32_t))r)(self, value);
}
// file: papi/device_plugin.go

package papi

/*
// сюда подставляется код C-преамбулы
*/
import "C"
import (
  "fmt"
  "runtime/cgo"
  "unsafe"
)

type getDeviceCallbackFn func(data *C.char, size C.size_t)

// Структура, реализующая интерфейс плагина
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 {
  return dev.createDevice()
}

func (dev *DevicePlugin) FreeDevice(ptr uintptr) error {
  return dev.freeDevice(ptr)
}

func (dev *DevicePlugin) GetDevice(ptr uintptr, useJson bool) (encoded []byte, err error) {
  return dev.getDevice(ptr, useJson)
}

func (dev *DevicePlugin) Device_Print(self uintptr) error {
  return dev.device_Print(self)
}

func (dev *DevicePlugin) Device_Value(self uintptr) (value int32, err error) {
  return dev.device_Value(self)
}

func (dev *DevicePlugin) Device_SetValue(self uintptr, value int32) error {
  return dev.device_SetValue(self, value)
}

func (dev *DevicePlugin) Init(lookup func(symName string) (uintptr, error)) error {
  createDevicePtr, err := lookup("create_device") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }

  freeDevicePtr, err := lookup("free_device") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }

  getDevicePtr, err := lookup("get_device") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }

  devicePrintPtr, err := lookup("device__print") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }

  deviceValuePtr, err := lookup("device__value") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }

  deviceSetValuePtr, err := lookup("device__set_value") // ищем символ в динамической библиотеке
  if err != nil {
    return err
  }

  dev.createDevice = func() uintptr {
    // вызываем C-обетку для вызова функции плагина по указателю
    return uintptr(C.create_device(C.uintptr_t(createDevicePtr)))
  }

  dev.freeDevice = func(ptr uintptr) error {
    // вызываем C-обетку для вызова функции плагина по указателю
    cErr := int(C.free_device(C.uintptr_t(freeDevicePtr), C.uintptr_t(ptr)))

    // обрабатываем код ошибки
    switch cErr {
    case 0:
      return nil
    case -1:
      return fmt.Errorf("could not access a device with id %d", ptr)
    default:
      return fmt.Errorf("unexpected error with a code %d", cErr)
    }
  }

  dev.getDevice = func(ptr uintptr, useJson bool) ([]byte, error) {
    var encoded []byte

    // определяем лямбду-коллбек
    var cb getDeviceCallbackFn = func(data *C.char, size C.size_t) {
      encoded = make([]byte, size) // выделяем память нужного размера под буфер

      // копируем данные из буфера, переданного плагином, в локальный, управляемый местным сборщиком мусора
      C.memcpy(unsafe.Pointer(&encoded[0]), unsafe.Pointer(data), size)
    }

    cbHandle := cgo.NewHandle(cb) // создаем идентификатор указателя на лямбду
    defer cbHandle.Delete() // добавляем отложенный вызов, удаляющий идентификатор указателя на лямбду
    // это позволит сборщику мусора очистить память, когда лямбда-коллбек более не будет нужен

    var jsonMode C.char = 0 // из-за невозможности использовать C тип bool, будем использовать char

    if useJson {
      jsonMode = 1
    }

    // вызываем C-обетку для вызова функции плагина по указателю
    cErr := C.get_device(C.uintptr_t(getDevicePtr), C.uintptr_t(ptr), jsonMode, C.uintptr_t(cbHandle))

    // если результат меньше нуля - это код ошибки, если больше - размер буфера в байтах
    if cErr < 0 {
      // обрабатываем код ошибки
      switch cErr {
      case -1:
        return nil, fmt.Errorf("could not access a device with id %d", ptr)
      case -2:
        return nil, fmt.Errorf("could not encode a device with id %d", ptr)
      default:
        return nil, fmt.Errorf("unexpected error with a code %d", cErr)
      }
    }

    // возвращаем сериализованное состояние
    return encoded, nil
  }

  dev.device_Print = func(self uintptr) error {
    // вызываем C-обетку для вызова функции плагина по указателю
    cErr := int(C.device__print(C.uintptr_t(devicePrintPtr), C.uintptr_t(self)))

    // обрабатываем код ошибки
    switch cErr {
    case 0:
      return nil
    case -1:
      return fmt.Errorf("could not access a device with id %d", self)
    default:
      return fmt.Errorf("unexpected error with a code %d", cErr)
    }
  }

  dev.device_Value = func(self uintptr) (int32, error) {
    // объявляем переменную C типа int32_t, чтобы передать указатель на нее в функцию-обертку
    var value C.int32_t

    // вызываем C-обетку для вызова функции плагина по указателю
    cErr := int(C.device__value(C.uintptr_t(deviceValuePtr), C.uintptr_t(self), &value))

    // обрабатываем код ошибки
    switch cErr {
    case 0:
      return int32(value), nil
    case -1:
      return int32(value), fmt.Errorf("could not access a device with id %d", self)
    default:
      return int32(value), fmt.Errorf("unexpected error with a code %d", cErr)
    }
  }

  dev.device_SetValue = func(self uintptr, value int32) error {
    // вызываем C-обетку для вызова функции плагина по указателю
    cErr := int(C.device__set_value(C.uintptr_t(deviceSetValuePtr), C.uintptr_t(self), C.int32_t(value)))

    // обрабатываем код ошибки
    switch cErr {
    case 0:
      return nil
    case -1:
      return fmt.Errorf("could not access a device with id %d", self)
    default:
      return fmt.Errorf("unexpected error with a code %d", cErr)
    }
  }

  return nil
}

// так как функция должна вызываться из C, типы ее аргуметов должны быть C-типами
//export GetDeviceCallback
func GetDeviceCallback(h C.uintptr_t, data *C.char, size C.size_t) {
  // приводим полученный числовой идентицикатор к типу cgo.Handle,
  // получаем значение идентификатора и приводим его к типу указателя на функцию-коллбек
  callback := cgo.Handle(h).Value().(getDeviceCallbackFn)

  callback(data, size) // вызываем Go-коллбек с соответствующими аргументами
}

Основной файл проекта (main.go) не требует каких-либо детальных пояснений, его листинг с подробными комментариями приведен ниже.

Листинг основного файла проекта, при помощи dlplugin будем подключать и инициализировать плагин
// file: main.go

package main

import (
  "flag"
  "fmt"
  "os"

  "github.com/Devoter/dlplugin"

  // пути этих импортов будут отличаться для вашего проекта
  "github.com/Devoter/dlplugin_multilib_example/device"
  "github.com/Devoter/dlplugin_multilib_example/papi"
)

func main() {
  // задаем флаги командной строки
  libraryFilename := flag.String("plug", "", "plugin library filename") // путь к библиотеке-плагину
  setValue := flag.Int64("val", -120, "value to be set")                // пользовательское значение

  flag.Parse() // читаем флаги

  if *libraryFilename == "" { // если имя файла плагина не задано
    fmt.Fprintf(os.Stderr, "empty plugin filename\n") // выводим ошибку
    os.Exit(2)                                        // завершаем приложение
  }

  var papi1 papi.DevicePlugin // создаем экземпляр структуры, реализующей контракт плагина
  // и интерфейс dplugin.PluginInitializer

  // подключаем библиотеку и инициализируем контракт плагина
	// можно было бы в качестве второго аргумента передать nil и
	// инициализировать контракт плагина позднее, вызывав метод plug.Init(&papi)
  plug, err := dlplugin.Open(*libraryFilename, &papi1)
  if err != nil { // в случае возникновения ошибки
    fmt.Fprintf(os.Stderr, "could not open a library, error=[%v]\n", err) // выводит ошибку
    os.Exit(1)                                                            // завершаем приложение
  }
  defer plug.Close() // добавляем отложенный вызов отключения библиотеки

  dev := papi1.CreateDevice() // создаем экземпляр устройства
  defer papi1.FreeDevice(dev) // добавляем отложенный вызов особождения памяти экземпляра устройства

  fmt.Printf("Setting a value to %d\n", int32(*setValue))
  // устанавливаем значение устройства равное заданному пользователем
  if err := papi1.Device_SetValue(dev, int32(*setValue)); err != nil {
    fmt.Fprintf(os.Stderr, "could not set a value by the reason: %v\n", err)
  }

  fmt.Printf("Printing a value\n")
  // вызываем метод устройства, выводящий значение устройства на экран
  if err := papi1.Device_Print(dev); err != nil {
    fmt.Fprintf(os.Stderr, "could not print a value by the reason: %v\n", err)
  }

  fmt.Printf("Loading a value\n")
  // читаем значение из устройства
  value, err := papi1.Device_Value(dev)
  // выводим полученное значение и ошибку, если таковая имеется
  fmt.Printf("Loaded value: %d, error [%v]\n", value, err)

  fmt.Printf("Getting a binary state\n")
  // читаем сериализованное в бинаром виде состояние устройства
  encoded, err := papi1.GetDevice(dev, false)
  if err != nil {
    fmt.Fprintf(os.Stderr, "could not load an encoded device.Device by the reason: %v\n", err)
  } else {
    var d device.Device

    // декодируем бинарное состояние
    if err := d.UnmarshalBinary(encoded); err != nil {
      fmt.Fprintf(os.Stderr, "could not decode a device.Device by the reason: %v\n", err)
    } else {
      fmt.Println("decoded value", d.Value())
    }
  }

  fmt.Printf("Getting a JSON state\n")
  // читаем сериализованное с JSON-строку состояние устройства
  encoded, err = papi1.GetDevice(dev, true)
  if err != nil {
    fmt.Fprintf(os.Stderr, "could not load an encoded device.Device by the reason: %v\n", err)
  } else {
    var d device.Device

    // декодируем JSON
    if err := d.UnmarshalJSON(encoded); err != nil {
      fmt.Fprintf(os.Stderr, "could not decode a device.Device by the reason: %v\n", err)
    } else {
      fmt.Println("decoded value", d.Value())
    }
  }
}

Никаких дополнительных флагов при сборке указывать не требуется, при вызове указываем значения для флагов plug и val:

go run . -plug goplug/libgoplug.so -val 11

Более простые примеры доступны в директории examples пакета dlplugin.

Итого

Плюсы:

  • Позволяет передавать указатели в/из плагина для обработки (с нюансами, подробно рассмотренными выше)

  • Позволяет писать и собирать плагины отдельно от основого приложения

  • Позволяет использовать плагины, написанные на других языках

  • Позволяет динамически отключать подключенные плагины

  • Плагин и приложение работают в едином адресном пространстве, не создается дополнительных процессов

  • Передача данных и вызовы на порядок быстрее, чем реализация поверх TCP, соответственно, проще переварить интенсивное взаимодействие

Минусы:

  • Использует CGO, а потому потребует наличия кросс-компилятора C для сборки под другую платформу/архитектуру

  • Работает только на Linux, FreeBSD, macOS (пока что)

  • Существенно медленнее происходит вызов и передача данных, нежели при использовании стандартного пакета plugin

  • Требуется базовое знание C, нюансов взаимодействия C и Go, повышенная внимательность при реализации вызовов функций плагина

  • Не может использовать единый рантайм даже если и плагин, и основное приложение написаны на Go

Выводы

Как и говорилось в самом начале: идеального решения нет, у каждого из трех рассмотренных есть свои плюсы и минусы. При правильно подобранной архитектуре скорость передачи данных между плагином и основным приложением перестает играть решающую роль, ведь, зачастую, задача динамической библиотеки состоит либо в том, чтобы получить какие-то входные данные и выполнить работу и вернуть результат, либо в том, чтобы работать в качестве автономного модуля, с которым взаимодействие происходит через некий интерфейс. Если ваши задачи не укладываются в одно из этих двух вариантов, то стоит задуматься:

  1. Правильно ли выбрана вами архитектура приложения?

  2. Подходит ли Go для решения вашей задачи?

Выше рассматривается именно динамическое подключение библиотеки, что является весьма специфичной задачей. На самом деле, чаще можно обойтись более простыми способами, о которых доступна масса информации, в том числе и здесь - на хабре.

Надеюсь, что те, кто осилил данный лонгрид смогли почерпнуть для себя что-то полезное. Конструктивная критика в комментариях приветствуется. Полные примеры с подключением нескольких плагинов одновременно, реализацией плагина на C++ (работает быстрее Go-плагина и весит меньше), реализациями посредством пакетов plugin и hashicorp/go-plugin, а также простыми бенчмарками для всех вариантов, доступны здесь. Спасибо за внимание.

Комментарии (10)


  1. gecube
    25.01.2022 13:23

    Получается, проблема плагинов остро стоит только в случае бинарной дистрибуции голанг программ? Потому что если есть исходный код - его всегда можно модифицировать, дописав недостающий функционал в виде отдельных файлов. Вообще насколько часто реально голанг программы поставляются в виде бинарей? Может проблемы вовсе нет? Тот же хашикорп - прекрасно мог бы быть собран на месте, но они сделали хитрее :-) За что им честь и уважуха. Опять же накладные расходы на grpc (в конце-концов можно и через файловый сокет гонять, а не через сеть) не выглядят сильно высокими, в случае если плагин выполняет много работы.


    1. Devoter Автор
      25.01.2022 13:50
      +1

      Надеюсь, вы дочитали до конца, потому что согласен с вами почти полностью. Единственное, что хотелось бы сказать: приложения на Go не обязаны быть open source.


      1. gecube
        25.01.2022 13:51

        честно - первые две части осилил, третью уже смотрел по диагонали, TL;DR случился. Мотанул до выводов.


      1. youROCK
        25.01.2022 23:14

        В каком-то смысле должны быть. Собрать приложение на Go с использованием бинарных пакетов больше не выйдет: эту возможность, насколько я помню, относительно недавно выпилили (https://github.com/golang/go/issues/28152 ?). В целом вся система сборки построена вокруг сборки из исходников.

        То есть, да, есть плагины, и можно, конечно же, собрать своё приложение из исходных кодов, которые не в опенсорсе, но при этом это всё равно будет сборка из исходных кодов, а не бинарников.

        Эта статья показывает, насколько Go, в общем-то, практически принуждает всё делать с открытыми исходниками, за счёт отсутствия стабильного ABI :)


  1. xakep666
    25.01.2022 20:54
    +1

    Есть же вот такая библиотека https://github.com/Binject/universal, где, в том числе, реализована работа на M1 и винде.


    1. Devoter Автор
      25.01.2022 21:56

      Спасибо за ссылку - интересное решение. Только лицензия расстроила. Надо будет сравнить в аналогичных тестах.


      1. gecube
        25.01.2022 22:01

        а что не так с лицензией? GPL3.0 не понравился?


        1. Devoter Автор
          25.01.2022 22:14
          +1

          Она не нравится или не нравится, а просто ограничивает применимость в некоторых коммерческих проектах. Если вы пишете только открытое ПО ещё и получаете за это деньги, то искренне рад за вас.

          Ну и так немного почитал - пока не понял - можно ли там провернуть все, что нужно - смутило то, что список аргументов для метода Call имеет тип ...uintptr, а в примере передается просто число 7 константой, но пока спишу на то, что нужно поглубже вникнуть.


  1. serjeant
    26.01.2022 11:57

    Потрясающая статья! Аплодирую стоя!!! Сам как раз начал разбираться в этом вопросе - плагины и CGO. Так что ваша статья очень мне помогла! Благодарю


    1. Devoter Автор
      26.01.2022 14:50

      Спасибо, рад, что кому-то моя работа принесла пользу.