golang

Кроссплатформенное приложение на Go руками PHP разработчика

  • среда, 20 мая 2026 г. в 00:00:12
https://habr.com/ru/companies/betboom/articles/1031916/

Содержание

Идея
Стартуем!
Конфигурация
Сетевой запрос
Использование горутин
Уведомления
Первая версия
Рефакторинг
Системный трей
Интернационализация
Заключение

Я хочу рассказать, как подошел к изучению Go на примере разработки приложения для проверки доступности сайтов. Последовательно проведу от идеи до рефакторинга проекта, а по пути объясню выбор тех или иных решений. В начале прошлого года меня заинтересовал язык Go - подкупал рост его популярности. В вакансиях на hh всё чаще встречались требования: Go + Python, Go + PHP. Для веб-разработки python и php во многом похожи, и у меня уже был опыт работы с ними. Кроме того, мне просто нравится изучать новые языки программирования - расширение кругозора помогает докопаться до понимания сложных тем.

К сожалению, загруженный рабочий график и «бесконечность» ленты рилсов не позволили продвинуться. Я сделал лишь пару-тройку консольных утилит, но большой пользы не получил. Проекты на Go оставались для меня китайской грамотой.

В новогодние праздники я решил посвятить себя практике программирования.

Поезд PHP - GO
Поезд PHP - GO

Идея

Программирование это навык, который даст только практика. На мой взгляд, язык можно изучить только работая с ним. Учебники и документация — справочники. Код сам по себе не имеет ценности, она появляется только у рабочего приложения. А опыт разработчика это разработанные им приложения, а не прочитанные книги.

Исходя из этого, для освоения Go я решил разработать приложение. Небольшой, но законченный проект, чтобы сделать и не бросить.

На антресоли лежала идея: приложение для периодической проверки доступности сайтов. У меня есть несколько сайтов, которые иногда перестают работать без моего разрешения. Очень неприятно узнавать об этом от заказчиков. Для этой задачи Go подходит как нельзя лучше.

Он компилируется в один исполняемый файл — запустил на любой машине, и никаких конфликтов зависимостей. У меня был опыт с Electron и PyInstaller, но у этих технологий много нюансов и компромиссов. Electron громоздкий и избыточен для реализации моей идеи, а PyInstaller просто бесит, потому что могут возникнуть проблемы при сборке. Идти в сторону Windows Forms C++, C# мне пока не интересно.

Идея - есть! теперь необходимо понять, когда считать разработку завершенной. Делаем техническое задание максимально конкретным.

Требуется:

  • Фоновое приложение для ОС Windows

  • Работа в трее

  • Отправка системных уведомлений о сбоях

Стартуем!

Стартуем!
Стартуем!

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

Практически всегда, как бы это ни выглядело глупо, любая разработка начинается с `Hello world`. Вывод в терминал этой строки означает, что я подготовил проект для разработки.

package main
import "fmt"
func main() {
    fmt.Println("Hello, World!")
}

Поздоровались и можно двигаться дальше — главное вежливость!

Конфигурация

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

Вот так выглядит конфигурация:

sites:
  - url: "https://google.com"
    name: "Google"
    timeout: 30

  - url: "https://yandex.ru"
    name: "Yandex"
    timeout: 30

notifications:
  show_popup: true
  console_output: true

general:
  check_interval: 60
  concurrent_checks: 3

Для работы с конфигурацией мне понадобилась библиотека gopkg.in/yaml.v3 и структуры для каждой секции:

// Структуры для конфигурации
type Config struct {
	Sites          []SiteConfig `yaml:"sites"`
	Notifications  Notifications `yaml:"notifications"`
	General        GeneralConfig `yaml:"general"`
}

type SiteConfig struct {
	URL     string `yaml:"url"`
	Name    string `yaml:"name"`
	Timeout int    `yaml:"timeout"`
}

type Notifications struct {
	ShowPopup    bool `yaml:"show_popup"`
	ConsoleOutput bool `yaml:"console_output"`
}

type GeneralConfig struct {
	CheckInterval   int `yaml:"check_interval"`
	ConcurrentChecks int `yaml:"concurrent_checks"`
}

Самое необычное для меня в Go — struct и struct tags. Я не встречал такого в PHP, Python и JavaScript, поэтому поначалу я не понимал, что это и как с этим работать. Ещё больше пугали непонятные строки в обратных кавычках после полей:

yaml:"sites"

