Как стать автором
Поиск
Написать публикацию
Обновить

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

Уровень сложностиСложный
Время на прочтение33 мин
Количество просмотров27K

Рассказываю что можно сделать на одном только голом 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

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

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

Теги:
Хабы:
Всего голосов 73: ↑67 и ↓6+79
Комментарии107

Публикации

Ближайшие события