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

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

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

Для запуска некоторых из рассматриваемых здесь Go-программ и Bash-команд вам могут понадобиться привилегии суперпользователя (получить их можно с помощью команды sudo). Например — при удалении файлов из директории для временного хранения данных.

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

Вывод строки текста


Оболочка Bash (Linux):

echo "Go"   "is great!"

Команда echo выводит на экран строки, которые ей передают, предварительно конкатенируя их, соединяя их друг с другом одним пробельным символом.

Golang:

package main

import (
  "os"
)

func echo(text ...string) {
  var textBytes []byte

  for i, t := range text {
    if i > 0 {
      textBytes = append(textBytes, ' ')
    }

    textBytes = append(textBytes, t...)
  }

  textBytes = append(textBytes, '\n')

  if _, err := os.Stdout.Write(textBytes); err != nil {
    panic(err)
  }
}

func main() {
  echo("Go", "is great!")
}

Тут мы сначала, для подготовки выходного значения, создаём байтовый срез. Потом перебираем входные строки, присоединяя их байты к срезу. А перед обработкой строк (за исключением первой) мы присоединяем к срезу один пробельный символ. В итоге мы добавляем к срезу символ новой строки и выводим то, что получилось, в stdout.

Генерирование случайного числа


Оболочка Bash (Linux):

echo $RANDOM

Команду echo, помимо вывода строк, можно использовать для вывода переменных окружения. Их имена предваряются префиксами в виде знака доллара. Переменная $RANDOM устроена особым образом — она ссылается на нативную функцию командного интерпретатора Bash. Поэтому, когда мы передаём эту переменную команде echo, то мы, на самом деле, инициируем вызов функции, результат работы которой и выводится на экран.

Функция $RANDOM генерирует псевдослучайные числа в диапазоне от 0 до 32767 (а это — 215-1). Она не выдаёт криптографически надёжные числа, но подходит для использования в простых скриптах и в играх.

Golang:

package main

import (
  "fmt"
  "math/rand"
  "time"
)

const (
  randomUpperBound = 1 << 15 // 2 в 15-й степени
)

func random() int {
  return int(rand.Int31n(randomUpperBound))
}

func init() {
  rand.Seed(time.Now().UnixNano())
}

func main() {
  fmt.Printf("%d\n", random())
}

В функции random для генерирования случайного числа используются возможности стандартной библиотеки Go. Мы задаём верхнюю границу для этого числа, заранее поместив её в константу. Тут важно не забыть задать начальное число генератора, что мы и делаем в функции init. Если этого не сделать, то при каждом обращении к нашему варианту $RANDOM будет выдаваться один и тот же результат, а это, понятно, совсем не то, что нам нужно.

Вывод содержимого файла


Оболочка Bash (Linux):

cat -n test.txt

Перед нами — одна из самых простых Bash-команд. Она просто читает файл и выводит на экран его содержимое. Опция -n обеспечивает вывод перед строками их номеров. Эта команда способна конкатенировать несколько файлов (поэтому она и носит имя cat), но наша её реализация будет рассчитана на работу с единственным файлом.

Golang:

package main

import (
  "fmt"
  "os"
)

func cat(fileName string, printLineNumbers bool) {
  fileData, err := os.ReadFile(fileName)
  if err != nil {
    panic(err)
  }

  if printLineNumbers {
    outputData := make([]byte, 0, len(fileData))

    var lineCount int

    for i, d := range fileData {
      if d == '\n' || i == 0 {
        if i != 0 {
          outputData = append(outputData, d)
        }

        lineCount++

        startOfLineString := fmt.Sprintf("%6d  ", lineCount)

        outputData = append(outputData, startOfLineString...)

        if i == 0 {
          outputData = append(outputData, d)
        }
      } else {
        outputData = append(outputData, d)
      }
    }

    fmt.Printf("%s\n", outputData)
  } else {
    fmt.Println(string(fileData))
  }
}

func main() {
  cat("test.txt", true)
}

Основной объём вышеприведённого кода используется для вывода номеров строк, так как для вывода исходного содержимого файла нам достаточно прибегнуть к fmt.Println. Мы используем функцию fmt.Sprintf для создания префиксов в начале строк. Префикс содержит число, выровненное по правому краю, то есть — вывод получится таким же, как при использовании команды cat.

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

Вывод содержимого файла в обратном порядке


Оболочка Bash (Linux):

tac -b test.txt

Имя команды tac — это имя команды cat, записанное задом наперёд. Команда tac вполне оправдывает своё имя, так как выводит строки переданного ей файла в обратном порядке. Опция -b отвечает за присоединение разделителя к началу каждой строки.

