Как я встраивал ресурсы в Go %

Golang

Во время стажировки в WSO2, я работал над проектом разработки процессов непрерывной интеграции и развёртывания ПО для WSO2 API Manager. Работа велась в основном на Golang.

При разработке инструментов нам хотелось пройти фазу инициализации проекта средствами интерфейса командной строки. А это требует немалых работ по созданию кода.

Вначале всё было отлично: те немногочисленные файлы, которые у нас были, мы сохраняли как срезы байтов и затем обрабатывали.

// определяем значения по умолчанию, другие ресурсы в пакете по умолчанию
package defaults
var ConfigTemplate = []byte(`
  // содержимое идёт сюда
`)

// В каком-то другом пакете
func somefunc(){
  ioutil.WriteFile("/path/to/dest", defaults.ConfigTemplate, os.ModePerm)
}

В конце мешанина полная.

Кошмар обратных апострофов««`

По мере роста проекта увеличивалось и его содержимое, которое нам надо было сохранять. Сложности добавляло то, что практически всё оно было в Go-пакетах: всякий раз приходилось редактировать его вручную.

Однажды мне захотелось использовать разметку для файла README, автоматически появляющегося на этапе инициализации проекта. До этого создавался обычный текстовый файл.

В разметке обратные апострофы используются для кодовых блоков. Go использует их для неформатированных строк. Я никак не мог понять, как их избежать. Это была катастрофа!

Мы не могли отправлять эти файлы отдельно, так как упор делался на небольшие средства интерфейса командной строки. Поэтому я продолжал поиски методов для встраивания ресурсов в Go.

Имеющиеся решения

Конечно, есть готовые решения типа Go.rice, но для моей задачи они были слишком сложными и громоздкими. Всё, что мне требовалось, — это перевести ресурсы в Go-файлы и иметь к ним доступ.

Go generate приходит на помощь

Go — замечательный язык программирования, способный генерировать код. Так почему бы его не задействовать? Если вы ещё не знаете, что представляет собой go generate, загляните на официальный блог. Go generate использует специальную волшебную строку для определения файлов, которые нужно генерировать.

//go:generate <command> <args>

При выполнении go generate filename.go компилятор проверяет наличие волшебной строки и выполняет соответствующую команду. go generate ./… запускает команду, выискивая файлы с волшебной строкой на всём протяжении проекта.

Go generate запускает команду применительно к директории, содержащей файл с волшебной строкой.

Раз уж нам надо сделать генерацию кода универсальной, почему бы не использовать для этого сам go?

Схема

Архитектура нашей коробки ресурсов

Основным требованием было сохранять файлы как байты и иметь доступ к ним в Go-файлах. Для выполнения такого требования создан пакет с названием box. Он играл роль посредника между потребителями и источником данных. Мы сохраняем все наши файлы в специальной директории под названием resources внутри пакета box. Там же могут быть директории для организации содержимого.

Директория ресурсов со всеми нужными нам файлами

Таким образом здорово упрощается редактирование содержимого — ведь это всего лишь файлы в системе.

//go:generate go run gen.go

package box

type resourceBox struct {
 storage map[string][]byte
}

func newResourceBox() *resourceBox {
 return &resourceBox{storage: make(map[string][]byte)}
}

// Находим файл
func (r *resourceBox) Has(file string) bool {
 if _, ok := r.storage[file]; ok {
 return true
 }
 return false
}

// Получаем содержимое файла
func (r *resourceBox) Get(file string) ([]byte, bool) {
 if f, ok := r.storage[file]; ok {
 return f, ok
 }
 return nil, false
}

// Добавляем файл в коробку
func (r *resourceBox) Add(file string, content []byte) {
 r.storage[file] = content
}

// Раскрытие ресурсов
var resources = newResourceBox()

// Получаем файл из коробки
func Get(file string) ([]byte, bool)  {
 return resources.Get(file)
}

// Добавляем содержимое файла в коробку
func Add(file string, content []byte) {
 resources.Add(file, content)
}

// Файл в коробке
func Has(file string) bool {
 return resources.Has(file)
}

(box/defaults.go приходит на помощь.)

Этот Go-файл очень простой. Он помещает map внутрь ResourceBox, раскрывая все методы на уровне пакета, так что вы можете вызывать их, где захотите с помощью box.Get(‘filename’). Круто!

Объект resource имеет в пакете ключевое значение: в нём находятся все ресурсы для нашей коробки.

И вот оно волшебство со специальным комментарием //go:generate go run gen.go.

Во время вызова go generate ./…, файл обнаруживается, что приводит к запуску другой программы go.

Генератор

Теперь самое интересное: нам надо cчитать директорию ресурсов и сохранить содержимое файлов в ResourceBox. Для этого запускаем ещё один Go-файл под названием gen.go, который находится в той же package box.

Как было замечено выше, go generate способен выполнять данную программу применительно к директории, где найден файл с волшебной строкой, поэтому go run gen.go будет выполняться внутри директории box. То есть мы без проблем сможем считывать все файлы в директории ресурсов.

Мы создадим файл blob.go со всем нужным нам содержимым внутри того же пакета.

Поскольку gen.go — программа, нам нужно сообщить Go, что это не часть нашей компоновки.

Поэтому добавим

//+build ignore

в начале файла, чтобы компилятор проигнорировал это в компоновке.

Теперь пробежимся по всем файлам в директории ресурсов и преобразуем их в go-файлы.

resources := make(map[string][]byte)
err := filepath.Walk("resources", func(path string, info os.FileInfo, err error) error {
 if err != nil {
 log.Println("Error :", err)
 return err
 }
 relativePath := filepath.ToSlash(strings.TrimPrefix(path, "resources"))
 if info.IsDir() {
 log.Println(path, "is a directory, skipping...")
 return nil
 } else {
 log.Println(path, "is a file, baking in...")
 b, err := ioutil.ReadFile(path)
 if err != nil {
 log.Printf("Error reading %s: %s", path, err)
 return err
 }
 resources[relativePath] = b
 }
 return nil
 })

 if err != nil {
 log.Fatal("Error walking through resources directory:",err)
 }

(Часть gen.go, которая проходит дерево файлов, считывая и сохраняя каждый файл).

В go есть отличный способ разобраться с этим, используя метод filepath.Walk. Мы просто проходимся по всем файлам внутри директории ресурсов (включая все поддиректории) и сохраняем их в карте resources. Доступ к файлам будем осуществлять с помощью относительного пути к директории ресурсов.

Например, для доступа к файлу в resources/init/sample.yaml можем использовать интуитивно понятный box.Get(‘/init/sample.yaml’). Но что будет, если выполнять генерацию в Windows?

В этом случае воспользуемся функцией ToSlash в пакете filepath, которая преобразует собственный разделитель пути платформы в слеш.

filepath.ToSlash(strings.TrimPrefix(path, "resources"))

Теперь надо создать blob.go со всеми имеющимися у нас данными. Просто добавляем все данные к ресурсному объекту в пакете box.

Вызываем resources.Add(‘/file/path’, data). Далее помещаем это в метод init() в blob.go, который инициализируется сразу для всего пакета.

package box
func init() {
   resources.Add("/init/README.md", []byte{50, 70, 80, })
   resources.Add("/init/api_params.tmpl", []byte{100, 110, 125,})
   ...
}

Для этого просто используем шаблоны Go Templates, как показано ниже.

var packageTemplate = template.Must(template.New("").Funcs(map[string]interface{}{"conv": FormatByteSlice}).Parse(`// Код, сгенерированный с помощью go generate; НЕ РЕДАКТИРОВАТЬ.
// сгенерирован с помощью файлов из директории ресурсов
// НЕ ФИКСИРОВАТЬ ИЗМЕНЕНИЙ этого файла
package box

