Основы Go: ввод-вывод файловой системы

Введение

Чтение с диска и запись на диск, а также перемещение по файловой системе  —  это основной элемент в любом языке. Узнаем, как все это делать в Go с помощью пакета os, который позволяет взаимодействовать с функциональностью операционной системы.

Что обсудим:

Файлы
   Создание и открытие файлов
   Чтение файлов
   Запись и добавление в файлы
   Удаление файлов

Каталоги
   Создание каталогов
   Чтение каталогов и перемещение по ним
   Пройдемся по каталогу

Файлы

Создание и открытие файлов

Создание файлов происходит с помощью os.Create, а открытие  —  с помощью os.Open. И там и там принимается путь к файлу и возвращается структура File, а в случае неуспеха  —  ошибка с nil.

import (
	"os"
)

func createFile(){
	filePtr, err := os.Create("./test/creating.txt");
	if err != nil {
		log.Fatal(err);
	}
	defer filePtr.Close(); // закрываем файл
	// Читаем с файла и записываем в файл
}

func openFile(){
	filePtr, err := os.Open("./test/creating.txt");
	if err != nil {
		log.Fatal(err);
	}
	defer filePtr.Close(); // закрываем файл
	// Читаем с файла и записываем в файл
}

Когда os.Create вызывается в существующем файле, он этот файл обрезает: данные файла стираются. В то же время вызов os.Open в несуществующем файле приводит к ошибке.

В случае успеха возвращенная структура File используется для записи и чтения данных в файле (дальше в статье будет приведен пример того, как файл открывается, читается фрагмент за фрагментом и закрывается).

После взаимодействия с возвращенным файлом закрываем его с помощью File.Close.

Чтение файлов

Один из способов обработки файла  —  прочитать сразу все содержащиеся в нем данные. Делается это с использованием os.ReadFile. Вводимые данные  —  это путь к файлу, а выходные данные  —  это байтовый массив данных файла и ошибка в случае неуспеха.

import (
  "log"
  "os"
)

/*
Содержание test.txt: 
Hello World!
*/

func readFileContents(){
	bytes, err := os.ReadFile("test.txt");
	if err != nil {
    		log.Fatal(err);
	}
	fileText = string(bytes[:]); // fileText — это «Hello World!»
}

При обработке текстового файла для получения текста файла нужно преобразовать получаемый на выходе массив байтов в строку.

Имейте в виду, что os.ReadFile прочитает весь файл и загрузит его данные в память. И чем больше файл, тем больший объем памяти будет потребляться при использовании os.ReadFile.

Эффективный с точки зрения потребления памяти подход связан с пофрагментной обработкой файла, осуществляемой с помощью os.Open.

