1. Старт сервера та конфігурація
Коли ви вперше запускаєте локальний HTTP-сервер, дуже хочеться зробити все нашвидкуруч: написати 8080 прямо в коді, запустити — і радіти життю. І так, перші пʼять хвилин життя проєкту справді хочеться прожити саме так. Проблема в тому, що бекенд-код зазвичай живе довше за пʼять хвилин, а порт 8080 у світі розробників — це як розетка біля холодильника: здається, що вона вільна рівно доти, доки хтось не підʼєднає туди чайник, мікрохвильовку й зарядку від ноутбука одночасно.
Для server-режиму нам потрібно лише кілька стартових параметрів. Але ці параметри — не «частина логіки застосунку», а «частина середовища, у якому застосунок запущено». Сьогодні це ваш ноутбук, завтра — ноутбук ментора, післязавтра — CI, а потім ви просто відкриєте другий проєкт, який теж захоче 8080 (і, звісно ж, переможе в бійці).
Уже є набір об’єктів JDK: ми розуміємо, що HttpServer живе довго, а HttpExchange приходить на кожен запит. Тепер із цих цеглинок треба зібрати живий процес, який реально привʼязується до host і port.
Тому старт сервера має виглядати як чітка послідовність: ми отримуємо ServerConfig із загальної конфігураційної моделі проєкту, збираємо InetSocketAddress, створюємо HttpServer, викликаємо start() і одразу ж пишемо зрозумілий лог: яка адреса, який порт, яке імʼя застосунку. Це не «занудство заради занудства» — це базова звичка, яка відрізняє «код на вечір для себе» від «код, який ви зможете пояснити й підтримувати».
Щоб було простіше орієнтуватися, ось мінімальний фрагмент application.properties, який нам потрібен саме для server-mode:
Файл: src/main/resources/application.properties
app.name=readlater-starter
server.host=localhost
server.port=8080
Ми не повторюємо тут механіку читання properties (це було в дні про конфігурацію), але тримаємо в голові важливу думку: сервер стартує на тому, що прийшло з конфігурації, а не на тому, що випадково набралося в класі LocalApiServer.
2. ServerConfig: record замість параметрів
Коли ви запускаєте сервер, у код легко просочується «пара зайвих аргументів». Спочатку потрібні host і port. Потім раптово хочеться додати appName (і це логічно — ми хочемо повертати його в /health). Потім — ще щось. Якщо ці значення ганяти окремими параметрами, ваш код швидко перетворюється на «паровозик з аргументів», де легко переплутати порядок, типи й сенс.
Для таких випадків дуже зручно виділяти маленький конфіг-об’єкт. У Java 25 ідеальний формат для цього — record: він короткий, зрозумілий і за змістом чудово підходить для контейнера налаштувань.
Ось мінімальна форма ServerConfig, якої вистачить на сьогоднішній запуск:
package com.example.readlater.config;
/**
* Конфігурація запуску HTTP-сервера.
* Тут лише те, що стосується середовища (де й як стартуємо), а не бізнес-логіки.
*/
public record ServerConfig(
String host, // Хост/інтерфейс для привʼязування (наприклад, localhost)
int port, // Порт, який слухатиме сервер
String appName // Імʼя застосунку для логів/health тощо
) {
}
Зверніть увагу на дрібницю: ми кладемо ServerConfig у пакет config. Це чесно відображає роль об’єкта: він не «серверна логіка», а налаштування для серверного режиму.
Тепер питання: звідки взяти ServerConfig? В ідеалі — з вашої загальної конфігураційної моделі (умовно AppConfig). Назва класів у вас може бути іншою, але ідея однакова: конфіг застосунку зберігає appName, server.host, server.port. Далі ми або зберігаємо ServerConfig як частину AppConfig, або збираємо його на місці.
Приклад збирання — коротко й без заглиблення в читання properties:
import com.example.readlater.config.ServerConfig;
// У прикладі значення "захардкожені" лише для демонстрації.
// У реальному застосунку їх потрібно брати з уже завантаженої конфігурації (properties/env/etc).
ServerConfig serverConfig = new ServerConfig(
"localhost",
8080,
"readlater-starter"
);
У реальному проєкті замість літералів будуть значення з уже завантаженого AppConfig. І головний плюс у тому, що далі весь код запуску сервера приймає один об’єкт. Не три параметри, не пʼять, не «а ще один прапорець», а один компактний ServerConfig.
3. LocalApiServer: окремий клас запуску
Є давня легенда: якщо довго писати все в main(), то одного дня main() почне писати код замість вас. Легенда, звісно, неправдива. Але те, що main() роздується до сотень рядків і стане найстрашнішим місцем проєкту, — це цілком реальна доля.
ReadLaterApplication (або як у вас називається точка входу) має робити одну річ: обирати режим і збирати залежності. А ось запускати сервер, будувати адресу, ловити IOException, писати лог запуску — це окрема відповідальність. Саме тому ми виносимо запуск в окремий клас, наприклад LocalApiServer.
Він буде дуже маленьким. І це добре. Це «пульт керування сервером», а не «серверний фреймворк імені нас». Він уміє запускати сервер на адресі з ServerConfig і логувати результат.
На цьому кроці LocalApiServer відповідає лише за старт і привʼязку до адреси. Цього достатньо, щоб довести: процес став сервером. Коли зʼявиться перший зовнішній шлях, той самий клас просто отримає реєстрацію /health і JSON-відповідь.
Мінімальна заготовка:
package com.example.readlater.app.server;
import com.example.readlater.config.ServerConfig;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
public class LocalApiServer {
public HttpServer start(ServerConfig config) throws IOException {
// Перетворюємо host/port із конфігурації на "мережеву адресу", на якій слухатиме сервер
InetSocketAddress address =
new InetSocketAddress(config.host(), config.port());
// backlog = 0 => використовуємо значення за замовчуванням (у деталі сьогодні не заглиблюємося)
HttpServer server = HttpServer.create(address, 0);
// Запускаємо приймання вхідних зʼєднань
server.start();
// Повертаємо сервер, щоб за потреби можна було коректно зупинити (server.stop(...))
return server;
}
}
Поки тут немає логування — ми додамо його в наступному розділі. І так, метод повертає HttpServer. Навіть коли зараз здається «навіщо повертати, я ж усе одно нічого з ним не роблю», це корисна звичка: ми зберігаємо можливість у майбутньому коректно зупинити сервер через server.stop(...), не перетворюючи застосунок на набір глобальних змінних.
Тепер підключимо це в точку входу. Важливо: ReadLaterApplication не має будувати сервер по цеглинках. Він має створити LocalApiServer і викликати start(...). Все.
Наприклад, фрагмент гілки SERVER (припускаємо, що launchMode уже розпізнано):
import com.example.readlater.app.server.LocalApiServer;
import com.example.readlater.config.ServerConfig;
// Поки сервер відповідає лише за старт і привʼязку до адреси, тому залежностей у нього мінімум
LocalApiServer localApiServer = new LocalApiServer();
// Припускаємо, що appConfig уже зібрано з application.properties (або іншого джерела)
ServerConfig serverConfig = appConfig.server(); // припустімо, так
localApiServer.start(serverConfig);
Зверніть увагу, як читається код. Він майже «розмовний»: є сервер, є конфіг, запускаємо. І головне — у ReadLaterApplication не з’являються деталі на кшталт InetSocketAddress і HttpServer.create(...). Це деталі серверної частини, їхнє місце — у LocalApiServer.
4. Створюємо HttpServer: адреса і start()
Зараз ми зробимо найважливіший крок лекції: від «у нас є режим server на словах» до «у нас реально стартує HTTP-сервер». І в цьому місці дуже корисно прямо проговорити, що відбувається на рівні коду, без спроби запамʼятати все як заклинання.
Ми беремо host і port, збираємо з них InetSocketAddress і передаємо в HttpServer.create(...). Другий аргумент create(...) — це backlog. Якщо говорити по-людськи, backlog — це «скільки вхідних зʼєднань можна потримати в черзі, поки сервер зайнятий». У production це може бути цікавою темою, але сьогодні — ні. Ми ставимо 0, щоб використовувати значення за замовчуванням, і свідомо не ліземо в низькорівневе налаштування.
Давайте додамо в LocalApiServer логування, обробку винятків на рівні виклику й нормальний лог запуску. Почнімо з логера:
package com.example.readlater.app.server;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LocalApiServer {
// Логер на клас: у логах буде зрозуміло, звідки прилетіло повідомлення
private static final Logger log =
LoggerFactory.getLogger(LocalApiServer.class);
}
Тепер розширимо метод start(...) так, щоб він логував запуск. Важливо: ми хочемо побачити в логах готове посилання, за яким будемо ходити в API. Тому лог краще писати у вигляді http://host:port.
import com.example.readlater.config.ServerConfig;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
public HttpServer start(ServerConfig config) throws IOException {
// Готуємо адресу, до якої привʼязується сервер
InetSocketAddress address =
new InetSocketAddress(config.host(), config.port());
// Створюємо сервер і запускаємо його
HttpServer server = HttpServer.create(address, 0);
server.start();
// Пишемо зрозумілий лог запуску: одразу видно URL і імʼя застосунку
// {} — плейсхолдери SLF4J (без конкатенації рядків)
log.info("Локальний API запущено: http://{}:{}, імʼя застосунку={}",
config.host(), config.port(), config.appName());
// Повертаємо server, щоб код, який викликає, міг за бажанням зупинити його пізніше
return server;
}
Зверніть увагу, чого тут ще немає: сервер уже слухає адресу й живе після start(), але зовні йому поки нема на що відповісти по-справжньому. Поки не зʼявиться хоча б один createContext(...), зовнішній клієнт не побачить корисної кінцевої точки з передбачуваним контрактом.
Зверніть увагу на стиль SLF4J: ми не конкатенуємо рядки через +, а використовуємо плейсхолдери {}. Це водночас і коротше, і ефективніше, і просто «так прийнято» в живих проєктах. І так, це та дрібниця, яка в майбутньому відділятиме ваш код від коду, де логування перетворене на “println, але з анотаціями”.
Ще один важливий момент. HttpServer.create(...) і start() можуть кинути IOException. На практиці найчастіша причина — порт зайнятий. Це нормально. Не «зламалась Java», не «поганий JDK», а просто хтось уже слухає цей порт. Тому на рівні ReadLaterApplication ми маємо чесно зловити виняток, залогувати й завершитися.
Приклад (короткий, без зайвої архітектури):
try {
// Коли порт вільний — сервер стартує, і процес продовжить жити в режимі очікування запитів
localApiServer.start(serverConfig);
} catch (IOException e) {
// Важливо передати e: так у логах буде stack trace (часто там одразу видно BindException і причину)
log.error("Сервер не вдалося запустити на {}:{}",
serverConfig.host(), serverConfig.port(), e);
}
Тут ми робимо одразу дві корисні речі. По-перше, у логах буде видно, на якій адресі ми намагалися стартувати. По-друге, ми зберігаємо stack trace через e — він стане у пригоді, якщо це не «порт зайнятий», а щось рідкісніше.
5. Лог запуску сервера
У логуванні є просте правило: лог має відповідати на питання «що мені робити далі?». Із запуском сервера це правило працює ідеально. Після запуску застосунку ви зазвичай хочете зрозуміти три речі: чи стартував процес узагалі, на якій адресі слухає сервер і який це застосунок (особливо якщо у вас одночасно відкрито два термінали і три проєкти — а так і буде).
Тому хороший лог запуску для server-mode зазвичай містить host, port, appName, а іноді ще й підказку на кшталт “Press Ctrl+C to stop”. Це не «професійна звичка», це просто маленька турбота про себе з майбутнього. Себе з майбутнього, до речі, треба любити: саме він розгрібатиме ваші ж логи.
Ось приклад формату, який достатньо інформативний і при цьому не перетворюється на роман:
// Слеш у кінці — суто візуальна дрібниця: посилання виглядає "як URL", зручно копіювати
log.info("Сервер запущено на http://{}:{}/ (імʼя застосунку={})",
config.host(), config.port(), config.appName());
Чому я додав / у кінці? Це суто візуальна дрібниця: посилання виглядає «завершеним». Коли ви копіюєте його мишкою з термінала, іноді хочеться, щоб воно було у формі URL. Це необовʼязково, але приємно.
Можна додати ще один рядок, якщо не хочете, щоб один був надто довгим:
// Підказка для користувача: у навчальному сценарії це найпростіший спосіб зупинити сервер
log.info("Натисніть Ctrl+C, щоб зупинити сервер.");
І так — це той випадок, коли трохи тексту в логах корисніше, ніж мовчання. Згадуйте: наші логи — це «панель приладів» застосунку. Якщо прилади не показують швидкість, ви все одно їдете, просто трохи нервово.
Ще нюанс: на старті server-mode корисно логувати те, що ми взагалі в server-mode, особливо якщо застосунок запускається з IDE і в вас паралельно бувають client-команди. Ви здивуєтеся, як часто «я думав, що запустив сервер, а запустив catalog details». З людиною таке трапляється; людина — не JVM.
Приклад:
log.info("Режим запуску: SERVER");
Головне — не переборщити. Два-три рядки — чудово. Двадцять три рядки з ASCII-артом — уже… теж мистецтво, але не те, яке допомагає.
6. Життєвий цикл після start()
Коли ви пишете консольні програми, main() зазвичай завершується — і процес закінчується. Це звично: зробив роботу, надрукував результат, вийшов. Серверний застосунок живе інакше. Він запускається і далі «чекає подій» — вхідних запитів. У сервера немає природного «я закінчив» (хіба що ви написали сервер, який після першого запиту йде у відпустку).
У HttpServer після start() з’являються робочі потоки, які продовжують слухати порт і приймати з’єднання. Тому навіть якщо ваш метод startServer(...) повернувся, JVM залишається жити. Це той самий момент, коли новачок дивиться в термінал і думає: «Ой, воно зависло». Ні, воно не зависло. Воно працює. Це сервер. Він чекає.
Що важливо зробити на рівні мислення: розділити два стани. «Програма зависла» — це коли вона не може продовжити й нічого не робить. «Сервер працює» — це коли він здебільшого нічого не робить (бо запитів немає), але готовий миттєво відреагувати, коли вони зʼявляться. Це як охоронець: якщо він сидить спокійно, це не означає, що він зламався. Це означає, що день спокійний (поки що).
Ще один момент: вам може захотітися «заблокувати main thread», щоб “точно не завершилося”. У HttpServer зазвичай це не потрібно, він і так тримає процес живим. І це добре: менше коду, менше магії, менше «а навіщо тут CountDownLatch?». Ми зберігаємо простоту доти, доки вона не заважає.
На цьому етапі важливо просто прийняти: у server-режимі застосунок не має завершуватися одразу. Він має запуститися, залогувати адресу — і жити, поки ви його не зупините. Зупинка в нашому навчальному сценарії — це банально Ctrl+C. Тема graceful shutdown — цікава, але це явно не мета сьогоднішнього дня (і вже точно не мета цієї лекції).
7. Типові помилки під час запуску сервера
У цьому місці зазвичай трапляється перша серія маленьких «а чому не працює». Це нормально: ви вперше переходите від «усе всередині процесу» до «є порт, є сервер, є зовнішній світ». Помилки тут часто не про Java, а про середовище й дисципліну запуску.
Помилка №1: хардкод порту в коді.
Здається безневинним написати new InetSocketAddress("localhost", 8080), тому що «ну ми ж на своїй машині». Але на своїй машині порт 8080 може бути зайнятий чим завгодно: іншим вашим проєктом, Docker (якщо він у вас є), чужим демо, IDE-плагіном. Правильна стратегія — брати host/port із конфігурації та мати можливість змінити порт без перекомпіляції. Навіть у навчальному проєкті це економить час.
Помилка №2: лог “Server started” без адреси.
Такий лог виглядає бадьоро, але він марний. Ви все одно в наступну секунду поставите питання: «А куди стукати?». Тому на старті сервера майже завжди логують URL або хоча б host:port. Один хороший лог зменшує кількість «чому не працює» приблизно наполовину — перевірено на людях (зокрема на авторі цих рядків).
Помилка №3: ловити IOException, але не логувати stack trace.
Іноді пишуть catch(IOException e) { log.error("Не вдалося"); }. Це як «лікар сказав: вам погано». Так, дякую. Якщо порт зайнятий, ви побачите у винятку BindException (часто всередині), і це одразу пояснює проблему. Тому в логові при помилці запуску майже завжди передають e, щоб отримати stack trace. У навчальному проєкті це особливо важливо: ви вчитеся читати помилки, а не боятися їх.
Помилка №4: запускати server-mode, але думати, що запустили client-mode (або навпаки).
Це дуже людська помилка. Ви змінюєте args, запускаєте, бачите, що щось вивелося, і мозок каже: «Ну, значить, сервер». А потім виявляється, що ви знову зробили catalog details, і жодного сервера немає. Лікується логом режиму запуску (Режим запуску: SERVER) і звичкою дивитися на лог запуску.
Помилка №5: складати код запуску сервера в ReadLaterApplication, поки він не стане нечитабельним.
На початку здається, що «так простіше». Але вже за наступну лекцію ви додасте /health, зʼявиться ObjectMapper, зʼявляться обробники — і ReadLaterApplication перетвориться на довгий сценарій, де складно знайти, що взагалі відбувається. Набагато здоровіше виділити LocalApiServer і тримати код запуску там, а точку входу залишити «диригентом»: обрати режим, зібрати залежності, викликати потрібний компонент.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