Golang:

package main

import (
  "os"
)

func printReversedLine(lineData []byte) {
  l := len(lineData)
  mid := l / 2

  for i := 0; i < mid; i++ {
    j := l - i - 1

    lineData[i], lineData[j] = lineData[j], lineData[i]
  }

  if _, err := os.Stdout.Write(lineData); err != nil {
    panic(err)
  }
}

func tac(fileName string, fixNewlineBug bool) {
  file, err := os.Open(fileName)
  if err != nil {
    panic(err)
  }
  defer file.Close()

  endLocation, err := file.Seek(0, 2)
  if err != nil {
    panic(err)
  }

  fileData := make([]byte, endLocation)

  if _, err = file.Seek(0, 0); err != nil {
    panic(err)
  }

  _, err = file.Read(fileData)
  if err != nil {
    panic(err)
  }

  if fileData[endLocation-1] == 0 { // EOF
    endLocation--
  }

  var line []byte

  for i := endLocation - 1; i >= 0; i-- {
    d := fileData[i]

    line = append(line, d)

    if d == '\n' {
      if len(line) > 0 {
        printReversedLine(line)

        line = []byte{}
      }
    }
  }

  if len(line) > 0 {
    if fixNewlineBug {
      line = append(line, '\n')
    }

    printReversedLine(line)
  }
}

func main() {
  tac("test.txt", false)
}

Как видите, этот код получился гораздо сложнее, чем тот, что мы писали, воспроизводя команду cat. Но понять его довольно легко. Тут я создал вспомогательную функцию printReversedLine, которая выводит содержимое байтового среза в обратном порядке. Я воспользовался простым алгоритмом, о котором уже писал в материале о различных способах обращения порядка вывода срезов.

Метод Seek устанавливает смещение для следующих операций чтения из файла или записи в файл. Если его второй аргумент — 0, то первый аргумент задаёт смещение от начала файла, а если второй аргумент — 2 — первый задаёт смещение с конца файла. В результате конструкция file.Seek(0, 2) приводит нас в конец файла, позволяя определить его размер.

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

В исходной Bash-команде имеется некоторая проблема, которую я воспроизвёл в моём Go-коде. Две последних строки выводимого файла выводятся вместе. Если установить опцию fixNewlineBug в true — строки окажутся правильно разделёнными.

Вывод имён файлов, содержащихся в директории, отсортированных в обратном порядке


Оболочка Bash (Linux):

ls /bin | sort -r

Bash-команда ls выводит имена файлов, содержащихся в заданной директории. Для отправки выходных данных команды ls команде sort мы используем символ конвейера. Здесь команда sort сортирует то, что получит, в обратном алфавитном порядке.

Golang:

package main

import (
  "fmt"
  "os"
  "sort"
)

func ls(path string) {
  file, err := os.Open(path)
  if err != nil {
    panic(err)
  }
  defer file.Close()

  list, err := file.Readdirnames(0)
  if err != nil {
    panic(err)
  }

  sort.Strings(list)

  for i := len(list) - 1; i >= 0; i-- {
    name := list[i]

    fmt.Println(name)
  }
}

func main() {
  ls("/bin")
}

Основной фукнционал нашей программы реализован с помощью метода Readdirnames. Принимаемый им аргумент задаёт ограничение на количество имён файлов, считываемых за один раз. Но если аргумент меньше или равен нулю, этот метод просто считывает всё, что может.

Тут важно помнить о том, что то, что мы хотим вывести строки в обратном порядке, не значит, что нам надо сортировать их в таком порядке. Поэтому можно использовать функцию sort.Strings из стандартной библиотеки для их сортировки в восходящем алфавитном порядке, а потом, при выводе данных, просто перебрать срез в обратном порядке. В результате имена файлов будут выведены так, как нам нужно.

Вывод пути к текущей рабочей директории


Оболочка Bash (Linux):

pwd

Команда pwd работает просто и понятно. Она, в соответствии со своим именем, образованным первыми буквами слов «print working directory», выводит сведения о рабочей директории.

Golang:

package main

import (
  "fmt"
  "os"
)

func pwd() {
  path, err := os.Getwd()
  if err != nil {
    panic(err)
  }

  fmt.Println(path)
}

func main() {
  pwd()
}

Пакет os даёт нам доступ к библиотекам операционной системы. Функция Getwd возвращает, помимо сведений об ошибке, абсолютный путь (то есть — путь, начинающийся с корневой директории) текущей директории.

Вывод строк файла, соответствующих заданному шаблону


Оболочка Bash (Linux):