func readFileChunkWise() {
	chunkSize := 10 // обработка сразу 10 байтов файла
	b := make([]byte, chunkSize) 
	file, err := os.Open("./folder/test.txt);
	if err != nil {
		log.Fatal(err)
	}
	for {
		bytesRead, _ := file.Read(b);
		if bytesRead == 0 { // bytesRead будет равен 0 в конце файла.
			break
		}
    		// обрабатываются прочитанные на данный момент байты
    		process(b, bytesRead);
	}
	file.Close();
}

После открытия файла происходит многократный вызов File.Read до EOF (конца файла).

File.Read принимает байтовый массив b и загружает до len(b) байтов из файла в b. А затем возвращает количество прочитанных байтов bytesRead и ошибку, если что-то пойдет не так. При bytesRead равным 0 нажимаем EOF и заканчиваем обработку файла.

В приведенном выше коде из файла загружается максимум 10 байтов. Они обрабатываются, и этот процесс повторяется до EOF (конца файла).

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

Запись и добавление в файлы

Для записи байтов в файл существует аналогичная os.ReadFile функция os.WriteFile.

import (
  "log"
  "os"
)

func writeFileContents() {
	content := "Something to write";
  
  	/* os.WriteFile принимает путь к файлу, []byte содержимого файла 
     	и биты полномочий, в случае если файл не существует */
  
	err := os.WriteFile("./test.txt", []byte(content), 0666);
	if err != nil {
		log.Fatal(err);
	}
}

Что следует учесть при использовании os.WriteFile:

  • Обязательно преобразуйте данные для записи в []byte, прежде чем передавать их в os.WriteFile.
  • Биты полномочий необходимы для создания файла, если он еще не существует. Но на них заострять внимание не стоит.
  • Если путь к файлу уже существует, os.WriteFile переопределит исходные данные в файле с помощью новых записываемых данных.

os.WriteFile хорош для создания нового файла или его переопределения. Но он не работает, когда нужно сделать добавление к имеющемуся содержимому файла. Для добавления в файл нужно задействовать os.OpenFile .

Согласно документации, os.OpenFile —  это более обобщенная версия os.Open и os.Create. И os.Create, и os.Open внутренне вызывают его.

Кроме пути к файлу, os.OpenFile принимает флаги int и perm (биты полномочий) и возвращает структуру File. Для выполнения таких операций, как чтение и запись, в os.OpenFile должна быть указана правильная комбинация флагов.

const (
    // Должно быть указано либо O_RDONLY, либо O_WRONLY, либо O_RDWR.
    O_RDONLY int = syscall.O_RDONLY // файл открывается только для чтения.
    O_WRONLY int = syscall.O_WRONLY // файл открывается только для записи.
    O_RDWR   int = syscall.O_RDWR   // файл открывается для чтения и записи.
    // Остальные значения пропускаются через логическое ИЛИ, чтобы контролировать поведение.
    O_APPEND int = syscall.O_APPEND // добавление данных в файл при записи.
    O_CREATE int = syscall.O_CREAT  // создание нового файла, если не существует.
    O_EXCL   int = syscall.O_EXCL   // используется с O_CREATE, файл не должен существовать.
    O_SYNC   int = syscall.O_SYNC   // открытие для синхронного ввода-вывода.
    O_TRUNC  int = syscall.O_TRUNC  // обрезается обычный файл с возможностью записи при открытии.
)

Источник: https://golang.org/pkg/os/#pkg-constants

O_APPEND и O_WRONLY объединяют с побитовым ИЛИ и передают в os.OpenFile для получения структуры File. После этого при вызове File.Write с любыми передаваемыми данными эти данные будут добавлены в конец файла.

import (
  "log"
  "os"
)
/*
append.txt изначально:
Имеющаяся строка

append.txt после вызова appendToFile:
Имеющаяся строка
Добавление новой строки
*/
func appendToFile(){
	content := "\nAdding a new line";
	file, err := os.OpenFile("append.txt", os.O_APPEND | os.O_WRONLY, 0644);
  	defer file.Close();
	if err != nil {
		log.Fatal(err);
	}
	file.Write([]byte(content));
}

Удаление файлов

os.Remove принимает путь к файлу или пустому каталогу и удаляет этот файл/каталог. Если файл не существует, будет возвращена ошибка с nil.

import (
  "log"
  "os
)

func removeFile(){
	err := os.Remove("./removeFolder/remove.txt");
	if err != nil{
		log.Fatal(err);
	}
}

Освоив основы работы с файлами, перейдем теперь к каталогам.

Каталоги

Создание каталогов

Для создания нового каталога используется os.Mkdir. Эта функция принимает имя каталога и биты полномочий, и так создается новый каталог. Если os.Mkdir не создаст каталог, будет возвращена ошибка с nil.

import (
 "log"
 "os"
)

func makeDir(){
  err := os.Mkdir("newDirectory", 0755);
  if err != nil {
  	log.Fatal(err);
  }
}

В некоторых ситуациях бывают нужны временные каталоги, которые существуют только во время выполнения программы. Для создания таких каталогов используется os.MkdirTemp.

import (
	"log"
	"os"
) 

/*
os.MkdirTemp принимает путь для создания временного каталога и шаблон. 

os.MkdirTemp создаст новый каталог с именем, состоящим из шаблона и случайной строки.

Пример. Для шаблона «transform» временный каталог будет такой:
./temporary/transform952209073
*/

func makeTempDir(){
	dirName, err := os.MkdirTemp("./temporary", "transform");
	defer os.RemoveAll(dirName); // удаляем все содержимое каталога
	if err != nil {
		log.Fatal(err);
	}
}

os.MkdirTemp снабжает создаваемые временные каталоги уникальными именами, даже когда происходят вызовы от нескольких горутин или программ (источник). Закончив работу с временным каталогом, обязательно удалите вместе с его содержимым с помощью os.RemoveAll.

Чтение каталогов и перемещение по ним

Сначала с помощью os.Getwd получим текущий рабочий каталог:

import (
	"log"
	"os"
)

func getWd() {
	dir, err = os.Getwd()
	if err != nil {
		log.Fatal(err);
	}
	return dir;
}

Затем для изменения рабочего каталога задействуем os.Chdir:

import (
	"log"
	"os"
)

func navigate(){
  os.Getwd() // Рабочий каталог: ./folder
  
  os.Chdir("./item"); // Рабочий каталог: ./folder/item
  
  os.Chdir("../"); // Рабочий каталог: ./folder
}

В добавок к изменению рабочего каталога у нас есть возможность получить дочерний каталог. Делается это с помощью os.ReadDir. Эта функция принимает путь к каталогу и возвращает массив структур DirEntry и ошибку c nil в случае неуспеха.

type DirEntry interface {
    // Name возвращает имя файла (или подкаталога), описываемое записью entry.
    // Это имя не весь путь, а только конечный его элемент (базовое имя).
    // Например, Name вернет «hello.go», а не «/home/gopher/hello.go».
    Name() string

    // IsDir сообщает, описывает ли запись каталог.
    IsDir() bool

    // Type возвращает разряды типа для записи.
    // Разряды типа — это подмножество обычных разрядов FileMode, возвращаемых FileMode.Type method.
    Type() FileMode

    // Info возвращает FileInfo для файла или подкаталога, описываемого записью.
    // Возвращаемый FileInfo бывает с момента чтения исходного каталога
    // или с момента вызова к Info. Когда файл удален или переименован
    // с момента чтения каталога, Info возвращает ошибку, удовлетворяющую errors.Is(err, ErrNotExist).
    // Когда запись обозначает символическую ссылку, Info сообщает информацию о самой ссылке,
    // а не о цели ссылки.
    Info() (FileInfo, error)
}

Источник: https://golang.org/pkg/io/fs/#DirEntry

Вот пример использования:

import (
  "fmt"
  "log"
  "os"
)
/*
Допустим, это была структура каталогов теста:
- test
	- a.txt
	- b
		- c.txt		
getDirectoryContents will print out "a.txt" and "b".
*/
func getDirectoryContents(){
	entries, err := os.ReadDir("./test");
	if err != nil {
		log.Fatal(err);
	}
        //выполняется итеративный обход объектов каталога и выводится их название.
	for _, entry := range(entries) {
		fmt.Println(entry.Name());
	}
}

Пройдемся по каталогу

Используя os.Chdir и os.ReadDir, мы проходимся по всем файлам и подкаталогам родительского каталога. Но в пакете path/filepath есть функция filepath.WalkDir, позволяющая сделать это более элегантно.

filepath.WalkDir принимает каталог root, из которого мы стартуем, и функцию обратного вызова fn следующего типа:

type WalkDirFunc func(path string, d DirEntry, err error) error

fn будет вызываться в каждом файле и подкаталоге каталога root. Вот пример подсчета всех файлов в корневом каталоге:

import (
	"fmt"
	"io/fs"
	"path/filepath"
)

// пример подсчета всех файлов в корневом каталоге
func countFiles() int {
	count := 0;
	filepath.WalkDir(".", func(path string, file fs.DirEntry,  err error) error {
		if err != nil {
			return err
		}
		if !file.IsDir() {
			fmt.Println(path);
			count++;
		}

		return nil;
	});
	return count;
}

В path/filepath есть еще одна функция filepath.Walk с поведением, аналогичным filepath.WalkDir. Однако в документации сказано, что filepath.Walk менее эффективна, чем filepath.WalkDir. Поэтому лучше использовать filepath.WalkDir.

Заключение

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

Спасибо за внимание!

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Ramki Pitchala: Go Basics: Filesystem IO

Предыдущая статьяКак оптимизировать набор текста с помощью Python
Следующая статья25 полезных сокращений в JavaScript для веб-разработчиков