habrahabr

«Голая Java» или разработка без всего

  • вторник, 24 сентября 2024 г. в 00:00:09
https://habr.com/ru/articles/841574/

Рассказываю что можно сделать на одном только голом JDK. Это старое и ныне почти забытое искусство разработки без внешних библиотек и фреймворков. Работать будем «как в былинные времена» — киркой и лопатой голыми руками и немного мозгом.

В работе.
В работе.

Disclaimer:

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

Обязательно посоветуйтесь с вашим психотерапевтом если родились после 2000х прежде чем читать дальше.

Disclaimer №2:

Конечно же я не призываю полностью отказываться от фреймворков и библиотек в рабочих проектах, данная статья — лишь демонстрация, что подобная разработка все еще вообще возможна.

Поскольку современные Java-разработчики почему-то считают, что без пары десятков библиотек Apache Commons, Spring и JPA с Hibernate разработки быть не может, а сразу за порогом любимого фреймворка начинается «страшный C++» и  ходят люди с песьими головами.

Disclamier №3:

Эта объемная работа предназначена в первую очередь для профессионалов разработки на Java, которые уже имеют практический опыт с большими фреймворками, так популярными в этом болоте среде и смогут в полной мере оценить всю сложность работы «без всего».

Что будем делать

Вот такое:

Это самая обычная на первый взгляд гостевая книга — древний аналог «стены» из ВКонтакта.

Еще это веб‑приложение на Java (и немного на JavaScript), сильно упрощенный аналог самой популярной связки из Spring Boot + Thymeleaf, которые используются для современной разработки каждый день.

Но только:

без фреймворков и библиотек.

Готовый проект был по традиции выложен на GitHub.

Фичи

  • Хранилище данных на диске

  • Локализация

  • Авторизация, роли и разграничение доступа

  • Добавление, просмотр и удаление записей гостевой

И все это сделано и работает на одном только JDK, без каких-либо внешних библиотек:

Без сервлетов, сервлет-контейнеров, серверов приложений и так далее.

Одна голая Java и все.

Технические фичи

  • Парсер и генератор JSON

  • Шаблонизатор страниц

  • Парсер выражений (Expression Language «а‑ля рюс»)

  • IoC-Контейнер

Напоминаю что все это реализовано с нуля в рамках проекта, без каких-либо внешних библиотек.

Наверное прикинув сейчас размеры каких-нибудь Wildfly, Spring, Thymeleaf или еще каких монстров вы подумали что слегка устанете это все читать?

Немного успокою:

  • ~800 строк кода, ~1200 с комментариями

  • 70кб итоговый «бинарник»

Технически наш проект будет представлять собой встроенный HTTP‑сервер с упакованными внутрь ресурсами — как в Spring Boot. В качестве движка веб‑сервера будет использоваться «тайный» класс специального назначения com.sun.net.httpserver, который «тайно» присутствует в JRE и JDK начиная аж с версии 1.8, а ныне вообще является официально поддерживаемым для внешнего использования.

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

Я не стал так поступать чтобы не увеличивать размер кодовой базы демонстрационного проекта в два раза — все же обработка HTTP на голых сокетах достаточно объемна.

Упрощенная логика использования выглядит так:

import com.sun.net.httpserver.*;
import java.net.*;
import java.io.*;

public class Test {
    public static void main(String[] args) throws Exception {
        // создаем объект http сервера
        HttpServer server = HttpServer.create(
                            new InetSocketAddress(8000), 0);
        // добавляем контекст
        server.createContext("/test", new MyHandler());
        // запускаем
        server.start();
    }
   /**
     Пример обработчика. 
     Все настолько просто что поймут даже зумеры и дети.
    */
    static class MyHandler implements HttpHandler {
        /**
           Вызов обработчика при совпадении контекста, 
           к которому он привязан.
        */
        @Override
        public void handle(HttpExchange t) throws IOException {
            // тестовая строка
            final String response = "Это тест";
            // устанавливаем код 200 = ОК и размер отправляемых данных
            t.sendResponseHeaders(200, response.length());
            // пишем в поток вывода данные, которые отправятся пользователю.
            try (OutputStream os = t.getResponseBody();) {
              os.write(response.getBytes("UTF-8")); os.flush();
            }            
        }
    }
}

Можете легко собрать руками:

javac -cp . Test.java

и запустить:

java -cp . Test

Но конечно у нас в проекте все будет сложнее, поскольку есть и статичные ресурсы и специальная обработка шаблонов и еще всякие непотребства. Еще у нас будет почти настоящий REST API и некое подобие SPA:

аж целый отдельный класс на Javascript ECMA6, на котором сделан весь интерактив.

И еще один, отвечающий за авторизацию. Плюс немного CSS — для стильности и целая одна иконка. Куда же без иконки-то?

Когда вы в последний раз собирали Java-проект голыми руками? Никогда?
Когда вы в последний раз собирали Java-проект голыми руками? Никогда?

Сборка

Разумеется для нормальной разработки стоит использовать какую-то внешнюю систему сборки, но поскольку мы идем путем бусидо лишений и страданий — будем использовать исключительно средства JDK и ничего больше:

javac, jar и.. все.

Я использовал достаточно свежие фичи в проекте, поэтому необходимо собирать с помощью современных версий JDK — 17 и выше.

Вот так выглядит «тру» компиляция без всего:

javac -cp ./src/main/java -d target/classes src/main/java/com/Ox08/noframeworks/FeelsLikeAServer.java

Для упрощения жизни, был написан простой shell-скрипт, повторяющий шаги сборки из обычного Apache Maven:

#!/bin/sh