Оказалось, это struct tags — метаданные, которые библиотеки (например, yaml) используют для сопоставления полей структуры с внешними данными. И здесь всплывает важное правило Go: поле с маленькой буквы — приватное (неэкспортируемое), внешний пакет yaml не может в него записать данные. Поэтому поля делаются с большой буквы (публичные), а тег связывает их с ключом в YAML, который обычно пишется с маленькой. Так работает интеграция между конфигом и приложением — я описываю структуру, а библиотека сама парсит метаинформацию и заполняет поля. Удобно, хоть и непривычно.

После того как я настроил загрузку конфигурации, вынес код в отдельную функцию и забыл о ней.

func loadConfig(filename string) (*Config, error) {
	data, err := os.ReadFile(filename)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("файл конфигурации %s не найден", filename)
		}
		return nil, err
	}
	var config Config
	err = yaml.Unmarshal(data, &config)
	if err != nil {
		return nil, err
	}

	return &config, nil
}

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

Сетевой запрос

Проверку сайтов я осуществляю при помощи HTTP-запросов. В Go доступна стандартная библиотека net/http, которая содержит всё необходимое для отправки и чтения запросов. Сначала я разбираюсь, как работать с одним запросом, а затем запускаю перебор всего списка сайтов из конфигурации.

Определяю структуру результата для последующей обработки:

type CheckResult struct {
	Site      SiteConfig
	Success   bool
	StatusCode int
	Error     string
	Duration  time.Duration
}

Создаю клиент. В net/http есть готовые методы (http.Get(), http.Post()), но они не позволяют задать таймаут. Поэтому я создаю собственный клиент с нужными настройками:

client := &http.Client{
	Timeout: time.Duration(site.Timeout) * time.Second,
	Transport: &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
	},
}

Затем создаю запрос. В стандартной библиотеке нужно отдельно создавать и выполнять запросы. Это добавляет гибкости, но мне показалось неудобным по сравнению с однострочными методами вроде http.Get().

Определяю в http.NewRequestпараметры запроса. В случае ошибки возвращаем результат с ошибкой. После создания Request устанавливаем заголовок для идентификации на сервере.

req, err := http.NewRequest("GET", site.URL, nil)
if err != nil {
	return CheckResult{
		Site:     site,
		Success:  false,
		Error:    fmt.Sprintf("Ошибка создания запроса: %v", err),
		Duration: time.Since(start),
	}
}
req.Header.Set("User-Agent", "WebsiteChecker/1.0")

Использую ранее созданный client — вызываю метод client.Do(req). Метод установит соединение.

resp, err := client.Do(req)
if err != nil {
	return CheckResult{
		Site:     site,
		Success:  false,
		Error:    fmt.Sprintf("Ошибка соединения: %v", err),
		Duration: time.Since(start),
	}
}
defer resp.Body.Close()

Если соединение успешно, то можно прочитать ответ. Потребовалось узнать статус и понять, есть ли что-то в теле ответа для определения его работы. Беру первые 4КБ, если ошибки нет — значит данные есть. Бывают ситуации, когда сервер может закрыть соединение после возврата заголовков.

_, err = io.CopyN(io.Discard, resp.Body, 4096)
if err != nil && err != io.EOF {
	return CheckResult{
		Site:       site,
		Success:    false,
		StatusCode: resp.StatusCode,
		Error:      fmt.Sprintf("Ошибка чтения ответа: %v", err),
		Duration:   time.Since(start),
	}
}

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

success := resp.StatusCode >= 200 && resp.StatusCode < 300

Формируем флаг success из resp.StatusCode для показа соответствующей иконки в трее. Затем возвращаем из функции всё ту же структуру CheckResult.

Тестирую. Основа приложения есть — один сайт точно будет проверен.

Использование горутин

И вот подошел к еще одной сложной теме, а именно горутины. Так сложилось, что я в основном пишу синхронный код. И чтобы не перетрудиться, конечно, использовал синхронный подход. Но когда функция была готова, я увидел возможности для параллельного выполнения. За параллельное выполнение в языке Go отвечают горутины (goroutines).

Как оказалось, ничего сложного в работе с горутинами нет. Я сделал метод для проверки всех сайтов. В параметры метода передаем указатель на конфигурацию и флаг verbose. Данный флаг отвечает за вывод логов во время работы для отладки.

