Пишем файловую сиcтему на Go и FUSE
Перевод статьи "Writing file systems in Go with FUSE" от Tommi Virtanen.
Мотивация
Некоторое время назад мне понадобилось улучшить используемое хранилище. Основная причина - это необходимость удобной синхронизации. Кроме того, было и другие причины. Мне была необходима файловая система, которая сочетает в себе лучшее из трех стихий: локальных файлов, сетевой файловой системы и синхронизации файлов. Все это реализовалось в виде проекта Bazil, названный в честь базиллион байтов.
Для создания Bazil мне был необходим инструмент для простого написания файловых систем на Go. Таким образом, на свет появился пакет bazil.org/fuse.
Сегодня, в качестве примера, мы напишем Go приложение, которое позволит использовать zip архивы как файловую систему:
$ unzip -v archive.zip
Archive: archive.zip
Length Method Size Cmpr Date Time CRC-32 Name
-------- ------ ------- ---- ---------- ----- -------- ----
0 Stored 0 0% 2014-12-11 04:03 00000000 buried/
0 Stored 0 0% 2014-12-11 04:03 00000000 buried/deep/
5 Stored 5 0% 2014-12-11 04:03 2efcceec buried/deep/loot
13 Stored 13 0% 2014-12-11 04:03 f4247453 greeting
-------- ------- --- -------
18 18 0% 4 files
$ zipfs archive.zip mnt &
$ tree mnt
mnt
├── buried
│ └── deep
│ └── loot
└── greeting
2 directories, 2 files
$ cat mnt/greeting
hello, world
FUSE
FUSE (Filesystem In Userpace) это файловая система ядра Linux, которая работает поверх запросов к файловым дескрипторам в пространстве пользователя. Исторически, все это обрабатывается с помощью сишной библиотеки с таким же именем. Но FUSE - это всего лишь протокол. Сейчас его поддержка реализована в OS X, FreeBSD и OpenBSD.
bazil.org/fuse это реализация протокола на чистом Go.
Структура файловой системы Unix
Файловая система в никсах состоит из inodes (“index nodes”). Это файлы, каталоги, и т.д. Каталоги содержат записи (dirent от directory entries) которые указывают на вложенные ноды. Запись в каталоге идентифицируется по имени и содержит очень мало метаинформации. Через ноды можно добраться до дополнительной метаинформации(включая контроль доступа) и до содержимого самого файла.
Открытые файлы идентифицируются с помощью файловых дескрипторов, который являются безопасными ссылками на объекты ядра (handles или ручки).
Go API
Наша библиотека FUSE разделена на две части. Низкоуровневая часть bazil.org/fuse
. И более высокоуровневая часть bazil.org/fuse/fs
в которой есть методы для отслеживания времени жизни объектов, их состояний.
Каждая файловая система имеет корневую запись. В интерфейсе fs.FS
есть метод Root
, который возвращает fs.Node
.
Для доступа к файлу (просмотр метаданных, открытие, и т.д.) ядро будет запрашивать его по имени, отправляя fuse.LookupRequest
к FUSE серверу. Этот запрос обрабатывается методом Lookup
родительской fs.Node
. Метод возвращает fs.Node
, результат кешируется в ядре и пересчитываются ссылки. Удаление кеша инициирует ForgetRequest
и, когда количество ссылок становится равным нулю, вызывается Forget
.
Файлы переименовываются с помощью Rename
, удаляются с помощью Remove
и так далее.
Обработчики навешиваются, для примера, в момент открытия файла. Открытия любого существующего файла инициирует OpenRequest
который обрабатывается с помощью Open
. Все методы, которые создают новые обработчики, возвращают Handle
. Обработчики закрываются комбинацией Flush
и Release
.
Если Open
не определен, то как Handle
используется fs.Node
. Это, как правило, работает нормально, но только для неизменяемых файлов.
Для чтение из Handle
используется Read
, для записи - Write
. И кроме всего этого есть дополнительные данные, аналогичные io.ReaderAt
и io.WriterAt
. Обратите внимание, что информация о размере файла изменяется только через Setattr
и не зависит от вызовов Write
. Нужно следить, чтобы Attr
возвращал корректный Size
.
Листинг в каталоге реализуется благодаря чтению дескриптора каталога, который, на самом деле, тоже файл. После прочтения каталога, вместо содержимого файла, возвращается список(directory entries). Метод ReadDir
реализует чуть более высокоуровневое API и возвращает слайс.
Написание файловой системы требует неплохого понимания структур данных ядра и принципов изменения их состояний, на абстрактном уровне. Но Go позволяется обойтись без лишних усложнений. Так что давайте погрузимся в код.
Zipfs
Для примера напишем простую файловую систему, которая позволит просматривать содержимое zip архива.
Исходники готовой программы доступны тут: https://github.com/bazillion/zipfs
Каркас
Начнем с каркаса программы, которая парсит аргументы командной строки:
package main
import (
"archive/zip"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"bazil.org/fuse"
"bazil.org/fuse/fs"
)
// Мы должны учитывать, что zip файл также содержит записи для каталогов.
var progName = filepath.Base(os.Args[0])
func usage() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n", progName)
fmt.Fprintf(os.Stderr, " %s ZIP MOUNTPOINT\n", progName)
flag.PrintDefaults()
}
func main() {
log.SetFlags(0)
log.SetPrefix(progName + ": ")
flag.Usage = usage
flag.Parse()
if flag.NArg() != 2 {
usage()
os.Exit(2)
}
path := flag.Arg(0)
mountpoint := flag.Arg(1)
if err := mount(path, mountpoint); err != nil {
log.Fatal(err)
}
}
Монтирование это несколько громоздкое действо, еще и потому что монтирование в Linux отличается от монтирования в OSXFUSE. И это может быть причиной некоторых ошибок.
func mount(path, mountpoint string) error {
archive, err := zip.OpenReader(path)
if err != nil {
return err
}
defer archive.Close()
c, err := fuse.Mount(mountpoint)
if err != nil {
return err
}
defer c.Close()
filesys := &FS{
archive: &archive.Reader,
}
if err := fs.Serve(c, filesys); err != nil {
return err
}
// проверяем ошибки при монтировании
<-c.Ready
if err := c.MountError; err != nil {
return err
}
return nil
}
Файловая система
Фактически, эта файловая система работает через указатель на zip архив:
type FS struct {
archive *zip.Reader
}
И для начала нам нужно указать Root
метод:
var _ fs.FS = (*FS)(nil)
func (f *FS) Root() (fs.Node, fuse.Error) {
n := &Dir{
archive: f.archive,
}
return n, nil
}
Каталоги
Zip файлы содержат списки файлов. Кроме того, типичные zip-архивы содержать записи каталогов, которые заканчиваются на слеш. Мы вернемся к этому чуть позже.
Определим наш тип Dir
и реализуем обязательный метод Attr
. Будем использовать *zip.File
для работы с метаданными каталога.
type Dir struct {
archive *zip.Reader
// для корневого каталога, который не имеет ни
// каких зипов, это будет nil
file *zip.File
}
var _ fs.Node = (*Dir)(nil)
func zipAttr(f *zip.File) fuse.Attr {
return fuse.Attr{
Size: f.UncompressedSize64,
Mode: f.Mode(),
Mtime: f.ModTime(),
Ctime: f.ModTime(),
Crtime: f.ModTime(),
}
}
func (d *Dir) Attr() fuse.Attr {
if d.file == nil {
// корневой каталог
return fuse.Attr{Mode: os.ModeDir | 0755}
}
return zipAttr(d.file)
}
Просмотр записей в каталоге
Чтобы наша файловая система могла делать что то полезное, нам нужно реализовать поиск файла по имени. Мы просто будем перебирать все записи в zip архиве, сравнивая путь:
var _ = fs.NodeRequestLookuper(&Dir{})
func (d *Dir) Lookup(req *fuse.LookupRequest,
resp *fuse.LookupResponse,
intr fs.Intr) (fs.Node, fuse.Error) {
path := req.Name
if d.file != nil {
path = d.file.Name + path
}
for _, f := range d.archive.File {
switch {
case f.Name == path:
child := &File{
file: f,
}
return child, nil
case f.Name[:len(f.Name)-1] == path && f.Name[len(f.Name)-1] == '/':
child := &Dir{
archive: d.archive,
file: f,
}
return child, nil
}
}
return nil, fuse.ENOENT
}
Файлы
Наш метод Lookup
возвращает типы File
, если запись не заканчивается на слеш. Определим тип File
, который использует туже вспомогательную функцию zipAttr
:
type File struct {
file *zip.File
}
var _ fs.Node = (*File)(nil)
func (f *File) Attr() fuse.Attr {
return zipAttr(f.file)
}
Но файлы не нужны, если их нельзя открыть:
var _ = fs.NodeOpener(&File{})
func (f *File) Open(req *fuse.OpenRequest,
resp *fuse.OpenResponse,
intr fs.Intr) (fs.Handle, fuse.Error) {
r, err := f.file.Open()
if err != nil {
return nil, err
}
// индивидуальный записи в архиве
resp.Flags |= fuse.OpenNonSeekable
return &FileHandle{r: r}, nil
}
Обработчики
type FileHandle struct {
r io.ReadCloser
}
var _ fs.Handle = (*FileHandle)(nil)
Мы реализуем "открытый файл" с помощью обработчиков. В нашем случае это просто обертка на archive/zip
, но в других случаях это может быть *os.File
, сетевое соединение и многое другое. И нужно не забывать закрывать их:
var _ fs.HandleReleaser = (*FileHandle)(nil)
func (fh *FileHandle) Release(req *fuse.ReleaseRequest, intr fs.Intr) fuse.Error {
return fh.r.Close()
}
Давайте напишем реальный обработчик операции Read
:
var _ = fs.HandleReader(&FileHandle{})
func (fh *FileHandle) Read(req *fuse.ReadRequest,
resp *fuse.ReadResponse, intr fs.Intr) fuse.Error {
// В такой реализации мы не учитываем Offset для определения, где
// закончилось прошлое чтение. Для этого нам пришлось бы трекать эту
// операции. Ядро сделает все за нас основываясь
// на флаге fuse.OpenNonSeekable.
buf := make([]byte, req.Size)
n, err := fh.r.Read(buf)
resp.Data = buf[:n]
return err
}
Чтение директорий
В таком виде мы имеем доступ к нашим файлам через cat
и аналогичный способы. Но для этого нам нужно знать их имена. Давайте добавим поддержку ReadDir
:
var _ = fs.HandleReadDirer(&Dir{})
func (d *Dir) ReadDir(intr fs.Intr) ([]fuse.Dirent, fuse.Error) {
prefix := ""
if d.file != nil {
prefix = d.file.Name
}
var res []fuse.Dirent
for _, f := range d.archive.File {
if !strings.HasPrefix(f.Name, prefix) {
continue
}
name := f.Name[len(prefix):]
if name == "" {
// сама директория, не вложения
continue
}
if strings.ContainsRune(name[:len(name)-1], '/') {
// есть слеш в середине -> находится во вложенном каталоге
continue
}
var de fuse.Dirent
if name[len(name)-1] == '/' {
// каталог
name = name[:len(name)-1]
de.Type = fuse.DT_Dir
}
de.Name = name
res = append(res, de)
}
return res, nil
}
Тестируем zipfs
Подготавливаем zip файл:
$ mkdir -p data/buried/deep
$ echo hello, world >data/greeting
$ echo gold >data/buried/deep/loot
$ ( cd data && zip -r -q ../archive.zip . )
Монтируем архив:
$ mkdir mnt
$ zipfs archive.zip mnt &
Просматриваем записи в каталоге:
$ ls -ld mnt/greeting
-rw-r--r-- 1 root root 13 Dec 11 2014 mnt/greeting
$ ls -ld mnt/buried
drwxr-xr-x 1 root root 0 Dec 11 2014 mnt/buried
Читаем содержимое файла:
$ cat mnt/greeting
hello, world
$ cat mnt/buried/deep/loot
gold
Чтение каталога ("total 0" это не правильно, но пока не значительно):
$ ls -l mnt
total 0
drwxr-xr-x 1 root root 0 Dec 11 2014 buried
-rw-r--r-- 1 root root 13 Dec 11 2014 greeting
$ ls -l mnt/buried
total 0
drwxr-xr-x 1 root root 0 Dec 11 2014 deep
Размонтирование (для OS X используйте umount mnt):
$ fusermount -u mnt
B на этом все. Если хотите продолжения и больше примеров, посмотрите https://github.com/bazillion/bolt-mount (скринкаст в дополнение к коду) и другие проекты использующие fuse.
Почитать
- Bazil распределенная файловая система. Позволяет шарить файлы на всех ваших компьютерах, включая облачные сервисы.
- FUSE модуль для ядер UNIX-подобных операционных систем. Свободное программное обеспечение с открытым исходным кодом.
- Сишная библиотека FUSE, реализующая поддержку протокола.
- bazil.org/fuse Go библиотека для написания файловых систем. Смотрите godoc для
fuse
иfuse/fs
- OSXFUSE это реализация ядра FUSE для OS X.
bolt-mount
это более обширный пример файловой системы, включая операции записи. Также, можете посмотреть скринкаст.- Написание файловых систем на Go чуть более подробное описание FUSE.
- Вопросы по FUSE можете задавать в bazil-dev Google Group или на IRC канале #go-nuts на irc.freenode.net.