Использовать bufio.Scanner, чтобы не загружать весь файл в память:

file, err := os.Open("large_file.txt")
if err != nil {
    log.Fatal("Произошла ошибка при открытии файла", err)
}
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    fmt.Println(scanner.Text())
}
if err := scanner.Error(); err != nil {
    log.Fatal("Произошла ошибка чтения файла", err)
}

Здесь bufio.NewScanner создаёт объект, который умеет “читать по строкам”. Он сам заботится о разбиении потока байт на отдельные текстовые строки (по символу новой строки).

Каждый вызов scanner.Scan() читает из файла до следующей строки, возвращая её текст через scanner.Text(). В памяти хранится только одна строку за раз, а не весь файл целиком. Это позволяет обрабатывать файлы размером в сотни мегабайт даже на машинах с ограниченным объёмом ОЗУ.

Как работает буферизация?

При первом вызове scanner.Scan() bufio.Scanner запрашивает у ОС не по байту, а целый блок (обычно ~4 КБ) и кладёт его в свой внутренний буфер. Внутренний алгоритм разбивает этот блок по символам \n, отдаёт строки одну за другой и, когда буфер почти исчерпан, подгружает следующий блок

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

Файл — это не просто значок на рабочем столе. Это структурированный набор данных, который хранится на диске и управляется операционной системой (ОС).

В Go работа с файлами строится на двух китах:

  • пакет os для низкоуровневых операций (открытие файла, закрытие файла, чтение или запись файла)
  • пакет io для абстракции ввода-вывода

Когда файл открывается в Go, то на выходе получается объект типа *os.File. Это не просто “ссылка” на файл — это структура, которая содержит:

  • дескриптор файла
  • позиция чтения/записи (offset)
  • метаданные

Дескриптор файла

Дескриптор — это целое число, которое ОС присваивает открытому файлу. Это как номер столика в ресторане: официант (ОС) знает, что заказ (данные) относится к вашему столику (файлу).

Зачем нужно? ОС использует дескрипторы для управления всеми открытыми файлами. Каждая программа имеет ограничение на количество дескрипторов (зависит от ОС).

file, _ := os.Open("awesome_file.txt")
fmt.Printf("Дескриптор файла %d\n", file.Fd()) // -> Выведет число, например, 3

Позиция чтения и записи

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

  • при открытии файла offset = 0 (начало файла)
  • после чтения N байт offset сдвигается на N
  • при записи offset перемещается в конец записанных данных

Метаданные

Объект os.File хранит информацию о файле:

  • путь к файлу в файловой системе ОС
  • режим доступа (чтение, запись и т.д.)
  • права доступа (например -> 0644)

Права доступа — это набор разрешений, который определяет, какие действия могут выполнять разные категории пользователей в ОС с файлом. Обычно права задаются в восьмеричной системе (0XYZ), где:

  • X — права владельца (User)
  • Y — права группы (Group)
  • Z — права остальных (Others)

Каждая цифра — сумма следующих флагов:

  • 4 (r) — чтение: право открывать и просматривать содержимое файла
  • 2 (w) — запись: право изменять или дописывать файл
  • 1 (x) — выполнение: право запускать файл как программу

Например, 0644 означает, что владелец может читать и записывать файл (6 = 4 + 2), а группа и остальные имеют только право чтения (4).

Стратегия замены (подмены)

Самый простой вариант - это стратегия замены:

const stream$ = from([1, 2, 3, 'text', 4, 5]);
stream$
  .pipe(
    map((value) => {
      if (isNaN(value)) {
        throw new Error('Value is not a number!');
      }
      return parseInt(value);
    }),
    catchError((error) => of())
  )
  .subscribe({
    next: (value) => console.log('stream emit value', value),
    error: (error) => console.log('complete stream with error', error),
    complete: () => console.log('complete stream'),
  });

Что мы здесь имеем? Есть поток stream$, который обрабатывается в pipe при помощи операторов map и catchError. Эвенты последовательно приходят из потока в оператор map, внутри которого выполняется проверка при помощи isNaN. Для первых трех эвентов - 1, 2, 3 - условие if не выполняется, поэтому выполняется следующая операция parseInt(value) и преобразованный эвент - попадает в оператор catchError. Так как эвент не является ошибкой, то оператор catchError - пропускает эвент дальше; эвент попадает в подписку subscribe - конкретно в next.