func checkAllSites(config *Config, verbose bool) []CheckResult {
    var wg sync.WaitGroup
    results := make([]CheckResult, len(config.Sites))
    semaphore := make(chan struct{}, config.General.ConcurrentChecks)
    for i, site := range config.Sites {
        wg.Add(1) // Увеличиваем счётчик ожидаемых горутин
        
        // Запускаем анонимную функцию в горутине
        go func(idx int, site SiteConfig) {
            defer wg.Done() // Уменьшаем счётчик при завершении
            
            // Занимаем слот в семафоре (блокируется, если все слоты заняты)
            semaphore <- struct{}{}
            defer func() { <-semaphore }() // Освобождаем слот
            
            results[idx] = checkSite(site, verbose)
        }(i, site) // Явно передаём значения, чтобы избежать замыкания
    }
    wg.Wait() // Ожидаем завершения всех горутин
    return results
}

Этот код выполняет параллельную проверку всех сайтов из конфигурации:

  • sync.WaitGroup — ожидает завершения всех горутин;

  • Семафор (semaphore) — ограничивает количество одновременных проверок (не более чем указано в config.General.ConcurrentChecks);

  • Для каждого сайта запускается горутина, которая вызывает checkSite() и сохраняет результат в слайс;

  • Результаты возвращаются после завершения всех проверок;

  • results — слайс, содержащий результаты проверки.

Так как мне необходимо вернуть результат работы функции проверки сайтов, я использовал структуру WaitGroup из пакета sync и создал переменную этого типа — wg. Данная структура работает как счётчик, который увеличивается на каждой итерации на единицу, а при помощи оператора defer вызывает метод уменьшения, что говорит о том, что горутина выполнена. Для определения горутины используется оператор go перед вызовом анонимной функции.

Вызов метода wg.Wait() указывает, что нужно дождаться завершения всех горутин, то есть когда состояние счётчика будет равно нулю.

Уведомления

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

Для начала следует указать название приложения:

beeep.AppName = "Websitechecker"

Затем вызов показа уведомления происходит с помощью метода Notify:

beeep.Notify(
		title,
		msg,
		iconPath,
	)

Данный код вызовет уведомление операционной системы.

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

Уведомление об успешной проверке:

func sendSuccessNotification(config *Config) {
	if !config.Notifications.ShowPopup {
		return
	}

	title := "Website Checker"
	msg := "✅ Все сайты работают нормально!"
	iconPath := "assets/info.ico"
	// Используем beeep для уведомлений Windows
	err := beeep.Notify(
		title,
		msg,
		iconPath, // Можно заменить на свой иконку
	)
	
	if err != nil {
		fmt.Printf("Ошибка отправки уведомления: %v\n", err)
	}
}

И уведомление о том, на каких сайтах проверка завершилась ошибкой:

func sendFailNotification(config *Config, failedResults []CheckResult) {
    if !config.Notifications.ShowPopup || len(failedResults) == 0 {
        return
    }

    title := "Website Checker"
    iconPath := "assets/danger.ico"
    // Формируем сообщение с деталями
    msg := "⚠️ Обнаружены проблемы с сайтами:\n\n"
    for _, result := range failedResults {
        statusText := "ОШИБКА"
        if result.StatusCode > 0 {
            statusText = fmt.Sprintf("Статус: %d", result.StatusCode)
        }
        
        duration := result.Duration.Round(time.Millisecond)
        msg += fmt.Sprintf("• %s: %s (время: %v)\n", 
            result.Site.Name, 
            statusText, 
            duration,
        )
    }

    err := beeep.Notify(title, msg, iconPath)
    
    if err != nil {
        fmt.Printf("Ошибка отправки уведомления: %v\n", err)
    }
}

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

В коде указан относительный путь assets/info.ico. При запуске через go run иконки берутся из internal/app/assets/, а при компиляции их нужно скопировать в папку с exe.

Библиотека beeep — это обёртка над нативными API Windows, Linux и macOS. Вам не нужно писать отдельный код под каждую ОС, хотя есть нюансы при компиляции.

Уведомление об успешной загрузке конфигурации
Уведомление об успешной загрузке конфигурации
Уведомление с логом операций проверки
Уведомление с логом операций проверки
Уведомление об ошибке проверки
Уведомление об ошибке проверки
Уведомление об успешной проверке
Уведомление об успешной проверке

Первая версия

Я получил первую базовую версию приложения. На данном этапе оно запускалось, выполняло все проверки и выводило уведомления. Этого явно недостаточно, но главную задачу "проверять сайты" уже решает - делаем коммит и двигаемся дальше.

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

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

Джарвис, рефакторинг на максимум!
Джарвис, рефакторинг на максимум!

Рефакторинг