grep local /etc/hosts

Команда grep столь полезна, что её имя даже вошло в английский язык в виде глагола «grep». Например, слово «grepping» (не путать с «grokking») используется в ситуациях, когда говорят о поиске в файлах фрагментов, заданных в виде шаблонов.

Вышеприведённая команда выполняет поиск строки local в файле /etc/hosts. Этот файл присутствует во всех Linux-системах и содержит сведения о соответствии IP-адресов и URL. Мы ищем в нём именно local преимущественно потому, что хотим узнать новый IP-адрес localhost.

Golang:

package main

import (
  "bufio"
  "fmt"
  "os"
  "strings"
)

func grep(pattern string, fileName string) {
  file, err := os.Open(fileName)
  if err != nil {
    panic(err)
  }
  defer file.Close()

  scanner := bufio.NewScanner(file)
  for scanner.Scan() {
    text := scanner.Text()

    if strings.Contains(text, pattern) {
      fmt.Println(text)
    }
  }
}

func main() {
  grep("local", "/etc/hosts")
}

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

Перезапись содержимого файла случайными данными


Оболочка Bash (Linux):

shred -n 5 test.txt

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

Команда shred перезаписывает содержимое файла случайными данными и в результате то, что ранее хранилось в этом файле, восстановить уже не получится. Опция -n задаёт количество итераций. Другими словами — определяет то, сколько раз команда перезапишет содержимое файла случайными данными.

Golang:

package main

import (
  "crypto/rand"
  "io/fs"
  "os"
)

func shred(fileName string, iterations int, roundUp bool) {
  file, err := os.OpenFile(fileName, 0666, fs.FileMode(os.O_RDWR))
  if err != nil {
    panic(err)
  }
  defer file.Close()

  endLocation, err := file.Seek(0, 2)
  if err != nil {
    panic(err)
  }

  if roundUp {
    roundedEndLocation := endLocation / 1024
    if endLocation%1024 != 0 {
      roundedEndLocation++
    }
    roundedEndLocation *= 1024

    endLocation = roundedEndLocation
  }

  data := make([]byte, endLocation)

  for i := 0; i < iterations; i++ {
    if _, err := rand.Read(data); err != nil {
      panic(err)
    }

    if _, err := file.WriteAt(data, 0); err != nil {
      panic(err)
    }

    if err := file.Sync(); err != nil {
      panic(err)
    }
  }
}

func main() {
  shred("test.txt", 5, true)
}

Тут мы снова прибегаем к методу Seek, чтобы оценить размер файла. Если задана опция roundUp — размер файла будет округлён до ближайшего числа, кратного 1024, так как это согласуется с размером блока, который по умолчанию используется в Linux. После этого мы выполняем необходимое количество операций перезаписи, подготавливая случайные данные и записывая их в файл. Здесь мы пользуемся пакетом crypto/rand, а не math/rand, так как этот пакет даёт нам криптографически надёжные случайные данные. Применение метода Sync обеспечивает то, что данные реально будут записаны на диск.

Удаление всех файлов из директории для временного хранения данных


Оболочка Bash (Linux):

rm -rf /tmp

Регулярную очистку директории, предназначенной для хранения временных файлов, можно счесть хорошей привычкой, способствующей тому, что в такой директории не будет скапливаться слишком много старых файлов. Именно эту задачу и решает вышеприведённая Bash-команда. Благодаря опции -r команда rm работает рекурсивно, удаляя не только файлы, но и директории, а опция -f позволяет игнорировать ошибки.

Golang:

package main

import (
  "os"
  "path/filepath"
)

func rm(path string) {
  dir, err := os.ReadDir(path)
  if err != nil {
    panic(err)
  }

  for _, dirEntry := range dir {
    childPath := filepath.Join(
      path,
      dirEntry.Name(),
    )

    if err := os.RemoveAll(childPath); err != nil {
      panic(err)
    }
  }
}

func main() {
  rm("/tmp")
}

Тут всё устроено довольно-таки просто. Мы берём имя каждой записи в директории и перебираем эти записи, используя функцию os.RemoveAll для рекурсивного удаления файлов и директорий, а также подфайлов и поддиректорий.

Загрузка файла из интернета


Оболочка Bash (Linux):

wget https://file-examples-com.github.io/uploads/2017/10/file-sample_150kB.pdf example.pdf

Полезной может оказаться возможность загружать файлы, размещённые в интернете, и при этом не пользоваться браузером. Bash-команда wget решает именно эту задачу. Её имя представляет собой сокращение, в состав которого входят «World Wide Web» и «get».