# очищаем каталог сборки
rm -rf target/
# компилируем
javac -cp ./src/main/java -d target/classes src/main/java/com/Ox08/noframeworks/FeelsLikeAServer.java
# копируем ресурсы
cp -R ./src/main/resources/* target/classes/
# формируем манифест для создания исполнимого JAR-файла
echo 'Manifest-Version: 1.0' > target/manifest.mf
echo 'Main-Class: com.Ox08.noframeworks.FeelsLikeAServer' >> target/manifest.mf

# упаковываем результат сборки в JAR-файл
jar cfm  target/likeAServer.jar target/manifest.mf -C target/classes .

В результате сборки появится файл likeAServer.jar в каталоге target.

Запустить собранное приложение можно следующим образом:

java -jar target/likeAServer.jar

Вот так выглядит запущенное приложение в работе:

Теперь рассказываю как оно все работает.

Общая логика

Все реализовано в виде одного класса с некоторой вложенностью, точкой запуска является стандартная функция:

public static void main(String[] args) {}

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

Вот так выглядит общая структура класса и функция запуска (без учета вложенных классов):

/**
  Да, это все - один класс. 
*/
public class FeelsLikeAServer {
// JUL логгер, один и общий.
private final static Logger LOG = Logger.getLogger("NOFRAMEWORKS");
// признак включения отладки
private static boolean debugMessages;
/**
  Вот она - та самая дырка: точка входа в приложение. Отсюда оно запускается.
*/
public static void main(String[] args) throws IOException {
// Получить номер порта из входящих параметров, если не указан - будет 8500
// Если кто вдруг не знает, параметры указываются как -DappPort=9000
final int port = Integer.parseInt(System.getProperty("appPort", "8500"));
// проверка на включение отладочных сообщений.
debugMessages = Boolean.parseBoolean(
                    System.getProperty("appDebug", "false"));
// если включена отладка - делаем доп. настройку JUL логгера 
// для показа FINE уровня
if (debugMessages) {    
   LOG.setUseParentHandlers(false);    
   final Handler systemOut = new ConsoleHandler();
   systemOut.setLevel(Level.FINE);    
   LOG.addHandler(systemOut);
   LOG.setLevel(Level.FINE);}
} 
// создание DI контейнера
final TinyDI notDI = new TinyDI();
// инициализация - указываем все классы являющиеся зависимостями
notDI.setup(List.of(Users.class,Sessions.class,LocaleStorage.class,
        BookRecordStorage.class,RestAPI.class,Expression.class,
                Json.class,PageHandler.class,ResourceHandler.class));
// получение уже созданного контейнером инстанса сервиса Users
// он отвечает за работу с пользователями
final Users users = notDI.getInstance(Users.class); 
// загрузка списка пользователей
users.load();
// получение инстанса сервиса с записями в гостевой
final BookRecordStorage storage = notDI.getInstance(BookRecordStorage.class);
// загрузка их с диска
storage.load();
// загрузка локализованных строк
final LocaleStorage localeStorage = notDI.getInstance(LocaleStorage.class);
localeStorage.load();
// инициализация встроенного HTTP-сервера
final HttpServer server = HttpServer.create(new InetSocketAddress(port), 50);
// подключение обработчика страниц
server.createContext("/").setHandler(notDI.getInstance(PageHandler.class));
// .. обработчика статичных ресурсов
final ResourceHandler rs = notDI.getInstance(ResourceHandler.class);
server.createContext("/static").setHandler(rs);
server.createContext("/favicon.ico").setHandler(rs);
// .. обработчика REST API
server.createContext("/api").setHandler(notDI.getInstance(RestAPI.class));
LOG.info("FeelsLikeAServer started: http://%s:%d . Press CTRL-C to stop"
           .formatted(server.getAddress().getHostString(), port));
// запуск сервера
server.start();
}
..

А пока кратко разберем что тут происходит и зачем:

// создание DI контейнера
final TinyDI notDI = new TinyDI();
// инициализация - указываем все классы являющиеся зависимостями
notDI.setup(List.of(Users.class,Sessions.class,LocaleStorage.class,
        BookRecordStorage.class,RestAPI.class,Expression.class,
                Json.class,PageHandler.class,ResourceHandler.class));

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

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

// получение уже созданного контейнером инстанса сервиса Users
// он отвечает за работу с пользователями
final Users users = notDI.getInstance(Users.class); 
// загрузка списка пользователей
users.load();
// получение инстанса сервиса с записями в гостевой
final BookRecordStorage storage = notDI.getInstance(BookRecordStorage.class);
// загрузка их с диска
storage.load();
// загрузка локализованных текстов
final LocaleStorage localeStorage = notDI.getInstance(LocaleStorage.class);
localeStorage.load();

Метод load() в данном случае — сильно упрощенный аналог @PostConstruct аннотации, который вызывается вручную согласно логике работы приложения.

Дальше происходит инстанциация и настройка движка HTTP-сервера:

final HttpServer server = HttpServer.create(new InetSocketAddress(port), 50);
server.createContext("/").setHandler(notDI.getInstance(PageHandler.class));
final ResourceHandler rs = notDI.getInstance(ResourceHandler.class);
server.createContext("/static").setHandler(rs);

server.createContext("/favicon.ico").setHandler(rs);
server.createContext("/api").setHandler(notDI.getInstance(RestAPI.class));
LOG.info("FeelsLikeAServer started: http://%s:%d . Press CTRL-C to stop"
     .formatted(server.getAddress().getHostString(), port));
server.start();

Выставляются обработчики контента а в последней строке происходит непосредственно запуск HTTP-сервера. Вызов метода start() является блокирующим, поэтому на этом месте произойдет блокировка ввода. 

Завершить приложение можно будет только по нажатию Ctrl-C. Или kill -9

По-умолчанию сервер запускается на порту 8500, откройте в браузере адрес:

http://localhost:8500/

и сможете узреть нашу гостевую:

Управление зависимостями

Да, когда-то давно так начинался знаменитый Spring Framework — как контейнер для автоматического управления зависимостями:

Внедрение зависимости (англ. Dependency injection, DI) — процесс предоставления внешней зависимости программному компоненту. Является специфичной формой «инверсии управления» (англ. Inversion of control, IoC), когда она применяется к управлению зависимостями. В полном соответствии с принципом единственной ответственности объект отдаёт заботу о построении требуемых ему зависимостей внешнему, специально предназначенному для этого общему механизму[1].

Расскажу в кратце как это работает с точки зрения «пользователя» — обычного разработчика, который использует DI и IoC в своем проекте. Допустим есть классы:

class Moo {
	public Moo(Zoo z, Foo f) {}
}
class Foo {
}
class Zoo {
	public Zoo(Foo f) {}
}

Для того чтобы инициализировать класс Moo, содержащий зависимости от двух других классов без DI-контейнера, придется последовательно инициализировать все зависимые классы, подставляя параметры в конструкторы:

Foo f = new Foo();
Zoo z = new Zoo(f);
Moo m = new Moo(z,f);

Теперь представьте объем подобного кода для типового проекта, где каждая вставка @Autowired или @Inject является признаком зависимости от другого бина.

Вот для примера небольшой кусочек из примера для JHipster:

public UserService(
        UserRepository userRepository,
        PasswordEncoder passwordEncoder,
        AuthorityRepository authorityRepository,
        CacheManager cacheManager
    ) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.authorityRepository = authorityRepository;
        this.cacheManager = cacheManager;
}
...