Если разобрать текущее состояние приложения на компоненты, то можно увидеть, что оно состоит из трех частей:

  • Конфигурация

  • Проверка сайтов

  • Уведомления

Эти части могут стать отдельными модулями (package). Немного изучив образцы, я обнаружил, что в сообществе разработчиков Go принято использовать директорию internal в корне проекта для хранения внутренних пакетов приложения.

У каждого языка и фреймворка есть принятая система названий — тезаурус. Например, в python модуль это файл, а пакет это директория с __init__.py. В go другая система: package это директория, а модуль это объединенные package с go.mod файлом. Package в Go это единица компиляции, поэтому недопустимо использовать в одной директории несколько package.
В других известных мне языках (PHP, Javascript) эти понятия отличаются. Это вносит дополнительную сложность в понимание работы разных языков программирования. Поэтому, без практики, ни один язык не удастся "выучить".

Изучив примеры кода, я прикинул каким делать приложение, чтобы его было удобно разрабатывать.


Рефакторинг — это процесс улучшения внутренней структуры кода без изменения его внешнего поведения.

Разобравшись со структурой приложения, я решил разделить код на пакеты. Привёл код к следующей структуре:

├───cmd
│   └───website-checker
│           main.go
│
├───dist
│       config.yml
│       website-checker.exe
│
└───internal
    ├───app
    │       app.go
    │
    ├───checker
    │       checker.go
    │
    ├───config
    │   │   config.go
    │   │
    │   └───assets
    │           danger.ico
    │           info.ico
    │
    └───notification
            notification.go

Теперь объясню логику этой структуры.

cmd/website-checker — точка входа в приложение. Здесь лежит main.go, который запускает всю программу. В Go принято хранить исполняемые пакеты именно в cmd.

internal/ — служебная папка, в которой лежат внутренние пакеты. Это механизм языка: код внутри internal может импортироваться только самим приложением, но не внешними проектами. Так я явно отделил то, что предназначено только для этого приложения, от потенциально переиспользуемого кода.

dist/ — папка для сборки. Я храню бинарный файл website-checker.exe и готовый config.yml прямо в репозитории. Это удобно: можно скачать и сразу запустить, без компиляции и настройки.

А теперь о том, как распределилась логика по пакетам внутри internal:

  • app/ — оркестратор. Именно здесь всё собирается вместе: конфигурация, проверки, уведомления, трей. app.go содержит основной цикл приложения и связывает остальные модули.

    • assets/ — встроенные ресурсы: иконки для уведомлений и трея.

  • checker/ — проверка сайтов. Вся логика сетевых запросов, таймаутов, параллельной проверки — здесь.

  • config/ — работа с конфигурацией. Загрузка YAML-файла, парсинг, доступ к настройкам.

notification/ — системные уведомления. Обёртка над библиотекой beeep с поддержкой иконок и разных типов сообщений.

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

Системный трей

Системный трей (System Tray) — это область на панели задач операционной системы (обычно в правом нижнем или верхнем углу экрана), которая содержит значки фоновых системных функций, активных приложений и служебных уведомлений.

Иконка приложения в системном трее
Иконка приложения в системном трее

Для реализации работы с системным треем я использовал пакет github.com/getlantern/systray. Это кроссплатформенная библиотека, которая поддерживает Linux, macOS и Windows.

Я создал пакет в internal/systray. Определил переменные в области видимости пакета — те переменные, к которым потребуется доступ из разных методов. Написал методы для старта работы пакета извне.

var (
	stopChan        chan bool
	mutex           sync.RWMutex
	checking        bool
	cfg				*config.Config
	lastCheckTime   time.Time
	lastCheckResult string
	configFile 		*string
	mStatus 		*systray.MenuItem
)

func Run(globalConfig *config.Config, configFilePath string) {
	cfg = globalConfig
	configFile = &configFilePath
	// Канал для остановки
	stopChan = make(chan bool)

	// Запускаем системный трей
	systray.Run(onReady, nil)
}

В systray.Run передаются методы onReady и onExit, которые отвечают за запуск и прекращение работы. По сути метод onReady содержит основной жизненный цикл программы. Метод onExit я опустил, потому что не нашел для него работы. Если передать nil библиотека зарегистрирует пустую анонимную функцию.

func onReady() {
	setIcon()
	setMenu()
	go backgroundChecker(mStatus)
}

Метод onReady регистрирует иконку приложения и пункты меню, которое открывается при нажатии на иконку в трее. Затем запускается фоновый процесс в котором идет основная работа.
Библиотека systray содержит метод AddSeparator, который добавляет разделитель, добавляя возможность группировки пунктов меню по смыслу.

func setIcon() {
	systray.SetIcon(app.IconGood)
	systray.SetTitle(i18n.T("checker_title"))
	systray.SetTooltip(i18n.T("checker_tooltip"))
}

func setMenu() {
	mCheckNow := systray.AddMenuItem(i18n.T("checker_check_now"), i18n.T("checker_check_now_tooltip"))
	mStatus = systray.AddMenuItem(i18n.T("checker_status_not_checked"), i18n.T("checker_status_not_checked_tooltip"))
	mStatus.Disable()

	systray.AddSeparator()

	mSettings := systray.AddMenuItem(i18n.T("checker_settings"), i18n.T("checker_settings_tooltip"))
	mViewLog := systray.AddMenuItem(i18n.T("checker_view_log"), i18n.T("checker_view_log_tooltip"))

	systray.AddSeparator()

	mPause := systray.AddMenuItem(i18n.T("checker_pause"), i18n.T("checker_pause_tooltip"))
	mRestart := systray.AddMenuItem(i18n.T("checker_restart"), i18n.T("checker_restart_tooltip"))
	mQuit := systray.AddMenuItem(i18n.T("checker_quit"), i18n.T("checker_quit_tooltip"))

	go func() {
		for {
			select {
			case <-mCheckNow.ClickedCh:
				mutex.Lock()
				checking = true
				mutex.Unlock()

				results := checker.CheckAllSites(cfg)
				updateStatus(results, mStatus)

				mutex.Lock()
				checking = false
				mutex.Unlock()

			case <-mSettings.ClickedCh:
				openConfigFile()

			case <-mViewLog.ClickedCh:
				notification.ShowLog(lastCheckResult)

			case <-mPause.ClickedCh:
				togglePause(mPause)

			case <-mRestart.ClickedCh:
				restartApp()

			case <-mQuit.ClickedCh:
				close(stopChan)
				systray.Quit()
				return
			}
		}
	}()
}

Регистрируем меню и запускаем диспетчер — горутину для обработки событий клика по меню. При помощи select loop перебираем все пункты меню, которые содержат канал ожидающим событие клика.
Данный код уже использует модуль i18n, который я опишу в следующем разделе.

func backgroundChecker(statusItem *systray.MenuItem) {
	ticker := time.NewTicker(time.Duration(cfg.General.CheckInterval) * time.Second)
	defer ticker.Stop()

	// Первая проверка сразу при старте
	results := checker.CheckAllSites(cfg)
	updateStatus(results, statusItem)

	for {
		select {
		case <-ticker.C:
			mutex.RLock()
			isChecking := checking
			mutex.RUnlock()

			if !isChecking {
				results := checker.CheckAllSites(cfg)
				updateStatus(results, statusItem)
			}

		case <-stopChan:
			return
		}
	}
}

func updateStatus(results []checker.CheckResult, statusItem *systray.MenuItem) {
	lastCheckTime = time.Now()

	failed := getFailedResults(results)
	allOK := len(failed) == 0

	// Обновляем иконку в зависимости от статуса
	if allOK {
		systray.SetIcon(app.IconGood)
		statusItem.SetIcon(app.IconGood)
		statusItem.SetTitle(fmt.Sprintf(i18n.T("checker_status_ok"), lastCheckTime.Format("15:04")))
		if cfg.Notifications.ShowPopup {
			notification.SendSuccess()
		}
	} else {
		systray.SetIcon(app.IconBad)
		statusItem.SetIcon(app.IconBad)
		statusItem.SetTitle(i18n.T("checker_status_error", len(failed), lastCheckTime.Format("15:04")))
		if cfg.Notifications.ShowPopup {
			notification.SendFail(failed)
		}
	}

	// Сохраняем результат для просмотра
	mutex.Lock()
	lastCheckResult = formatResults(results)
	mutex.Unlock()
}

Фоновый процесс backgroundChecker выполняет метод проверки всех сайтов по интервалу указанному в конфигурации. Затем результат передается в метод уведомления, который меняет статус и иконку сигнализируя о результате проверки.

Иконка приложения в состоянии ошибки
Иконка приложения в состоянии ошибки
func getFailedResults(results []checker.CheckResult) []checker.CheckResult {
	var failed []checker.CheckResult
	for _, result := range results {
		if !result.Success {
			failed = append(failed, result)
		}
	}
	return failed
}

func formatResults(results []checker.CheckResult) string {
	var output string
	for _, result := range results {
		status := "✅"
		if !result.Success {
			status = "❌"
		}
		output += fmt.Sprintf("%s %s: %d (%v)\n",
			status, result.Site.Name, result.StatusCode, result.Duration)
	}
	return output
}

// Вспомогательные функции
func openConfigFile() {
	// Открыть файл конфигурации в блокноте
	exec.Command("notepad.exe", *configFile).Start()
}

func togglePause(menuItem *systray.MenuItem) {
	mutex.Lock()
	checking = !checking
	if checking {
		menuItem.SetTitle(i18n.T("checker_pause"))
	} else {
		menuItem.SetTitle(i18n.T("checker_resume"))
	}
	mutex.Unlock()
}

func restartApp() {
	// Перезапуск приложения
	exe, _ := os.Executable()
	exec.Command(exe).Start()
	os.Exit(0)
}

Перед выводом в уведомление метод formatResults форматирует вывод.
Пакет содержит также вспомогательные методы, которые вызываются нажатием кнопок в меню трея, а именно:

  • Поставить/снять паузу для проверок

  • Перезапуск приложения

  • Открыть файл конфигурации

Меню приложения в системном трее
Меню приложения в системном трее
Состояние меню во время ошибки проверки
Состояние меню во время ошибки проверки

Интернационализация

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

Языковые файлы имеют одинаковую структуру, но значения ключей отличаются в зависимости от выбранного языка. Я выбрал json формат для хранения переводов.

Я использовал пакет embed для хранения словарей с переводом. Он позволяет встроить файловую систему в бинарный файл. На этапе компиляции переводы упаковываются в встроенную файловую систему и выполняемая программа получает к ним доступ.

package i18n

import (
	"embed"
	"encoding/json"
	"fmt"
	"strings"
)

//go:embed locales/*.json
var localesFS embed.FS

var translations map[string]string

func Load(lang string) error {
	if lang == "" {
		lang = "en" // Язык по умолчанию
	}

	fileName := fmt.Sprintf("locales/%s.json", lang)
	data, err := localesFS.ReadFile(fileName)
	if err != nil {
		// Если файл для языка не найден, пробуем загрузить английский
		data, err = localesFS.ReadFile("locales/en.json")
		if err != nil {
			return fmt.Errorf("failed to load default translation file: %w", err)
		}
	}

	err = json.Unmarshal(data, &translations)
	if err != nil {
		return fmt.Errorf("failed to parse translation file %s: %w", fileName, err)
	}

	return nil
}

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

func T(key string, args ...interface{}) string {
	translation, ok := translations[key]
	if !ok {
		return key
	}

	if len(args) > 0 && len(args)%2 == 0 {
		var replacerArgs []string
		for i := 0; i < len(args); i += 2 {
			placeholder := fmt.Sprintf("{%v}", args[i])
			value := fmt.Sprintf("%v", args[i+1])
			replacerArgs = append(replacerArgs, placeholder, value)
		}
		return strings.NewReplacer(replacerArgs...).Replace(translation)
	}

	return translation
}

Пакет i18n использует всего два метода:

  • Загрузка локали

  • Вывод текста по ключу

После того как я реализовал код переводчика, я прошел по всему приложению и заменил простые строки на вызов метода i18n.T.

Языковой файл представляет из себя json объект, где ключи используются для доступа к строке. Функция T реализует вставку переменных, их можно указать в фигурных скобках {}.
А вот пример языкового файла:

{
  "checker_quit": "Выход",
  "checker_quit_tooltip": "Выйти из программы",
  "checker_status_ok": "✅ OK ({time})",
  "checker_status_error": "⚠️ {count} ошибок ({time})",
}

Заключение

Вложил время в свое образование! Сделал полезное приложение, разобрался в языке программирования Go. Действительно, этот язык достаточно легко освоить и уловить базовые концепции, но как на нем разрабатывать большие проекты мне еще предстоит узнать.

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

Я пока не думаю, что мне бы хотелось все программировать на Go. Возможно, стоит больше с ним работать, чтобы лучше освоиться. Хочется отметить, что написание статьи об этой поделке заняло значительно больше времени, чем разработка. Но это позволило по новому взглянуть на свой код, исправить ошибки и глубже разобраться в языке. Статья рассчитана на новичков и надеюсь принесет пользу. Оставляю ссылку на github, если кому то это приложение пригодится. Буду рад вашим комментариям!

https://github.com/igoreshirokov/website-checker