В предыдущем примере мы загружаем из интернета PDF-файл, содержащий бессмысленный текст.

Golang:

package main

import (
  "fmt"
  "io"
  "net/http"
  "os"
)

func wget(url string, path string) {
  file, err := os.Create(path)
  if err != nil {
    panic(err)
  }
  defer file.Close()

  resp, err := http.Get(url)
  if err != nil {
    panic(err)
  }
  defer resp.Body.Close()

  if resp.StatusCode != http.StatusOK {
    err := fmt.Errorf(
      "status code: %d [%s]",
      resp.StatusCode, resp.Status,
    )

    panic(err)
  }

  _, err = io.Copy(file, resp.Body)
  if err != nil {
    panic(err)
  }
}

func main() {
  wget(
    "https://file-examples-com.github.io/uploads/2017/10/file-sample_150kB.pdf",
    "example.pdf",
  )
}

Сначала мы создаём пустой файл (сделать это можно и средствами командной строки, воспользовавшись командой touch). Далее мы подключаемся к нужному URL, применяя пакет net/http из стандартной библиотеки Go.

Если получен ответ с кодом состояния 200, означающий, что всё хорошо, мы просто копируем в открытый нами файл данные, полученные в теле HTTP-ответа. Этот файл будет автоматически закрыт при завершении работы функции, так как ранее мы применили ключевое слово defer.

Вывод текущего времени и сведений о времени работы системы после загрузки


Оболочка Bash (Linux):

uptime | cut -d , -f 1 | sed -e 's/^[[:space:]]*//'

Bash-команда uptime выводит текущее время (с использованием 24-часового формата) и сведения о том, сколько времени, в часах и минутах, система проработала после загрузки.

Эта команда выдаёт и некоторые другие сведения, в частности — о количестве пользователей, залогинившихся на машине, и о том, под какой нагрузкой работает процессор. Но нас это не интересует. Поэтому мы пользуемся командой cut для того, чтобы вывести только те данные, которые идут до первой запятой.

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

Golang:

package main

import (
  "fmt"
  "os"
  "strconv"
  "strings"
  "time"
  "unicode"
)

func formatTime(totalSeconds int, includeSeconds bool) string {
  seconds := totalSeconds

  minutes := seconds / 60
  if minutes > 0 {
    seconds -= minutes * 60
  }

  hours := minutes / 60
  if hours > 0 {
    minutes -= hours * 60
  }

  var builder strings.Builder

  if hours > 0 {
    builder.WriteString(fmt.Sprintf("%02d:", hours))
  }

  if minutes > 0 || hours > 0 {
    builder.WriteString(fmt.Sprintf("%02d", minutes))

    if includeSeconds {
      builder.WriteByte(':')
    }
  }

  if includeSeconds {
    builder.WriteString(fmt.Sprintf("%02d", seconds))
  }

  return builder.String()
}
 
func uptime() {
  currentTime := time.Now().UTC()
  currentTimeTotalSeconds := currentTime.Second() +
    currentTime.Minute()*60 +
    currentTime.Hour()*60*60

  uptimeFileData, err := os.ReadFile("/proc/uptime")
  if err != nil {
    panic(err)
  }

  uptimeSecondsData := make([]byte, 0, len(uptimeFileData))

  for _, d := range uptimeFileData {
    if d != '.' && !unicode.IsDigit(rune(d)) {
      break
    }

    uptimeSecondsData = append(uptimeSecondsData, d)
  }

  uptimeSecondsFloat, err := strconv.ParseFloat(string(uptimeSecondsData), 10)
  if err != nil {
    panic(err)
  }

  fmt.Printf(
    "%s up %s\n",
    formatTime(currentTimeTotalSeconds, true),
    formatTime(int(uptimeSecondsFloat), false),
  )
}

func main() {
  uptime()
}

Мы создали очередную вспомогательную функцию, которая форматирует заданное время или заданную в секундах длительность временного интервала, выражая всё это в часах, минутах и секундах, разделённых двоеточиями. Спецификатор %02d сообщает функции fmt.Sprintf о том, что она должна превратить переданное ей целое число в строку, дополненную, если её длина меньше 2 символов, нулями (то есть — если функции передали число 4, представляющее минуты, она превратит его в 04).

Узнать текущее время — это простая задача, решаемая с помощью функции Now из стандартной библиотеки Go. Тут надо лишь помнить о том, что для перехода к секундам, минуты надо умножать на 60, а часы — на 3600 (60x60).

В Linux файл /proc/uptime содержит два значения, разделённые пробелом. Первое — это общее время работы системы. Второе — это сумма длительностей отрезков времени, которые бездействовало каждое из процессорных ядер. Оба значения представлены в секундах с точностью до двух знаков после запятой.