Чтобы не утонуть во всех этих массах однотипного говнокода и были придуманы DI-контейнеры, которые сами выстраивают цепочки зависимостей и согласно ним инициализируют классы.

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

Инициализация контейнера, построение дерева зависимостей и инстанциация зависимых классов — все происходит в один шаг вызовом метода:

public synchronized void setup(List<Class<?>> inputCl) {}

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

public <T> T getInstance(Class<T> clazz) {}

Да, это прямой аналог метода getBean() из ApplicationContext в Spring:

@Autowired
private ApplicationContext context;
..
SomeClass sc = (SomeClass)context.getBean(SomeClass.class);

Вот так выглядит метод инициализации целиком:

public synchronized void setup(List<Class<?>> inputCl) {
      if (this.totalDeps > 0) 
          throw new IllegalStateException("Already initialized!");
      
      if (inputCl == null || inputCl.isEmpty()) 
          throw new IllegalStateException("There should be dependencies!");
      
      // we use 0 as marker for 'no dependencies'
      this.totalDeps = inputCl.size() + 1;
      // build adjuction array
      for (int i = 0; i < totalDeps; i++) 
          adj.add(new ArrayList<>());
      // build classes indexes, set initial class number
      this.cl = new Class[totalDeps]; this.cdc = 1;
      // build dependencies tree, based on class constructor
      for (Class<?> c : inputCl) {
          final List<Class<?>> dependsOn = new ArrayList<>();
          for (Class<?> p : c.getDeclaredConstructors()
          [0].getParameterTypes())
                if (Dependency.class.isAssignableFrom(p)) 
                    dependsOn.add(p);
                // add class number
                addClassNum(c, dependsOn);
            }
            // make topological sort
            final int[] ans = topoSort(adj); 
            final List<Integer> deps = new ArrayList<>();
            // put marks for 'zero-dependency', 
            // when class does not depend on others
            for (int node : ans) 
                  if (node > 0) 
                     deps.add(node);
            // reverse to get least depend on top
            Collections.reverse(deps);
            // and instantiate one by one
            for (int i : deps) instantiate(cl[i]);
}

Тут происходит определение зависимых классов путем поиска аргументов у конструктора по-умолчанию:

for (Class<?> p : c.getDeclaredConstructors()[0].getParameterTypes())
            if (Dependency.class.isAssignableFrom(p)) 
                     dependsOn.add(p);
             ..

Dependency это специальный интерфейс, который используется как маркер зависимости, все зависимые классы должны обязательно его иметь:

static class Sessions implements Dependency {
   ..
}

Что нужно для отделения «мух от котлет» — для понимания какие из зависимых классов являются управляемыми, а какие — нет.

Для построения дерева зависимостей используется Topological sort:

final int[] ans = topoSort(adj); 
final List<Integer> deps = new ArrayList<>();
// put marks for 'zero-dependency', when class does not depend on others
for (int node : ans) if (node > 0) deps.add(node);
// reverse to get least depend on top
Collections.reverse(deps);        

Вот так выглядит реализация такой сортировки:

static int[] topoSort(ArrayList<ArrayList<Integer>> adj) {
    final int[] indegree = new int[adj.size()];
    for (ArrayList<Integer> integers : adj) 
       for (int it : integers) indegree[it]++;
          final Queue<Integer> q = new LinkedList<>();
          for (int i = 0; i < adj.size(); i++) 
               if (indegree[i] == 0) 
                   q.add(i);
            final int[] topo = new int[adj.size()]; 
            int i = 0;
            while (!q.isEmpty()) {
                topo[i++] = q.remove(); 
                for (int it : adj.get(topo[i - 1])) 
                    if (--indegree[it] == 0) 
                         q.add(it);
            }
            return topo;
}

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

Для примера с тремя зависимыми классами Foo,Zoo и Moo выше это будет выглядеть как-то так:

  • Foo — 0

  • Zoo — 1

  • Moo — 2

В результате всех операций мы получаем список классов, отсортированных по количеству зависимостей и готовых к инициализации:

// and instantiate one by one
for (int i : deps) 
      instantiate(cl[i]);

Инстанциация класса происходит с помощью Reflection API и выглядит следующим образом :

private void instantiate(Class<?> clazz) {
      if (clazz == null) 
      throw new IllegalStateException("Cannot create instance for null!");
            LOG.log(Level.FINE, "Creating instance of %s"
                             .formatted(clazz.getName()));
     // we just take first public constructor for simplicity
     final java.lang.reflect.Constructor<?> c = clazz
                            .getDeclaredConstructors()[0];
     final List<Object> params = new ArrayList<>();
     // lookups constructor params in 'instances storage'
     for (Class<?> p : c.getParameterTypes())
         if (Dependency.class.isAssignableFrom(p) 
                && services.containsKey(p)) 
                   params.add(services.get(p));
  // try to instantiate
  try { 
    services.put(clazz, c.newInstance(params.toArray())); 
  } catch (InstantiationException 
    | java.lang.reflect.InvocationTargetException 
    | IllegalAccessException e) {
        throw new RuntimeException("Cannot instantiate class: %s"
                .formatted(clazz.getName()), e);
   }
}

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

Если инициализация прошла успешно, бин добавляется в контейнер и сам становится доступен для подключения в виде зависимости.

Весь код целиком этого мини-контейнера можно посмотреть по ссылке.

Авторизация. Без фреймворков и библиотек.
Авторизация. Без фреймворков и библиотек.

Пользователи,сессии и авторизация

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

Начнем с самого простого — с сессий, вот так выглядит класс для управления сессиями пользователей:

static class Sessions implements Dependency {
        public static final int MAX_SESSIONS = 5,//max allowed sessions
                SESSION_EXPIRE_HOURS = 8; // session expiration, in hours
        private final Map<String, Session> sessions = new HashMap<>(); 
        private final Map<String, String> registeredUsers = new HashMap<>();
       ..
        public Session getSession(String sessionId) { 
             return !isSessionExist(sessionId) ? null : 
                  sessions.get(sessionId);}
       ..
        public boolean isSessionExist(String sessionId) {
            //  if there is no session registered with such id 
            //  respond false
            if (!sessions.containsKey(sessionId)) 
                        return false;
            // extract session entity
            final Session s = sessions.get(sessionId);
            // checks for expiration time
            // Logic is: [session created]...
            //  [now,session not expired]....
            //  [+8 hours]....
            //  [now,session expired]
            if (s.created.plusHours(SESSION_EXPIRE_HOURS)
               .isBefore(java.time.LocalDateTime.now())) {
                LOG.log(Level.INFO, 
                "removing expired session: %s for user: %s"
                .formatted(s.sessionId, s.user.username));
                sessions.remove(sessionId); return false;
            }
            return true;
        }
      ..
        public synchronized String registerSessionFor(Users.User user) {
            // disallow creation if max sessions limit is reached
            if (registeredUsers.size() > MAX_SESSIONS) 
                      return null;
            // disallow creation if there is existing session
            if (registeredUsers.containsKey(user.username)) 
                      return null;
            // create new session id
            final String newSessionId = UUID.randomUUID().toString();
            sessions.put(newSessionId, new Session(newSessionId, 
            java.time.LocalDateTime.now(), user));
            registeredUsers.put(user.username, newSessionId); 
            return newSessionId;
        }
       ..
        public synchronized boolean unregisterSession(String sessionId) {
            if (!sessions.containsKey(sessionId)) 
                       return false;
            registeredUsers.remove(sessions.remove(sessionId).user.username);
            return true;
        }
       ..
        public record Session(String sessionId, 
            java.time.LocalDateTime created, Users.User user) {}
}   

Как видно из самого начала класса:

public static final int MAX_SESSIONS = 5,//max allowed sessions
               SESSION_EXPIRE_HOURS = 8; // session expiration, in hours     

тут реализованы ограничения на количество сессий и их время жизни:

через 8 часов сессия авторизировавшегося пользователя превратится в тыкву перестает быть валидной и удаляется.

Для хранения сессий используются два key-value связки:

private final Map<String, Session> sessions = new HashMap<>(); 
private final Map<String, String> registeredUsers = new HashMap<>();      

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

Чтобы не реализовывать отдельную логику для проверки устаревания и удаления устаревших сессий, все это происходит непосредственно в методе проверки существования сессии:

public boolean isSessionExist(String sessionId) {
            // if there is no session registered with such id  
            // respond false
            if (!sessions.containsKey(sessionId)) 
                return false;
            // extract session entity
            final Session s = sessions.get(sessionId);
            // Checks for expiration time
            // Logic is: 
            //  [session created]...
            //  [now,session not expired]....
            //  [+8 hours]....[now,session expired]
            if (s.created.plusHours(SESSION_EXPIRE_HOURS)
                  .isBefore(java.time.LocalDateTime.now())) {
                LOG.log(Level.INFO, 
                "removing expired session: %s for user: %s"
                .formatted(s.sessionId, s.user.username));
                sessions.remove(sessionId); 
                return false;
            }
            return true;
}

Вот так выглядит регистрация новой сессии для пользователя:

public synchronized String registerSessionFor(Users.User user) {
            // disallow creation if max sessions limit is reached
            if (registeredUsers.size() > MAX_SESSIONS) 
                     return null;
            // disallow creation if there is existing session
            if (registeredUsers.containsKey(user.username)) 
                     return null;
            // create new session id
            final String newSessionId = UUID.randomUUID().toString();
            sessions.put(newSessionId, new Session(newSessionId, 
                java.time.LocalDateTime.now(), user));
            registeredUsers.put(user.username, newSessionId); 
            return newSessionId;
}

Заодно в этом методе происходит проверка на количество допустимых сессий и если этот лимит превышен — регистрации не произойдет. И проверка на повторную регистрацию — чтобы не было затирания предыдущей сессии.

Для упрощения реализации, возврат null из этой функции означает ошибку, если же регистрация прошла успешно — вернется ID сессии.

Пользователи

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

static class Users implements Dependency {
        private final Map<String, User> users = new TreeMap<>();
      ..
        public void load() {
            addUser(new User("admin", "admin", "Administrator", true));
            addUser(new User("alex", "alex", "Alex", false));
        }
       ..
        public boolean isUserExists(String username) {
            return username != null && !username.isBlank() 
              && users.containsKey(username);
        }
       ..
        public User getUserByUsername(String username) { 
             return users.getOrDefault(username, null); 
        }
       ..
        public void addUser(User user) { users.put(user.username(), user); }
        ..
        public record User(String username, 
                           String password, 
                           String name, boolean isAdmin) {}
}  

Этот класс — упрощенный аналог UserDetailsService из Spring Security, совмещенный с репозиторием для хранения записей о пользователях. Как видите все пользователи зашиты в код:

public void load() {
            addUser(new User("admin", "admin", "Administrator", true));
            addUser(new User("alex", "alex", "Alex", false));
}

Это было сделано для упрощения реализации, но ничего не мешает вставить в этом месте чтение из JSON/XML/СУБД лишь чуть усложнив логику. Также ради упрощения я реализовал разделение ролей админа и обычного пользователя одним булевым признаком isAdmin:

public record User(String username, 
                   String password, String name, boolean isAdmin) {}

Авторизация

Авторизация работает путем формирования на стороне браузера JSON с полями логина и пароля, c последующей отправкой этого JSON на сервер POST‑запросом с помощью асинхронного API — все как в больших проектах на SPA.

Далее сервер обрабатывает POST-запрос, парсит JSON, вытаскивает введенные пользователем логин с паролем и проверяет.

Если учетные данные совпали — сервер создает сессию, выставляет авторизационный Cookie отдельным заголовком и возвращает url для перехода после авторизации. Если нет — сервер возвращает ошибку, которая отображается в браузере (см. скриншот выше)

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

Этот JSON файл был сформирован, пишется и читается без каких-либо библиотек и фреймворков.
Этот JSON файл был сформирован, пишется и читается без каких-либо библиотек и фреймворков.

Самопальный "JSON"

Очень надеюсь на адекватность читающих — что вы не воспримете описанное как руководство к действию и никогда не опуститесь до подобной самопальной реализации парсера JSON в боевом проекте.

Не надо так делать. Никогда.

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

Опишу все ограничения, чтобы вы «не раскатывали губу» заранее:

  • Нет поддержки вложенности

  • Ручная сериализация, без рефлексии — по заранее определенным полям

  • Нет типов - все поля обрабатываются как строка

  • Нет обработки массивов при парсинге

Фактически вся обработка сводится к разбору вот таких примитивов:

{
   "id":"98e64df2-d2b5-4997-bedb-75ada485ea62",
   "title":"Some title 9",
   "author":"alex 9",
   "created":"1675173817790",
   "message":"test message 9"
}

и превращению полученных данных в Map с полями «ключ-значение».

Код полной реализации, как парсера так и сериализации в строку:

static class Json implements Dependency {
        final static Pattern PATTERN_JSON = Pattern
           .compile("\"([^\"]+)\":\"*([^,^}\"]+)", Pattern.CASE_INSENSITIVE);
        /**
         * That's how we do it: parse JSON as grandpa!
         * No nested objects allowed.
         *
         * @param json json string
         * @return key-value map parsed from json string
         */
        public static Map<String, String> parseJson(String json) {
            // yep, we just parse JSON with pattern and 
            // extract keys and values
          final java.util.regex.Matcher matcher = PATTERN_JSON.matcher(json);
            // output map
            final Map<String, String> params = new HashMap<>();
            // loop over all matches
            while (matcher.find()) {
                String key = null, value = null;
                // skip first match group (0 index) , 
                // because it responds whole text
                for (int i = 1; i <= matcher.groupCount(); i++) {
                    // First match will be key, second - value
                    // So we need to read them one by one
                    final String g = matcher.group(i); 
                    if (key != null) 
                       value = g; 
                    else 
                       key = g;
                    LOG.log(Level.FINE, "key=%s value=%s g=%s"
                        .formatted(key, value, g));
                    if (key != null && value != null) { 
                         params.put(key, value); 
                         key = null; 
                         value = null; 
                    }
                }
            }
            return params;
        }
        public static void toJson(StringBuilder out, 
                            Collection<BookRecord> records) {
            // yep, we build json manually
            out.append("["); 
            boolean first = true;
            // build list of objects
            for (BookRecord r : records) { 
               if (first) 
                  first = false; 
                else 
                  out.append(","); 
               Json.toJson(out, r); 
            }
            out.append("]");
        }
        /**
         * Build JSON string from BookRecord object
         */
        public static void toJson(StringBuilder out, BookRecord r) {
            out.append("{\n"); 
            toJson(out, "id", r.id, true);
            toJson(out, "title", r.title, true); 
            toJson(out, "author", r.author, true);
            toJson(out, "created", r.created.getTime(), true);
            toJson(out, "message", r.message, false); 
            out.append("}");
        }
        /**
         * Build JSON string with key-value pair
         */
        public static void toJson(StringBuilder sb, 
                        String key, Object value, boolean next) {
            sb.append("\"")
            .append(key)
            .append("\":\"")
            .append(value)
            .append("\"");
            if (next) 
              sb.append(","); 
            sb.append("\n");
        }
    }

Теперь разберем особенности реализации.

Парсинг JSON

Начнем с функции разбора JSON:

public static Map<String, String> parseJson(String json) { .. }

Для простоты реализации, весь JSON разбирается одним регулярным выражением:

   final static Pattern PATTERN_JSON = Pattern
       .compile("\"([^\"]+)\":\"*([^,^}\"]+)", Pattern.CASE_INSENSITIVE);    

Вызывается парсер регулярных выражений:

  final java.util.regex.Matcher matcher = PATTERN_JSON.matcher(json);       

и запускается цикл по найденным блокам:

  while (matcher.find()) { .. }

Внутри находится еще один цикл, в котором происходит перебор найденных пар ключ-значение:

String key = null, value = null;
// skip first match group (0 index) , 
// because it responds whole text
for (int i = 1; i <= matcher.groupCount(); i++) {
         // First match will be key, second - value
         // So we need to read them one by one
         final String g = matcher.group(i); 
         if (key != null) 
             value = g; 
         else 
             key = g;
        LOG.log(Level.FINE, "key=%s value=%s g=%s"
                        .formatted(key, value, g));
        if (key != null && value != null) { 
                params.put(key, value); 
                key = null; 
                value = null; 
        }
}

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

Сериализация JSON

Теперь разберем процесс сериализации в строку, он состоит из нескольких уровней, на самом низком это выглядит вот так:

public static void toJson(StringBuilder sb, 
                        String key, Object value, boolean next) {
         sb.append("\"")
            .append(key)
            .append("\":\"")
            .append(value)
            .append("\"");
         if (next) 
              sb.append(","); 
         sb.append("\n");
}

В результате работы этой функции будет сформирована одна пара ключ-значение в формате JSON:

"message":"Дооо&#0032;дооо&#0032;дооооо&#0032;дооооо"

Следующий уровень это последовательные вызовы данного метода для всех полей объекта:

public static void toJson(StringBuilder out, BookRecord r) {
            out.append("{\n"); 
            toJson(out, "id", r.id, true);
            toJson(out, "title", r.title, true); 
            toJson(out, "author", r.author, true);
            toJson(out, "created", r.created.getTime(), true);
            toJson(out, "message", r.message, false); 
            out.append("}");
}

В результате вызова получится вот такой JSON:

{
  "id":"0f2fbde8-c51d-4a39-bef2-3f5d33e64fe4",
  "title":"Some title 3",
  "author":"alex 3", 
  "created":"1675173817789",
  "message":"test message 3"
}

Что соответствует полям объекта BookRecord. Наконец на самом верхнем уровне находится обработка массивов объектов:

public static void toJson(StringBuilder out, 
                            Collection<BookRecord> records) {
            // yep, we build json manually
            out.append("["); 
            boolean first = true;
            // build list of objects
            for (BookRecord r : records) { 
               if (first) 
                  first = false; 
                else 
                  out.append(","); 
               Json.toJson(out, r); 
            }
            out.append("]");
}

В результате вызова получается строка в формате JSON, соответствующая массиву объектов. Вот так выглядит результат для массива объектов типа BookRecord:

[{
"id":"81081891-0282-40e2-abc8-c84a40823677",
"title":"тест",
"author":"тест",
"created":"1676379108664",
"message":"тест"
},{
"id":"77e4f673-da34-465b-867c-febe4035bee4",
"title":"Some title 5",
"author":"alex 5",
"created":"1675173817789",
"message":"test message 5"
},{
"id":"d4f7be9c-a290-407d-a642-e3030a2b9300",
"title":"лдлдл",
"author":"еее",
"created":"1676381010026",
"message":"лдлдл"
},{
"id":"60697959-ed1f-4cb0-94aa-a63109b4c710",
"title":"Еще&#0032;один&#0032;унылый&#0032;тест",
"author":"Тестов",
"created":"1717661222006",
"message":"Дооо&#0032;дооо&#0032;дооооо&#0032;дооооо"
}]

Но едем дальше, на очереди следущая интересная тема.

Шаблонизатор

«Чад кутежа во славу самопала» был бы неполным без своей реализации шаблонизатора — упрощенного аналога Thymeleaf, разумеется с крайне ограниченным функционалом.