Но вот для эвента со значением ‘text’ - все складывается по другому. Когда он попадает в оператор map, то выполняется условие if и оператор map возвращает вновь созданный эвент со сгенерированной ошибкой. Этот эвент попадает в оператор catchError и так как в данном эвенте содержится ошибка, то сработает callback-функция внутри оператора catchError, которая вернет как результат - новый поток of(). И этот поток - подменит собой поток stream$, в результате subscribe переподпишется на новый поток of(). Этот новый поток не эммитит эвентов, поэтому он сразу завершает свою работу, и в subscribe сработает callback-функция для случая complete.

Стоит обратить внимание, что в данном примере случай error внутри subscribe не сработает, так как обработка ошибки выполняется внутри потока pipe.

Стратегия перенаправления

Чуть более усложеннный вариант предыдущей стратегии:

const stream$ = from([1, 2, 3, 'text', 4, 5]);
stream$
  .pipe(
    map((value) => {
      if (isNaN(value)) {
        throw new Error('Value is not a number!');
      }
      return parseInt(value);
    }),
    catchError((error) => {
      return throwError(() => error);
    })
  )
  .subscribe({
    next: (value) => console.log('stream emit value', value),
    error: (error) => console.log('complete stream with error', error),
    complete: () => console.log('complete stream'),
  });

В данном случае мы имеем почти тот же случай, что и со стратегией подмены. Но - теперь callback-функция внутри оператора catchError - возвращает новый поток, который создан при помощи оператора throwError. То есть - в данном случае не происходит подмены данных в потоке, так как таже ошибка, что пришла из map и catchError, прокидывается дальше - только уже в другом потоке - в subscribe. Этот поток эммити ничего, кроме той самой ошибки, которую он получил от оператора throwError и аварийно завершает свою работу. Поэтому внутри переподписанного на него subscribe - сработает случай error.

Обратим внимание, что в данном случае внутри subscribe сработает случай error и вызовется на исполнение callback-функция, прописанная в нем.

Стратегия повторных попыток

const stream$ = from([1, 2, 3, 'text', 4, 5]);
stream$
  .pipe(
    map((value) => {
      if (isNaN(value)) {
        throw new Error('Value is not a number!');
      }
      return parseInt(value);
    }),
    retry(3),
    catchError((error) => {
      return throwError(() => error);
    })
  )
  .subscribe({
    next: (value) => console.log('stream emit value', value),
    error: (error) => console.log('complete stream with error', error),
    complete: () => console.log('complete stream'),
  });

Данная стратегия почти ничем не отличается от предыдущей стратегии перенаправления, за исключением использования оператора retry(). Как само собой понятно - при возникновении ошибки, которую обнаружит оператор catchError - мы заново перезапустим поток stream$. И таких попыток - в данном случае у нас будет три, то есть - поток перезапустится трижды. Если все три попытки оказались неудачными - выбросится новый поток при помощи оператора throwError и мы упадем в subscribe на случай error.

Есть более продвинутая версия оператора retry() - retryWhen(); можно гибко управлять моментом, когда должна запуститься новая попытка.

Императивный способ отписки

export class AwesomeComponent implements OnInit, OnDestroy {
  private readonly service = inject(TodoService);
  private subscription!: Subscription;

  public todos: Todo[] = [];

  ngOnInit(): void {
    this.subscription = toObservable(this.service.todos).subscribe(
      (todos) => (this.todos = todos)
    );
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

Декларативный способ отписки

export class AwesomeAnotherComponent {
  private readonly service = inject(TodoService);
  private destroy$ = new Subject<void>();

  public todos: Todo[] = [];

  ngOnInit(): void {
    toObservable(this.service.todos)
      .pipe(takeUntil(this.destroy$))
      .subscribe((todos) => (this.todos = todos));
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

async

Тут особо говорить нечего - и так все понятно.

Оператор takeUntilDestroyed

takeUntilDestroyed - более новый споосб отписки в RxJs. Материалы по теме:

В Visual Studio Code по умолчанию стоит настройка, которая отображает на владке Explorer вложенные папки таким образом:

VSC - Default View

Не знаю, чем руководствовались разработчики VSC, когда устанавливали такую настройку, но мне она кажется крайне неудачной и неудобной. Я предпочитаю “классическое” отображение вложенности папок в файловой системе.

В VSC практически все поддается настройкам и этот момент также легко исправить. В Settings редактора переводим отображение настроек в режим JSON и добавляем строку:

"explorer.compactFolders: false

… вот так:

VSC - Custom View Settings

… и тогда вложенные папки принимают удобочитаемый вид:

VSC - Custom View