Нам нужно лишь первое из этих значений. Поэтому мы просто обрабатываем байты до тех пор, пока не найдём нецифровой символ (в данном случае — пробел), используя лишь те байты, которые находятся до того места, где мы остановились. Потом мы преобразуем то, что получилось, к значению типа float64, и выводим часы и минуты с помощью функции formatTime.

Преобразование символов завершения строк, используемых в Windows, в формат, принятый в Linux


Оболочка Bash (Linux):

dos2unix test.txt

По традиции, в Windows и Linux используются различные способы представления конца строк в файлах. А именно: в Windows используется два символа — CR и LF (\r\n), а в Linux — один символ — LF (\n).

Это может привести к проблемам в реальных проектах. Например, у меня был случай, когда приложение для работы с контейнерами, Docker, отказалось читать файл Dockerfile только лишь из-за того, что этот файл был подготовлен с использованием Windows-формата представления окончания строк. Откровенно говоря, разработчики должны предвидеть такие проблемы и сами их исправлять, но по самым разным причинам — они могут упускать подобные вещи. Поэтому всегда стоит использовать тот подход к завершению строк, который соответствует используемой разработчиком операционной системе.

Bash-команда dos2unix преобразует окончания строк из Windows-формата к формату, принятому в Linux. А команда unix2dos выполняет обратное преобразование.

Golang:

package main

import (
  "os"
  "regexp"
)

var (
  windowsNewlineRegexp = regexp.MustCompile(`\r\n`)
)

func dos2unix(path string) {
  fileData, err := os.ReadFile(path)
  if err != nil {
    panic(err)
  }

  fileData = windowsNewlineRegexp.ReplaceAllLiteral(
    fileData,
    []byte{'\n'},
  )

  if err := os.WriteFile(path, fileData, 0666); err != nil {
    panic(err)
  }
}

func main() {
  dos2unix("test.txt")
}

Файл можно открыть, самостоятельно прочитать те таинственные письмена, из которых он состоит, вручную убрать символы CR, сразу за которыми идёт символ LF.

Но я решил облегчить себе жизнь и поступил иначе, воспользовавшись регулярным выражением. Мы просто вызываем метод ReplaceAllLiteral, который заменяет все символы окончания строк в Windows-стиле на их Linux-версию. Затем мы перезаписываем файл, помещая в него новое содержимое. Всё это делается с использованием байтового среза, который изначально у нас имеется — нам даже не нужно преобразовывать его в строку.

Пользуетесь ли вы аналогами Linux-команд собственной разработки?

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


  1. Crystal_HMR
    09.04.2022 17:09
    +8

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

    Честно говоря, я бы понял, если бы целью было что-то вроде "освоения базовых системных возможностей Go на примере написания ограниченных аналогов линуксовых команд", тогда бы вся эта работа была оправдана. При описанной постановке задачи, может я и придираюсь, но указанная цель амбициозна и не выполнена (как минимум учитывая то, что написано в следующем абзаце статьи).

    Всё прочтение преследовала мысль про троллейбус из хлеба. Писать замены существующих команд - зачем? Что бы привыкнуть к своим и прийдя в новое окружение скрежетать зубами, что не привычно?:) Понятно, что когда пишешь что-то на perl, python, go - лучше использовать что-то нативное вместо system(), os.system, os/exec. Но заменять своими поделками команды командной строки (для работы в командной строке), имхо, странно. Не говоря уже про то, что во многих командах десятилетиями закрывали баги и добавляли защиты от дураков, которую не вместишь в 20-50 строк на go. Даже, казалось бы, похожие по смыслу вещи вроде замены cat на pygmentize лучше делать схожим по виду алиасом (типо ccat) а не "подменой".


  1. Thary
    09.04.2022 17:39
    -7

    Полезная статья. Жаль, что так не совсеми командами Bash можно сделать


  1. Zuy
    09.04.2022 19:21
    +1

    Статья конечно больше для обучения, но я пользуясь случаем порекомендую 'gdu' как замену стандартной 'du'.

    Для оценки объемов занимаемого места на диске и удаления тяжёлых папок это просто сказка какая-то.


  1. gohrytt
    09.04.2022 21:19
    +5

    Зачем?


  1. JekaMas
    09.04.2022 23:19
    +3

    Жуткий год. 90% заменяется вменяемым использованием стандартной библиотеки.


  1. mc2
    10.04.2022 02:39
    +2

    Хм, замена bash команд? А wget, как минимум, какое отношение имеет к bash?