В качестве шаблонов используются HTML-файлы со специальными управляющими блоками внутри — по прямой аналогии с Thymeleaf.

Расскажу сначала как это выглядит со стороны самих шаблонов.

Главный шаблон и страницы

Классика шаблонизации страниц — разделение на один или несколько общих шаблонов, с последующей подстановкой внутрь именованных частей, заданных на каждой отдельной странице. Именно такой подход мы и будем использовать.

Вот так выглядит общий шаблон:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    ..   
</head>
<body class="c">
<div class="row" >
    <b class="col">
        <!-- inject section 'header' below -->
        ${inject(header)}
    </b>    
</div>
   ...
</body>
</html>

Cо стороны страницы использование родительского шаблона активируется специальным тегом:

<!-- instruct to use main template -->
${template(template/main.html)}

А так задаются данные для подстановки в именованную секцию:

<!-- the 'header' section -->
${section(header)
    <h4>${msg(gb.text.login.title)}</h4>
}

В результате при формировании страницы login.html будет взят шаблон template/main.html, в котором вместо ${inject(header)} будет подстановка текстового блока из login.html:

<h4>${msg(gb.text.login.title)}</h4>

Но перед этим еще произойдет препроцессинг — блок ${msg (gb.text.login.title)} будет заменен на строку из локализованного бандла:

gb.text.login.title=Please authenticate

Итог работы выглядит следующим образом:

<h4>Please authenticate</h4>

Ну разве не чудо?

Локализованные сообщения

Наш самостийный и краснознаменный шаблонизатор поддерживает подстановку локализованных текстовых сообщений из бандлов:

<div class="6 col">
     <label for="titleInput">${msg(gb.text.newmessage.title)}</label>
     <input type="text" class="card w-100" 
     id="titleInput" 
     placeholder="${msg(gb.text.newmessage.title.placeholder)}"/>
</div>

Тег ${msg(gb.text.newmessage.title)} является указанием на использование подстановки локализованного текстового значения из бандла.

Глобальные переменные

Разумеется шаблонизатор ограниченно поддерживает глобальные переменные:

<span style="padding-right:0.5em;">${msg(user.name)}</span>

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

Условия

Наконец наверное самое веселое — поддержка выражений, разумеется также сильно ограниченная:

${if(url eq /login.html)
            <a class="btn"
                href="/">${msg(gb.text.login.btn.back)}</a>
}

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

true && ( false || ( false && true ) )

Но вместо true/false будет подстановка вычисленных значений, типа такого:

${if(!gb.isAuthenticated)
       <a class="btn" href="/login.html">${msg(gb.text.login)}</a>
}

Реализация шаблонизатора

Начну с самого начала, тут происходит установка обработчика, отвечающего за выдачу страниц:

final HttpServer server = HttpServer.create(new InetSocketAddress(port), 50);
// setup page handler and bind it to /
server.createContext("/").setHandler(notDI.getInstance(PageHandler.class));

Поскольку мы имеем дело с встроенным и максимально упрощенным HTTP-сервером (это вам не Jetty), всю логику  — аналог сервлетов необходимо помещать в специальные обработчики, реализующие интерфейс HttpHandler:

class MyHandler implements HttpHandler {
       public void handle(HttpExchange t) throws IOException {
           InputStream is = t.getRequestBody();
           read(is); // .. read the request body
           String response = "This is the response";
           t.sendResponseHeaders(200, response.length());
           OutputStream os = t.getResponseBody();
           os.write(response.getBytes());
           os.close();
       }
}

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

Во-первых сам обработчик имеет зависимости, поэтому его жизненный цикл управляется IoC-контейнером:

PageHandler(Sessions sessions, Expression expr) {}

Бины Sessions (отвечает за сессии пользователей) и Expression (за вычисляемые выражения) инициируются до нашего обработчика и затем подставляются в конструктор.

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

templates.put("template/main.html",
                        new String(
                        getResource("/static/html/template/main.html")));

Данные шаблона добавляются в key-value хранилище, в качестве ключа используется путь, который указывается в теге $template:

<!-- instruct to use main template -->
${template(template/main.html)}

Затем загружаются сами страницы:

resources.put("/index.html",
                    new StaticResource(
                      getResource("/static/html/index.html"), "text/html"));
                      
resources.put("/login.html",
                     new StaticResource(
                      getResource("/static/html/login.html"), "text/html"));

и помещаются в другое хранилище, где ключем является URL страницы, по которому она доступна пользователям, например: /login.html

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

 @Override
 public void handle(HttpExchange exchange) throws IOException { .. }

Первым делом выполняется проверка и очистка URL, взятая из HTTP-запроса:

String url = getUrl(exchange.getRequestURI());

Метод getUrl() находится в классе AbstractHandler, и отвечает за проверку на пустоту и начальную очистку строки запроса:

protected String getUrl(URI u) { 
   return (u != null ? u.getPath() : "").toLowerCase().trim(); 
}

Перевод в нижний регистр нужно для последующего сравнения с доступными страницами, регистрация которых выполняется в нижнем регистре.

Дальше происходит получение «сырых» данных шаблона по URL:

final StaticResource resource = resources.get(url);

Поскольку одинаковый механизм используется как для статичных файлов так и для шаблонов - необходима проверка на тип MIME, чтобы отсеять файлы не являющиеся шаблонами или страницами:

if (!"text/html".equals(resource.mime)) { 
         respondData(exchange, resource.data); 
         return; 
}

Следующим шагом создается «рантайм» для шаблонизатора — HashMap, в который помещаются все ресурсы, доступные из шаблона:

// build rendering runtime
final TypedHashMap<String, Object> runtime = new TypedHashMap<>();

Добавляются ссылки на все доступные шаблоны:

// put all available templates to let expression parser found them
runtime.put(Expression.ALL_TEMPLATES_KEY,templates);

Добавляется выбранный язык или язык по-умолчанию, а также текущий URL страницы:

// put current language and current page url
runtime.put("lang", lang == null || lang.isBlank() ? "en" : lang); 
runtime.put("url",url);

Добавляется признак авторизации пользователя:

// check if user session exist
final boolean sessionExist = sessions.isSessionExist(sessionId);
LOG.info("got session: %s exist? %s".formatted(sessionId, sessionExist));
runtime.put("gb.isAuthenticated", sessionExist);

Напомню как выглядит его использование из шаблона:

${if(gb.isAuthenticated)
            <a href="#" id="deleteBtn"
               class="btn primary"
               confirm="${msg(gb.text.btn.delete.confirm)}">
                ${msg(gb.text.btn.delete)}
            </a>
}

Далее в окружение шаблонизатора добавляется информация о текущем пользователе:

// put current user's name to been displayed in top of page
if (sessionExist) 
    runtime.put("user.name", sessions.getSession(sessionId).user.name);

Наконец мы подходим к самой генерации страницы, поскольку она сложная и могут быть ошибки в шаблонах — вся логика обернута в блок try-catch:

try { 
    final String source = new String(resource.data);
      expr.parseTemplate(source, runtime, 
                        (line)-> expr.buildTemplate(line.expr,line.runtime));
    final String merged = runtime.containsKey(Expression.PAGE_TEMPLATE_KEY) ?
     expr.mergeTemplate(runtime.getTyped(Expression.PAGE_TEMPLATE_KEY,null),
                        runtime) : source;
               
    respondData(exchange, expr.parseTemplate(merged, runtime,
          (line)-> expr.parseExpr(line.expr,line.runtime))
                .getBytes(StandardCharsets.UTF_8));
     } catch (Exception e) { 
        LOG.log(Level.WARNING, 
           "Cannot parse template: %s".formatted(e.getMessage()), e);
                respondBadRequest(exchange);
     }

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

expr.parseTemplate(source, runtime, 
          (line)-> expr.buildTemplate(line.expr,line.runtime));

Причем третий аргумент это на самом деле замыкание, внутри которого вызывается метод подстановки в строке:

 (line)-> expr.buildTemplate(line.expr,line.runtime)

Следующим шагом запускаем обработку всех выражений:

respondData(exchange, expr.parseTemplate(merged, runtime,
     (line)-> expr.parseExpr(line.expr,line.runtime))
         .getBytes(StandardCharsets.UTF_8));

Метод parseTemplate() в котором происходит связывание частей в единый HTML оказался слишком объемным для цитирования, поэтому целиком его можно посмотреть по ссылке.

В нем происходит последовательное и посимвольное чтение шаблона, где внутри цикла происходит поиск и выборка всех подстановок вида ${..}

В момент определения выражения — когда последовательно считались символы '$', '{', внутренний блок и завершающий символ '}', происходит вызов функции обработки, переданной в качестве аргумента:

out.append(onReadExpr.apply(new Line(expr.toString(), runtime)));

Внутри происходит вызов функции buildTemplate():

(line)-> expr.buildTemplate(line.expr,line.runtime)

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

На следующем шаге эти данные подставляются в готовый шаблон.

Так выглядит вызов "REST API" - метода для получения записей гостевой.
Так выглядит вызов "REST API" - метода для получения записей гостевой.

REST API

Скажу сразу — на самом деле это лишь очень простое подобие RESTful.

Все отличие данного обработчика от отвечающего за шаблонизатор лишь в том что для входящих и исходящих данных используется JSON.

Нет подстановки именованных параметров из url (вроде «/api/records/get/<id>»), нет обработки HEAD, PUT и DELETE запросов — ничего не мешает все это добавить разумеется, но увеличит объем кода.

Поэтому я ограничился самым минимумом функцонала.

Которого как ни странно вполне хватает для управляющего ПО вашего роутера, например.

Вот так выглядит сокращенный исходный код обработчика (убрана только логика обработки методов внутри case — она описана отдельно по каждому логическому блоку):

static class RestAPI 
           extends AbstractHandler implements HttpHandler, Dependency {
        private final BookRecordStorage storage;
        private final Users users;
        private final Sessions sessions;
        private final LocaleStorage localeStorage;
        
        RestAPI(BookRecordStorage storage, 
                Users users, 
                Sessions sessions, LocaleStorage localeStorage) {
            this.storage = storage; 
            this.localeStorage = localeStorage; 
            this.users = users; 
            this.sessions = sessions;
        }
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            // extract url
            final String url = getUrl(exchange.getRequestURI()), 
                          query = exchange.getRequestURI().getQuery();
            // extract url params
            final Map<String, String> params = query != null 
                        && !query.trim().isBlank() ?
                    parseParams(exchange.getRequestURI().getQuery()) :
                        Collections.emptyMap();
            // for output json
            final StringBuilder out = new StringBuilder();
            // we use simple case-switch with end urls
            switch (url) {
                // respond list of records
                case "/api/records" -> {
				..
				..
    			}
 		  }
            respondData(exchange, out.toString()
                     .getBytes(StandardCharsets.UTF_8));
}

Помимо уже описанного выше метода getUrl(), который нужен для очистки входяшего урла, тут есть еще парсинг и заполнение «key-value» хранилища параметрами HTTP-запроса:

// extract url params
final Map<String, String> params = query != null 
                        && !query.trim().isBlank() ?
           parseParams(exchange.getRequestURI().getQuery()) :
           Collections.emptyMap();

Вот как происходит разбор параметров, указанных в урле HTTP-запроса:

static Map<String, String> parseParams(String query) { 
                        return Arrays.stream(query.split("&"))
                    .map(pair -> pair.split("=", 2))
                    .collect(java.util.stream.Collectors
                          .toMap(pair -> 
                          URLDecoder.decode(pair[0], StandardCharsets.UTF_8),
                            pair -> pair.length > 1 ? 
                        URLDecoder.decode(pair[1], StandardCharsets.UTF_8) : 
                        "")
                    );
}

Теперь вы тоже знаете откуда сервлет достает для вас параметры HTTP-запроса.

Подход очень даже рабочий.

Вид гостевой с английской локалью
Вид гостевой с английской локалью

Локализация

Разве можно делать современный веб-проект только на одном языке? Ведь в современном динамичном мире любое веб-приложение для широких масс должно иметь поддержку минимум двух языков:

английского и «местного», в нашем случае — русского.

Поэтому я тоже реализовал поддержку локализации — без фреймворков и библиотек.

Рассказываю как оно работает.

Выражения в шаблоне страницы

Для начала вернемся к шаблону страницы:

<div class="row">
        <label for="messageInput">${msg(gb.text.newmessage.message)}</label>
        <textarea class="card w-100" id="messageInput" 
             rows="3" 
             placeholder="${msg(gb.text.newmessage.message.placeholder)}">
         </textarea>
</div>

Это блок (div) отвечающий за отрисовку формы ввода сообщения:

Как видите вместо слова «Сообщение» и строки «Однажды в студеную зимнюю пору.» в шаблоне указаны только специальные теги с выражениями внутри:

${msg(gb.text.newmessage.message)} 

и:

${msg(gb.text.newmessage.message.placeholder)}

Эти выражения обрабатываются парсером при работе шаблонизатора и происходит подстановка — вместо выражения вставляется текстовое значение из .properties-файла, взятое по ключу:

gb.text.newmessage.message=Сообщение
gb.text.newmessage.message.placeholder=Однажды в студеную зимнюю пору..

Файлов .properties несколько, с постфиксами, соответствующими локали:

Выбираются они в зависимости от выбранной пользователем локали.

Интерфейс

Выбор локали осуществляется кнопками интерфейса:

По нажатию на которые происходит вызов обработчика:

document.querySelector('#selectEn')
   .addEventListener('click', (e) => {
     e.preventDefault(); 
     gb.changeLang('en'); 
});

Который выполняет POST-запрос с выбранной локалью на сервер:

changeLang(lang) {
            console.log("change lang to: ", lang);
            fetch('/api/locale?' + new URLSearchParams({ lang: lang }), 
               { method: 'POST', headers: {} }).then((response) => {
                // support for redirection
                if (response.redirected) { 
                      location.href = response.url;
                }
            }).catch(error => { 
               console.log("error on lang select: ", error);
            });
}

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

API бекэнда

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

..
case "/api/locale" -> {
                    if (!params.containsKey("lang")) { 
                        LOG.log(Level.FINE, 
                        "bad request: no 'lang' parameter");
                        respondBadRequest(exchange); 
                        return;
                    }
                    String lang = params.get("lang");
                    if (lang == null || lang.isBlank()) {
                        LOG.log(Level.FINE, 
                        "bad request: 'lang' parameter is empty");
                        respondBadRequest(exchange); 
                        return;
                    }
                    lang = lang.toLowerCase().trim();
                    if (!localeStorage.getSupportedLocales()
                        .contains(lang)) {
                        LOG.log(Level.FINE, 
                        "bad request: unsupported locale: %s"
                         .formatted(lang));
                        respondBadRequest(exchange); 
                        return;
                    }
                    exchange.getResponseHeaders()
                     .add("Set-Cookie", "%s=%s; Path=/;  Secure; HttpOnly"
                     .formatted(LANG_KEY, lang));
                    respondRedirect(exchange, "/index.html");
                    LOG.log(Level.FINE, "changed lang to: %s"
                    .formatted(lang));
                    return;
}
..

Обратите внимание на установку заголовка Set-Cookie — с его помощью сохраняется выбранный пользователем язык, который при следущих запросах передается на сервер.

На стороне сервера в методе обработчика страниц PageHandler.handle() происходит получение выбранного пользователем языка из заголовка Cookie:

lang = getCookieValue(exchange, LANG_KEY);

Если он пуст или не был задан — выбирается английская локаль в качестве значения по-умолчанию:

// put current language and current page url
runtime.put("lang", lang == null || lang.isBlank() ? "en" : lang); 

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

...
if (expr.startsWith("msg(")) {
                // extract variable name from expression block
                String data = expr.substring("msg(".length()); 
                data = data.substring(0, data.indexOf(")"));
                LOG.log(Level.FINE, "key: '%s'".formatted(data));
                /*
                 * We support 2 cases:
                 * 1) direct substitution from provided key-value map
                 * 2) attempt to get value from i18n bundle
                 */
      return runtime.containsKey(data) ? 
             runtime.get(data).toString() :
             localeStorage.resolveKey(data, (String) runtime.get("lang"));
}

Как видите вызов метода resolveKey(), который отвечает за получение текстовых сообщений из бандлов происходит с указанием выбранной локали.

Парсер булевых выражений

Наконец последняя, но крайне интересная тема данного проекта — свой собственный парсер булевых выражений.

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

String s = "true && ( false || ( false && true ) )";

В одно булевое значение true или false. Это и есть очень простой аналог Expression Language, вернее одной из его ключевых частей. Идея реализации была взята отсюда, затем переработана.

Вот так выглядит лексическое выражение:

 expression = factor { "||" factor }
 factor     = term { "&&" term }
 term       = [ "!" ] element
 element    = "T" | "F" | "(" expression ")"

Вот так парсер запускается:

ConditionalParser c =new ConditionalParser(s);
boolean result =  c.evaluate();

Как видите на каждое выражение порождается свой экземпляр парсера — это нужно из-за использования рекурсии в реализации:

 private static class ConditionalParser {
            private final String s; 
            int index = 0;
            
            ConditionalParser(String src) { 
                this.s = src; 
            }
            private boolean match(String expect) {
                while (index < s.length() 
                    && Character.isWhitespace(s.charAt(index))) 
                       ++index;
                if (index >= s.length()) 
                   return false;
                   
                if (s.startsWith(expect, index)) { 
                       index += expect.length(); 
                       return true; 
                   } 
                return false;
            }
            private boolean element() {
                if (match(Boolean.TRUE.toString())) 
                     return true; 
                if (match(Boolean.FALSE.toString())) 
                     return false;
                if (match("(")) {
                    boolean result = expression();
                    if (!match(")")) 
                       throw new RuntimeException("')' expected"); 
                    return result;
                } else 
                   throw new RuntimeException("unknown token found: %s"
                     .formatted(s));
            }
            private boolean term() { 
                return match("!") != element(); 
            }
            private boolean factor() {
                boolean result = term(); 
                while (match("&&")) 
                   result &= term(); 
                return result;
            }
            private boolean expression() {
                boolean result = factor(); 
                while (match("||")) 
                    result |= factor(); 
                return result;
            }
            public boolean evaluate() { 
            final boolean result = expression();
                if (index < s.length()) 
                      throw new RuntimeException(
                            "extra string '%s'"
                            .formatted(s.substring(index))); 
                 else 
                      return result;
            }
        }
 }

Кстати таких проектов достаточно много на Github, поскольку задача реализации подобного парсера является одним из домашних заданий в ВУЗах, где серьезно учат компьютерным наукам.

Эпилог

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

Сложность и объем разработки «полностью с нуля» думаю теперь стал для многих читателей вполне очевиден — это ни разу не накидывание готовых компонентов в уютном фреймворке.

Помните об этом прежде чем садиться за разработку чего-то «с нуля» и с желанием всех переиграть.

P.S.

К сожалению редактор статьей Хабра не выдерживает таких объемов текста и подвисает даже на урезанной версии, поэтому полную версию статьи (в два раза больше) с описанием всего реализованного функционала вы можете найти в нашем блоге.

0x08 Software

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

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