func init(){
 {{- range $name, $file := . }}
     resources.Add("{{ $name }}", []byte{ {{ conv $file }} })
 {{- end }}
}
`))

Здесь просто выполняется итерация и создаётся файл с названием и актуальными данными по этому файлу, который мы затем запишем на диск, разрешая доступ в коде.

Функция map переходит в шаблонизатор для создания представления байтов среза как строки и использования в шаблоне. Это очень простая функция. Она забирает срез байтов и возвращает значение строки, разделённое запятыми. В строке все байты представлены числовыми значениями.

func FormatByteSlice(sl []byte) string {
 builder := strings.Builder{}
 for _, v := range sl {
 builder.WriteString(fmt.Sprintf("%d,", int(v)))
 }
 return builder.String()
}

Если не можете найти обратные кавычки, не переживайте: теперь все они превратились в цифры.

Запишем файл на диск и готово.

Но почему-то средства контроля качества кода отказывают

Это свидетельствует о неправильном формате кода. Можно было бы исправить его самостоятельно после того, как код будет сгенерирован.

Но мы так делать не будем: в Go для этого предусмотрен специальный форматировщик любого кода. Просто воспользуйтесь им перед сохранением файла на диск:

data, err := format.Source(builder.Bytes())
if err != nil {
 log.Fatal("Error formatting generated code", err)
}

Проще простого!

//+build ignore

package main

import (
 "bytes"
 "fmt"
 "go/format"
 "io/ioutil"
 "log"
 "os"
 "path/filepath"
 "strings"
 "text/template"
)

const blob = "blob.go"

var packageTemplate = template.Must(template.New("").Funcs(map[string]interface{}{"conv": FormatByteSlice}).Parse(`// Код сгенерирован с помощью go generate; НЕ РЕДАКТИРОВАТЬ.
// сгенерирован с помощью файлов из директории ресурсов
// НЕ ФИКСИРОВАТЬ ИЗМЕНЕНИЙ этого файла
package box

