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

от автора

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/841574/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *