惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Кроссплатформенное приложение на Go руками PHP разработчика
itatarchenko · 2026-05-19 · via Все публикации подряд на Хабре

Уровень сложностиПростой

Время на прочтение16 мин

Охват и читатели933

Туториал

Содержание

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

Я хочу рассказать, как подошел к изучению 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