func init(){
 {{- range $name, $file := . }}
     resources.Add("{{ $name }}", []byte{ {{ conv $file }} })
 {{- end }}
}
`))

func FormatByteSlice(sl []byte) string {
 builder := strings.Builder{}
 for _, v := range sl {
 builder.WriteString(fmt.Sprintf("%d,", int(v)))
 }
 return builder.String()
}

func main() {
 log.Println("Baking resources... U0001F4E6")

 if _, err := os.Stat("resources"); os.IsNotExist(err) {
 log.Fatal("Resources directory does not exists")
 }

 resources := make(map[string][]byte)
 err := filepath.Walk("resources", func(path string, info os.FileInfo, err error) error {
 if err != nil {
 log.Println("Error :", err)
 return err
 }
 relativePath := filepath.ToSlash(strings.TrimPrefix(path, "resources"))
 if info.IsDir() {
 log.Println(path, "is a directory, skipping... U0001F47B")
 return nil
 } else {
 log.Println(path, "is a file, baking in... U0001F31F")
 b, err := ioutil.ReadFile(path)
 if err != nil {
 log.Printf("Error reading %s: %s", path, err)
 return err
 }
 resources[relativePath] = b
 }
 return nil
 })

 if err != nil {
 log.Fatal("Error walking through resources directory:",err)
 }

 f, err := os.Create(blob)
 if err != nil {
 log.Fatal("Error creating blob file:", err)
 }
 defer f.Close()

 builder := &bytes.Buffer{}

 err = packageTemplate.Execute(builder, resources)
 if err != nil {
 log.Fatal("Error executing template", err)
 }

 data, err := format.Source(builder.Bytes())
 if err != nil {
 log.Fatal("Error formatting generated code", err)
 }
 err= ioutil.WriteFile(blob, data, os.ModePerm)
 if err != nil {
 log.Fatal("Error writing blob file", err)
 }

 log.Println("Baking resources done... U0001F680")
 log.Println("DO NOT COMMIT box/blob.go U0001F47B")
}

(Источник для gen.go наделён функционалом.)

Запускаем

Теперь выполните go generate ./… в своём приложении и найдите файл box/blob.go, который содержит все данные для ресурсов в директории box/resources.

Сгенерированный файл blob

Осталось вызвать

box.Get('/sample/sample_config.yaml')

и получить данные из вашего файла. Теперь они встроены в двоичный Go.

Подведем итог

Существуют самые разные решения проблемы встраивания ресурсов. А мне хотелось реализовать простое.

Go предоставляет такое решение. Любой разработчик рад был бы иметь в своём инструментарии тот широкий функционал, которым обладает этот мощный язык программирования. В его стандартной библиотеке есть шаблоны, средства генерации кода и многое другое.

Это решение отделяет содержимое от кода, делая всё интуитивно понятным и лёгким в управлении.

Например, для исправления каких-либо проблем в файле README можно просто отредактировать этот файл и создать программу, не думая о реализации. Это со всех сторон беспроигрышное решение.

Вот код для всего проекта:wso2/product-apim-tooling
You can’t perform that action at this time. You signed in with another tab or window. You signed out in another tab or…github.com

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming