"Голая Java" и разработка без всего
Рассказываю что можно сделать на одном только голом JDK. Это старое и ныне забытое искусство разработки без библиотек и фреймворков. Будем работать как «диды завещали» — киркой и лопатой. И немного мозгом.
В нынешнееп#зданутоевремя, когда один только boilerplate (шаблон проекта) может занимать гигабайт, а количество библиотек в рядовом проекте приближается к паре сотен — данная статья может нанести вам непоправимую психическую травму и заставить уйти в запой с нехорошими мыслями о смысле жизни вообще и правильности выбора профессии в частности.
Обязательно посоветуйтесь с вашим психотерапевтом если родились после 2000х прежде чем читать дальше.
Конечно же я не призываю полностью отказываться от фреймворков и библиотек в рабочих проектах, данная статья это всего лишь демонстрация что подобная разработка возможна.
Поскольку народ поголовно с какого-то х#я считает будто без сотни common-* библиотек, Spring, JPA и вейпа с гироскутерами жизни не бывает — сразу за порогом начинается «страшный C++» и ходят люди с песьими головами.
Disclamier №3:
Эта статья писалась очень долго и тяжело — первый прототип веб-приложения на Java без каких-либо внешних библиотек и фреймворков был создан еще в пандемийном 2020 м году.
Затем пошли разнообразные оптимизации и минификация, что заняло еще год и только в 2023м я приступил к написанию самой статьи.
Это объемная и сложная работа, предназначенная в первую очередь для профессионалов разработки на Java, которые уже имеют практический опыт с большими и сложными фреймворками, так популярными в этом болоте среде.
Что будем делать
Это самая банальная (на первый взгляд) гостевая книга — древний аналог этой вашей «стены» из ВКонтакта. Еще это веб-приложение на Java и немного на Javascript.
Максимально упрощенный аналог самой популярной связки из Spring Boot + Thymeleaf.
Которые вы используете каждый день на работе.. но:
Без фреймворков и библиотек.
Готовый проект я по традиции выложил на GitHub.
Основные фичи
- Хранилище данных на диске в JSON
- Локализация
- Авторизация и разграничение доступа
- Добавление, просмотр и удаление записей гостевой
И все это сделано и работает на одном голом JDK, без каких-либо внешних библиотек:
Без сервлетов, сервлет-контейнеров, серверов приложений и так далее.
Еще нет?
Тогда смотрим технические фичи.
Технические возможности
- Парсер и генератор JSON
- Шаблонизатор страниц
- Парсер выражений (Expression Language аля рюс)
- IoC-Контейнер
Напоминаю что все это свое, реализованное с нуля в рамках проекта.
По крайней мере лично я бы ох#ел, если бы мне показали подобный проект в начале карьеры.
Наверное сейчас прикинув размеры какого-нибудь Wildfly, Spring, Thymeleaf и еще каких монстров вы подумали что слегка за#бетесь устанете это все читать?
Ну что, еще не задаетесь вопросами «нах#я я учил Spring и Hibernate» и «не ошибся ли вообще с профессией»?
А пора, ведь мы начинаем погружение.
Дичь в студию
Технически наш проект будет представлять собой встроенный HTTP-сервер с упакованными внутрь ресурсами — как в Spring Boot.
В качестве движка веб-сервера будет «тайный» класс специального назначения:
com.sun.net.httpserver
Который «тайно» присутствует в JRE начиная аж с версии 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 — для стильности и целая одна иконка.
Куда же без иконки-то?
Как на клиентской строне так и на серверной, даже сборка будет без внешних инструментов.
Сборка
Разумеется для нормальной разработки стоит использовать какую-то внешнюю систему сборки, но поскольку мы идем путем бусидо лишений и страданий — будем использовать исключительно средства 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
Сборка с Maven
Для сравнения я все же создал еще и обычную сборку проекта через Apache Maven, запустить можно из любой среды разработки либо напрямую из консоли:
mvn clean package
Разумеется для этого каталог bin из Apache Maven должен быть задан в переменной окружения PATH.
Для разработки использовалась обычная Intellj Idea, хотя вам ничего не мешает угореть по хардкору и использовать «блокнот»:
Целиком скрипт сборки для Maven выглядит как-то так:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.Ox08.experiments</groupId> <artifactId>no-frameworks</artifactId> <version>1.0-RELEASE</version> <name>0x08 Experiments: No frameworks</name> <url>http://maven.apache.org</url> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>18</source> <target>18</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <mainClass>com.Ox08.noframeworks.FeelsLikeAServer</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> </project>
Как видите тут нет зависимостей. Совсем нет. Никаких.
Для сборки и запуска нужна какая-нибудь свежая Java, поскольку я использовал кой-чего из новых возможностей (например record).
Но при желании весь код очень быстро портируется хоть на Java 1.8, которую уже можно запускать на всяких Arduno и прочих роутерах.
К слову через Maven приложение также собирается в виде executable jar, но с немного другим названием:
java -jar target/no-frameworks-1.0-RELEASE.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(); } ..
Казалось бы все достаточно просто и очевидно, но демонстрация этого проекта менее искушенным коллегам показала что все же надо детальнее раскрыть логику работы.
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);} }
Разумеется «тайное ниндзюцу» по работе со стандартным логгером (JUL), поставляемым с JDK/JRE полностью отсутствует у современных малолетних разработчиков — нахер ведь кому надо вылезать из навороченного окружения типа Spring/JEE, правда?
Поэтому даже столь банальная вещь как переключение уровня логирования «на лету» является неподъемной задачей для JUL.
при наличии параметра -DappDebug=true происходит переключение уровня логирования для JUL
Берите на вооружение, SL4J и Logback/Log4j есть далеко не всегда.
Дальше по коду происходит инициализация и запуск менеджера зависимостей (DI), это отдельная сложная тема, поэтому детально расписана ниже.
А пока кратко разберем что тут происходит и зачем:
// создание 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.
По-умолчанию сервер запускается на порту 8500, откройте в браузере адрес:
и сможете узреть эту самую гостевую:
Управление зависимостями
Да, когда-то давно так начинался знаменитый Spring Framework — как контейнер для автоматического управления зависимостями:
Внедрение зависимости (англ. Dependency injection, DI) — процесс предоставления внешней зависимости программному компоненту. Является специфичной формой «инверсии управления» (англ. Inversion of control, IoC), когда она применяется к управлению зависимостями. В полном соответствии с принципом единственной ответственности объект отдаёт заботу о построении требуемых ему зависимостей внешнему, специально предназначенному для этого общему механизму[1].
Разумеется современный Spring это уже раздутое до невообразимых размеров чудище, во внутреннем устройстве которого «черт ногу сломит» в попытке разобраться, так что даже не пытайтесь искать и сопоставлять логику работы с нашим маленьким проектом ;)
Расскажу в кратце как это работает с точки зрения «пользователя» — обычного разработчика, который лишь использует 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);
Теперь представьте объем подобного кода для типового Java-проекта, где каждая вставка @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);
Вот так выглядит метод инициализации целиком:
/** * Setup dependencies * @param inputCl * list of all classes that should be wired together */ 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 выше это будет выглядеть как-то так:
В результате всех операций мы получаем список классов, отсортированных по количеству зависимостей и готовых к инициализации:
// and instantiate one by one for (int i : deps) instantiate(cl[i]);
Вот так выглядит инстанциация класса:
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.
Если инициализация прошла успешно, бин добавляется в контейнер и сам становится доступен для подключения в виде зависимости.
Весь код целиком моего мини-контейнера выглядит так:
/** * Marker for our tiny dependency injection class * If class implements this interface - it's the injectable dependency */ interface Dependency {} /** * Simplest DI ever. * Based on Topological sort, supports only constructor injection */ static class TinyDI { // Function to return list containing vertices in Topological order. 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; } // No. of vertices private int totalDeps; // An Array of List which contains // references to the Adjacency List of // each vertex final ArrayList<ArrayList<Integer>> adj = new ArrayList<>(); private int cdc; // current index count, used when adding new class private Class<?>[] cl; // all classes with indexes private final TypedHashMap<Class<?>, Object> services = new TypedHashMap<>(); // stores all instances /** * Get instance of specified dependency * @param clazz * class of dependency * @return * dependency instance * @param <T> * instance type (class) */ public <T> T getInstance(Class<T> clazz) { return services.containsKey(clazz) ? services.getTyped(clazz, null) : null; } /** * Setup dependencies * @param inputCl * list of all classes that should be wired together */ 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]); } private void addClassNum(Class<?> c, List<Class<?>> deps) { LOG.log(Level.FINE, "add class %s with deps %d" .formatted(c.getName(), deps.size())); final int pos = addClassNum(c); // if class has no dependencies - put 'zero-deps' marks // and leave if (deps.isEmpty()) { adj.get(pos).add(0); return; } // for all others - links deps for (Class<?> cc : deps) adj.get(pos).add(addClassNum(cc)); } /** * Add dependency class to numbered array * @param c * dependency class * @return * class number */ private int addClassNum(Class<?> c) { // this line just checks for duplications, // to avoid double registrations of same class for (int i = 0; i < cl.length; i++) if (cl[i] != null && cl[i].equals(c)) return i; // actual adding cl[cdc] = c; return cdc++; } /** * Instantiates dependency class, pass its 'depended on' instances to constructor. * Puts instance to 'instances' storage. * @param clazz * dependency class */ 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); } } }
Если вы внимательно просмотрели код выше, то могли заметить некий TypedHashMap вместо обычного HashMap.
static class TypedHashMap<K,V> extends HashMap<K,V> { /** * Gets value with specified type * @param key * provided key * @param defaultValue * default value - with return if not found * @return * typed value * @param <T> * specified value type */ @SuppressWarnings("unchecked") <T> T getTyped(K key,T defaultValue) { return super.containsKey(key) ? (T)get(key) : defaultValue;} }
Весь смысл в том чтобы иметь возможность указать тип значения при вызове метода get () для тех случаев когда не используется точная типизация для самого HashMap.
Без этого класса нужно будет использовать unchecked type cast при каждом вызове, что будет порождать предупреждения.
Вообще идея собственного максимально простого и «деревянного» DI-контейнера народу зашла, поэтому мы начали применять чуть более продвинутую версию этого контейнера и в реальных проектах, в том числе в мобильной разработке.
Вот настолько до#бал Spring и его попытки делать то что не просят.
Но едем дальше, благо есть еще много нераскрытых тем в этом проекте.
Пользователи,сессии и авторизация
Да, все это также реализовано без каких-либо внешних библиотек и фреймворков — голыми руками.
Начнем с самого простого — с сессий, вот так выглядит класс для управления сессиями пользователей:
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<>(); /** * Get session object by session id * * @param sessionId session id * @return session object * @see Session */ public Session getSession(String sessionId) { return !isSessionExist(sessionId) ? null : sessions.get(sessionId);} /** * Checks if session exist * * @param sessionId session id to seek * @return true -if session found, false - otherwise */ 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; } /** * Register new user session * * @param user user entity * @return new session id */ 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; } /** * Unregister existing session (Logout) * * @param sessionId session id * @return true if session has been removed, false - otherwise */ public synchronized boolean unregisterSession(String sessionId) { if (!sessions.containsKey(sessionId)) return false; registeredUsers.remove(sessions.remove(sessionId).user.username); return true; } /** * Session record * * @param sessionId session id * @param created creation date * @param user session user */ 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<>(); /** * Load embedded users */ public void load() { addUser(new User("admin", "admin", "Administrator", true)); addUser(new User("alex", "alex", "Alex", false)); } /** * Check if user exist * * @param username username * @return true if user with provided username exists, false - otherwise */ public boolean isUserExists(String username) { return username != null && !username.isBlank() && users.containsKey(username); } /** * Return user entity by username * * @param username username/login * @return User entity */ public User getUserByUsername(String username) { return users.getOrDefault(username, null); } /** * Adds new user * * @param user user entity */ public void addUser(User user) { users.put(user.username(), user); } /** * User entity * * @param username username or login * @param password password as plaintext * @param name Full name * @param isAdmin true - user is administrator * false - otherwise */ 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:
/** * User entity * * @param username username or login * @param password password as plaintext * @param name Full name * @param isAdmin true - user is administrator * false - otherwise */ public record User(String username, String password, String name, boolean isAdmin) {}
На самом деле такой упрощенной реализации хватает для очень большого количества реальных систем и проектов, особенно встраиваемых, работающих на ограниченных ресурсах.
Еще булевый признак исключает типичную проблему с удалением всех ролей у записи пользователя, в данной реализации такого просто не может произойти.
Словом берите на вооружение если хотите простоты и надежности.
Авторизация
Авторизация работает путем формирования на стороне браузера JSON с полями логина и пароля.
С последующей отправкой этого JSON на сервер POST-запросом с помощью асинхронного API — все как в больших проектах с SPA.
Далее сервер обрабатывает POST-запрос, парсит JSON, вытаскивает введенные пользователем логин с паролем и проверяет.
Если учетные данные совпали — сервер создает сессию, выставляет авторизационный Cookie отдельным заголовком и возвращает url для перехода после авторизации.
Если нет — сервер возвращает ошибку, которая отображается в браузере (см. скриншот выше)
Такая реализация максимально близка к современным веб-системам, построенным по модели SPA и позволяет определенный интерактив:
например отображение сообщения об ошибке происходит без перезагрузки страницы.
Клиентская часть выглядит вот так:
class GuestBookLogin { /** * Submit entered credentials to server */ doAuth() { fetch('/api/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: document .querySelector('#usernameInput').value, password: document.querySelector('#passwordInput').value }) }).then((response) => { // if there was an error if (!response.ok) { // reset form document.querySelector('#loginForm').reset(); // show generic alert document.querySelector('#errorAlert').style.display = ''; // if authentication successful // there must be redirect to first page } else if (response.redirected) { location.href = response.url; } }).catch(error => { console.log("auth error: ", error); }); } } // create class instance const gbLogin = new GuestBookLogin(); // add listeners on page load window.onload = () => { // add 'click' event handler to login button document.querySelector('#loginBtn').addEventListener('click', (e) => { e.preventDefault(); gbLogin.doAuth(); }); // add form submit handler (when you press 'enter' key) document.querySelector('#loginForm').addEventListener('submit', (e) => { e.preventDefault(); gbLogin.doAuth(); }); };
Тут все достаточно просто и очевидно даже для вебмакаки начинающего Javascript-разработчика, добавлю лишь что (ради упрощения логики) вместо сообщения об ошибке передается лишь ее признак — что ошибка произошла.
При наличии этого признака происходит отображение статичного сообщения об ошибке, без подстановок данных с сервера.
Серверная логика авторизации находится в обработчике RestAPI, реализующим некую упрощенную пародию на RESTful API.
Часть отвечающая за авторизацию выглядит вот так:
.. case "/api/auth" -> { if (checkIfNonPostRequest(exchange)) return; final String req2 = new String(exchange .getRequestBody().readAllBytes(), StandardCharsets.UTF_8); LOG.info("req=%s".formatted(req2)); final Map<String, String> jsonParsed = Json.parseJson(req2); if (!jsonParsed.containsKey("username") || !jsonParsed.containsKey("password")) { LOG.info("bad request: no required fields"); respondBadRequest(exchange); return; } final String username = jsonParsed.get("username"), password = jsonParsed.get("password"); if (username == null || username.isBlank() || password == null || password.isBlank()) { LOG.info("bad request: some required fields are blank"); respondBadRequest(exchange); return; } if (!users.isUserExists(username)) { LOG.info("bad request: user not found"); respondBadRequest(exchange); return; } final Users.User u = users.getUserByUsername(username); if (!u.password.equals(password)) { LOG.info("bad request: password incorrect"); respondBadRequest(exchange); return; } final String sessionId = sessions.registerSessionFor(u); if (sessionId != null) { exchange.getResponseHeaders() .add("Set-Cookie", "%s=%s; Path=/; Secure; HttpOnly" .formatted(SESSION_KEY, sessionId)); LOG.info("set sessionId cookie: %s" .formatted(sessionId)); respondRedirect(exchange, "/"); return; } } }
Тут сразу несколько интересных вещей, которые точно стоит объяснить дорогим читателям.
Первое что происходит в обработчике это проверка на метод POST:
if (checkIfNonPostRequest(exchange)) return;
Если входящий запрос не POST, а например GET или HEAD — сервер вернет ошибку. Сама функция проверки выглядит достаточно просто:
protected boolean checkIfNonPostRequest(HttpExchange exchange) throws IOException { if ("POST".equals(exchange.getRequestMethod())) return false; LOG.log(Level.FINE, "bad request: only POST allowed"); respondBadRequest(exchange); return true; }
Как видите все что она делает это проверяет название метода используя API HTTP-сервера и вызывает метод для генерации ошибки:
protected void respondBadRequest(HttpExchange exchange) throws IOException { exchange.sendResponseHeaders(400, 0); exchange.close(); }
Выставляется HTTP статус ответа в 400 что означает «Bad Request» и сразу же завершается обработка — вызов exchange.close().
Но вернемся к теме авторизации.
Дальше по логике обработки запроса авторизации происходит чтение тела запроса в строку:
final String req2 = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
final Map<String, String> jsonParsed = Json.parseJson(req2);
В этом месте более-менее опытные Java-разработчики должны ох#еть насторожиться — в составе JRE/JDK нет стандартной реализации парсера JSON.
Есть спецификация, есть эталонная реализация, но.. для JEE стека — для сервера приложений, не для обычного JRE.
Поэтому мне пришлось написать свою упрощенную реализацию парсера JSON — в деталях описано чуть ниже.
Пока лишь отмечу что результат обработки это обычный HashMap, где ключами выступают поля, а значениями — значения JSON объекта.
Вложенность не поддерживается.
Дальше по логике обработки авторизации происходит проверка обязательных полей:
if (!jsonParsed.containsKey("username") || !jsonParsed.containsKey("password")) { LOG.info("bad request: no required fields"); respondBadRequest(exchange); return; }
На случай если прислали невалидный JSON, в этом случае сервер отдает статус 400 и обработка завершается.
Дальше происходит получение введенных значений из формы авторизации, запрос по именованным полям:
final String username = jsonParsed.get("username"), password = jsonParsed.get("password");
Затем делается проверка на пустоту — что и логин и пароль не пустые:
if (username == null || username.isBlank() || password == null || password.isBlank()) { LOG.info("bad request: some required fields are blank"); respondBadRequest(exchange); return; }
Затем проверка на существование пользователя с таким логином:
if (!users.isUserExists(username)) { LOG.info("bad request: user not found"); respondBadRequest(exchange); return; }
final Users.User u = users.getUserByUsername(username); if (!u.password.equals(password)) { LOG.info("bad request: password incorrect"); respondBadRequest(exchange); return; }
Если все проверки пройдены — происходит регистрация сессии пользователя:
final String sessionId = sessions.registerSessionFor(u);
Если регистрация прошла успешно, то к ответу сервера добавляется специальный заголовок:
if (sessionId != null) { exchange.getResponseHeaders() .add("Set-Cookie", "%s=%s; Path=/; Secure; HttpOnly" .formatted(SESSION_KEY, sessionId)); LOG.info("set sessionId cookie: %s".formatted(sessionId)); respondRedirect(exchange, "/"); return; }
Заголовок Set-Cookie отвечает за создание Cookie-файла с информацией о сессии на стороне браузера пользователя.
Затем сервер посылает ответ 301 «Redirect»:
respondRedirect(exchange, "/");
Реализация метода, выполняющего редирект выглядит вот так:
protected void respondRedirect(HttpExchange exchange, String targetUrl) throws IOException { exchange.getResponseHeaders().add("Location", targetUrl); exchange.sendResponseHeaders(301, 0); exchange.close(); }
Клиентская сторона проверяет признак редиректа (стандартная часть fetch API в современных браузерах):
.. } else if (response.redirected) { location.href = response.url; } ..
И если он есть — осуществляется перезагрузка страницы и переход на указанный в заголовке Location адрес.
При этом уже будет сохраненный Cookie, поскольку заголовок Set-Cookie обрабатывается браузером автоматически.
Весь этот «закат солнца вручную» — прямой аналог процесса создания JSESSIONID из любого стандартного сервлет-контейнера.
Именно поэтому я использовал вот такое название для своей версии:
protected static final String SESSION_KEY = "NotJSESSIONID", // name of key in cookies values, ..
Выход из системы
Теперь немного разберем как работает Logout — выход из системы.
С клиентской стороны это просто форма:
<form style="display: inline;" action="/api/logout" method="post"> <input class="btn primary" type="submit" value="${msg(gb.text.logout)}"/> </form>
Которая всего лишь отправляет POST-запрос к серверу, без изысков и защиты от CSRF.
Не считая подстановки текста в value через самопальный парсер EL-выражений — но о нем детально ниже.
Серверная сторона обработки выхода из системы выглядит вот так:
... case "/api/logout" -> { if (checkIfNonPostRequest(exchange)) return; final String sessionId = getCookieValue(exchange, SESSION_KEY); if (sessionId == null || sessionId.isBlank()) { respondBadRequest(exchange); return; } if (sessions.unregisterSession(sessionId)) { exchange.getResponseHeaders() .add("Set-Cookie", "%s=; Path=/; Max-Age=-1; Secure; HttpOnly" .formatted(SESSION_KEY)); respondRedirect(exchange, "/"); } else { LOG.warning("Cannot destroy session: %s".formatted(sessionId)); respondBadRequest(exchange); } return; }
Тут выполняется та же самая проверка на тип запроса и возвратом ошибки если это не POST:
if (checkIfNonPostRequest(exchange)) return;
Затем происходит получение ID текущей сессии пользователя путем чтения данных из HTTP-заголовка «Cookie»:
final String sessionId = getCookieValue(exchange, SESSION_KEY);
Логика разборка Cookie достаточно сложная:
protected String getCookieValue(HttpExchange exchange, String key) { // get cookie headers final List<String> cookies = exchange .getRequestHeaders().getOrDefault("Cookie", Collections.emptyList()); if (cookies.isEmpty()) return null; // Sample header: // Cookie SID=31d4d96e407aad42; // NonJSESSIONID=26288ab0-bc8f-4c4c-8982-fa5e2408db19 for (String c : cookies) { if (c == null || c.isBlank() ||!c.contains(key) || !c.contains("=") ||!c.contains(";")) continue; LOG.log(Level.FINE, "cookie value: %s".formatted(c)); final String[] parts = c.split(";"); for (String p : parts) { LOG.log(Level.FINE, "cookie value part %s" .formatted(p)); // check if part is null or blank and skip if so, // trim - otherwise if (p == null || p.isBlank()) continue; else p = p.trim(); // if part does not start with key or // does not contain '=' symbol - skip if (!p.startsWith(key) || !p.contains("=")) continue; // otherwise - split with '=' and // return second element, that will be our value final String[] kv = p.split("="); return kv[1]; } // if cookie starts with our key - just strip it and return if (c.startsWith(key)) return c.substring((key + "=").length()); } return null; }
Заголовков с именем «Cookie» в запросе может быть несколько, внутри каждого может быть несколько связок ключ-значение — каждую нужно обойти чтобы найти наш SESSION_KEY и достать данные.
Дальше по логике работы происходит проверка наличия сессии с указанным ID:
if (sessionId == null || sessionId.isBlank()) { respondBadRequest(exchange); return; }
Если сессия обнаружена — она уничтожается:
if (sessions.unregisterSession(sessionId)) { exchange.getResponseHeaders() .add("Set-Cookie", "%s=; Path=/; Max-Age=-1; Secure; HttpOnly" .formatted(SESSION_KEY)); respondRedirect(exchange, "/"); } else { LOG.warning("Cannot destroy session: %s" .formatted(sessionId)); respondBadRequest(exchange); }
Проверка актуальности сессии
Теперь расскажу как осуществляется проверка сессии пользователя.
Вот что происходит до этого момента:
После успешной авторизации, сервер отдает заголовком Cookie с ID сессии пользователя и просит браузер сделать редирект на главную страницу.
После редиректа, все HTTP-запросы из браузера к серверу будут содержать заголовок «Cookie», в данных которого будет ID сессии пользователя.
final String sessionId = getCookieValue(exchange, SESSION_KEY);
if (sessionId == null || sessionId.isBlank()) { LOG.log(Level.FINE, "bad request: no session header"); respondBadRequest(exchange); return; }
.. и наконец проверить наличие самой сессии:
// if session is not exist - respond bad request if (!sessions.isSessionExist(sessionId)) { LOG.log(Level.FINE, "bad request: session not found"); respondBadRequest(exchange); return; }
Если сессия найдена — пользователь считается авторизованным и отрабатывает дальнейшая логика обработки.
Вот так выглядит вся цепочка проверок для одного из методов моего самопального REST API:
.. // delete record case "/api/delete" -> { // we allow only POST there if (checkIfNonPostRequest(exchange)) { LOG.log(Level.FINE, "bad request: not a POST method");return; } // To remove record, user must be authenticated first, // so here we try to get session id from provided cookie header final String sessionId = getCookieValue(exchange, SESSION_KEY); // if there is no session id - respond bad request if (sessionId == null || sessionId.isBlank()) { LOG.log(Level.FINE, "bad request: no session header"); respondBadRequest(exchange); return; } // if session is not exist - respond bad request if (!sessions.isSessionExist(sessionId)) { LOG.log(Level.FINE, "bad request: session not found"); respondBadRequest(exchange); return; } ...
На этом считаю тему авторизации, сессий и пользователей закрытой, а мы переходим к следующей части — тоже в категории «полный пэ», благо других у меня нет.
Самопальный 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
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":"Дооо дооо дооооо дооооо"
Если было передан параметр next=true, то в конце будет добавлена запятая:
"created":"1676381027509",
Следующий уровень это последовательные вызовы данного метода для всех полей объекта:
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":"Еще один унылый тест", "author":"Тестов", "created":"1717661222006", "message":"Дооо дооо дооооо дооооо" }]
Не поверите, но таких примитивов вполне хватит для управляющего ПО для какого-нибудь Arduno.
Но едем дальше, на очереди следущая крутая тема.
Шаблонизатор
«Чад кутежа во славу самопала» был бы неполным без своей реализации шаблонизатора — упрощенного аналога 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(); } }
Вот так выглядит полный код нашего обрабочика для отдачи страниц с шаблонизатором:
static class PageHandler extends AbstractHandler implements HttpHandler, Dependency { private final Map<String, StaticResource> resources = new HashMap<>(); private final Map<String,String> templates = new HashMap<>(); private final Sessions sessions; private final Expression expr; PageHandler(Sessions sessions, Expression expr) { this.sessions = sessions; this.expr = expr; try { templates.put("template/main.html", new String( getResource("/static/html/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")); } catch (IOException e) { throw new RuntimeException("Cannot load page template",e); } } @Override public void handle(HttpExchange exchange) throws IOException { String url = getUrl(exchange.getRequestURI()); if (url == null || "/".equals(url) || url.isEmpty()) url = "/index.html"; // if passed url is not mapped - respond 404 if (!resources.containsKey(url)) { respondNotFound(exchange); return; } // get static resource final StaticResource resource = resources.get(url); // set mime type header exchange.getResponseHeaders().add("Content-type", resource.mime); // if resource is not a page template - just // write bytes and go away. if (!"text/html".equals(resource.mime)) { respondData(exchange, resource.data); return; } // retrieve session id final String sessionId = getCookieValue(exchange, SESSION_KEY), lang = getCookieValue(exchange, LANG_KEY); // 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); // 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); // put current user's name to been displayed in top of page if (sessionExist) runtime.put("user.name", sessions.getSession(sessionId).user.name); try { final String source = new String(resource.data); // at first, we need to build parts of template expr.parseTemplate(source, runtime, (line)-> expr.buildTemplate(line.expr,line.runtime)); // if template was used and found - merge it with // extracted sections, otherwise - just re-use source final String merged = runtime .containsKey(Expression.PAGE_TEMPLATE_KEY) ? expr.mergeTemplate( runtime.getTyped(Expression.PAGE_TEMPLATE_KEY,null), runtime) : source; // render everything 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); } } }
Во-первых сам обработчик имеет зависимости, поэтому его жизненный цикл управляется 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"));
и кладутся в очередное хранилище, где ключем является урл страницы, по которому она доступна пользователям:
/login.html
Метод getResource () как и record StaticResource находятся в абстрактном классе AbstractHandler, от которого наследуется наш обработчик.
Вот так выглядит метод чтения файла из ресурсов JAR-файла:
protected byte[] getResource(String name) throws IOException { LOG.info("reading resource %s".formatted(name)); try (InputStream in = getUrl(name).openStream(); ByteArrayOutputStream bo = new ByteArrayOutputStream()) { int nRead; final byte[] d = new byte[16384]; while ((nRead = in.read(d, 0, d.length)) != -1) bo.write(d, 0, nRead);return bo.toByteArray(); } }
protected URL getUrl(String name) throws FileNotFoundException { final URL u = getClass().getResource(name); if (u == null) throw new FileNotFoundException("Resource not found: %s" .formatted(name)); else return u; }
На этом процесс инициализации обработчика страниц заканчивается, остальная логика находится уже в методе обработки, вызываемом на каждый входящий HTTP-запрос:
@Override public void handle(HttpExchange exchange) throws IOException { .. }
Первым делом выполняется проверка и очистка url запроса:
String url = getUrl(exchange.getRequestURI());
Метод getUrl() также находится в абстрактном классе AbstractHandler, вот так выглядит его содержимое:
protected String getUrl(URI u) { return (u != null ? u.getPath() : "").toLowerCase().trim(); }
Перевод в нижний регистр нужно для последующего сравнения с доступными страницами, регистрация которых выполняется в нижнем регистре.
Следующим шагом производится проверка на пустоту:
if (url == null || "/".equals(url) || url.isEmpty()) url = "/index.html";
Если урл пришел пустой — подставляется вариант по-умолчанию.
Затем урл проверяется по доступным вариантам страниц:
if (!resources.containsKey(url)) { respondNotFound(exchange); return; }
Если нет страниц с указанным url — возвращается ошибка 404, вот так выглядит метод генерации этой ошибки:
protected void respondNotFound(HttpExchange exchange) throws IOException { exchange.sendResponseHeaders(404, 0); exchange.close(); }
Дальше происходит получение данных из хранилища:
final StaticResource resource = resources.get(url);
Следующим шагом в ответ сервера добавляется заголовок «Content-Type», отвечающий за распознавание типа данных, где в качестве значения указывается тип MIME:
exchange.getResponseHeaders().add("Content-type", resource.mime);
Если тип данных отличается от «text/html», который используется для шаблонов страниц, то просто отдаются данные и обработка завершается:
if (!"text/html".equals(resource.mime)) { respondData(exchange, resource.data); return; }
Дальше происходит чтение значений из заголовков Cookie:
final String sessionId = getCookieValue(exchange, SESSION_KEY), lang = getCookieValue(exchange, LANG_KEY);
Первое значение это сессия пользователя, второе — выбранный язык локали. Затем происходит создание рантайма для шаблонизатора:
// 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);
Затем в рантайм добавляется выбранный язык или язык по-умолчанию, а также текущий урл:
// 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); ... } catch (Exception e) { LOG.log(Level.WARNING, "Cannot parse template: %s".formatted(e.getMessage()), e); respondBadRequest(exchange); }
Теперь рассмотрим каждый шаг генерации шаблона, благо других таких разборов вы врядли найдете.
Первый важный шаг это связывание всех частей шаблона в единый HTML:
// at first, we need to build parts of template expr.parseTemplate(source, runtime, (line)-> expr.buildTemplate(line.expr,line.runtime));
Причем третий аргумент это на самом деле замыкание, внутри которого вызывается метод подстановки в строке:
(line)-> expr.buildTemplate(line.expr,line.runtime)
Следующим шагом запускаем обработку всех выражений:
// render everything respondData(exchange, expr.parseTemplate(merged, runtime, (line)-> expr.parseExpr(line.expr,line.runtime)) .getBytes(StandardCharsets.UTF_8));
Но лезем глубже, в сами методы обработки.
Вот так выглядит метод обработки шаблона, которым происходит связывание частей в единый HTML:
public String parseTemplate(String pageData, TypedHashMap<String, Object> runtime, Function<Line, String> onReadExpr) { final StringBuilder out = new StringBuilder(), expr = new StringBuilder(); boolean startExpr = false; int[] counts = new int[2]; // we need to iterate over each character in page content for (int i = 0; i < pageData.length(); i++) { // get single character char c = pageData.charAt(i); // if expression started if (startExpr) { // end of expression if (c == '}') { counts[1]++; LOG.log(Level.FINE, "count start=%d close=%d expr: %s" .formatted(counts[0], counts[1], expr)); if (counts[0] == counts[1]) { startExpr = false; counts = new int[]{0, 0}; out.append(onReadExpr .apply(new Line(expr.toString(), runtime))); expr.setLength(0); continue; } // this is start of internal expression, // that's inside current! } else if (c == '{') counts[0]++; expr.append(c); continue; } // first character in expression start: ${ if (c == '#39;) { // here we do checking for next character and // if it matches '{' - we have expression block. final int ni = i + 1; if (ni < pageData.length() && pageData.charAt(ni) == '{') { startExpr = true; counts[0]++; } i = ni; continue; } out.append(c); } return out.toString(); }
Тут происходит последовательное и посимвольное чтение шаблона, где внутри цикла происходит поиск и выборка всех подстановок вида ${..}
В момент определения выражения — когда последовательно считались символы '#39;, '{', внутренний блок и завершающий символ '}', происходит вызов функции обработки, переданной в качестве аргумента:
out.append(onReadExpr.apply(new Line(expr.toString(), runtime)));
Внутри происходит вызов функции:
(line)-> expr.buildTemplate(line.expr,line.runtime)
String buildTemplate(String expr, TypedHashMap<String,Object> runtime) { // check for template tag if (expr.startsWith("template(")) { // extract template key String key = expr.substring("template(".length()); key = key.substring(0, key.indexOf(")")); LOG.log(Level.FINE, "found template key: '%s'".formatted(key)); final Map<String, String> templates = runtime .getTyped(ALL_TEMPLATES_KEY,Collections.emptyMap()); // check if template exist if (!templates.containsKey(key)) { LOG.log(Level.FINE, "template not found: '%s'" .formatted(key)); return ""; } // check if template was already loaded if (runtime.containsKey(PAGE_TEMPLATE_KEY)) { LOG.log(Level.WARNING, "template already loaded: '%s'" .formatted(key)); return ""; } // put current template into runtime context runtime.put(PAGE_TEMPLATE_KEY, new Template(key, templates.get(key),new HashMap<>())); // check for section tag } else if (expr.startsWith("section(")) { String data = expr.substring("section(".length()); final int i = data.indexOf(")"); // extract conditional block final String code = data.substring(i + 1); data = data.substring(0, i); LOG.log(Level.FINE, "found section key: '%s' , data sz: %d" .formatted(data,code.length())); // add parsed section to current template if (runtime.containsKey(PAGE_TEMPLATE_KEY)) { final Template t = runtime.getTyped(PAGE_TEMPLATE_KEY,null); t.sections.put(data,code); } } return ""; }
В результате работы этой функции, происходит вычленение секций и заполнение рантайма данными из каждой секции.
На следующем шаге эти данные подставляются в готовый шаблон.
Статичный контент
Статичный контент это просто бинарные файлы, отдаваемые сервером «as-is» — без какой-либо модификации:
скрипты Javascript, иконки, картиночки и CSS-стили — все что использует браузер на странице.
Тут все достаточно просто и банально — используется отдельный обработчик, который устанавливается на путь «/static» и им же отдается «/favicon.ico»:
// create resource handler final ResourceHandler rs = notDI.getInstance(ResourceHandler.class); // bind to serve static content server.createContext("/static").setHandler(rs); server.createContext("/favicon.ico").setHandler(rs);
Сам обработчик также достаточно прост:
static class ResourceHandler extends AbstractHandler implements Dependency { private final Map<String, StaticResource> resources = new HashMap<>(); ResourceHandler() { try { resources.put("/favicon.ico", new StaticResource( getResource("/static/img/favicon.ico"), "image/x-icon")); .. resources.put("/static/js/login.js", new StaticResource( getResource("/static/js/login.js"), "application/javascript")); } catch (IOException e) { throw new RuntimeException("Cannot load static resource",e); } } @Override public void handle(HttpExchange exchange) throws IOException { final String url = getUrl(exchange.getRequestURI()); if (resources.containsKey(url)) { final StaticResource resource = resources.get(url); exchange.getResponseHeaders() .add("Content-type", resource.mime); respondData(exchange, resource.data); } else respondNotFound(exchange); } }
Как видите тут используются все те же стадии что и для описанного выше обработчика шаблонов.
Сначала статический контент загружается из ресурсов приложения в общее «key-value» хранилище, где ключом является урл ресурса:
resources.put("/favicon.ico", new StaticResource( getResource("/static/img/favicon.ico"), "image/x-icon"));
Когда обработчик вызывается на входящий HTTP-запрос, ресурс ищется по указанному урлу и отдается клиенту:
@Override public void handle(HttpExchange exchange) throws IOException { final String url = getUrl(exchange.getRequestURI()); if (resources.containsKey(url)) { final StaticResource resource = resources.get(url); exchange.getResponseHeaders() .add("Content-type", resource.mime); respondData(exchange, resource.data); } else respondNotFound(exchange); }
Вот так выглядит метод respondData, отвечающий за отдачу бинарных данных:
protected void respondData(HttpExchange exchange, byte[] data) throws IOException { boolean gzip = false; if (exchange.getRequestHeaders().containsKey("Accept-Encoding")) for (String part : exchange.getRequestHeaders() .get("Accept-Encoding")) if (part != null && part.toLowerCase() .contains("gzip")) { gzip = true; break; } if (gzip) { exchange.getResponseHeaders().add("Content-Encoding", "gzip"); exchange.sendResponseHeaders(200,0); LOG.log(Level.FINE, "respond gzipped content sz: %d" .formatted(data.length)); try (OutputStream out = new java.util .zip.GZIPOutputStream(exchange.getResponseBody())) { out.write(data); out.flush(); } } else { exchange.sendResponseHeaders(200, data.length); try (OutputStream os = exchange.getResponseBody()) { os.write(data); os.flush(); } } }
Если не заметили, тут реализована поддержка автоматической потоковой упаковки данных в Gzip — чего кстати нет в большинстве корпоративных говнопроектов на навороченных серверах приложений.
Сначала происходит проверка на наличие HTTP-заголовка Accept-Encoding:
if (exchange.getRequestHeaders().containsKey("Accept-Encoding")) for (String part : exchange.getRequestHeaders() .get("Accept-Encoding")) if (part != null && part.toLowerCase() .contains("gzip")) { gzip = true; break; }
Если таковой есть и в значениях есть слово «gzip» — добавляется заголовок Content-Encoding и сам контент отдается в сжатом виде:
if (gzip) { exchange.getResponseHeaders().add("Content-Encoding", "gzip"); exchange.sendResponseHeaders(200,0); LOG.log(Level.FINE, "respond gzipped content sz: %d" .formatted(data.length)); try (OutputStream out = new java.util .zip.GZIPOutputStream(exchange.getResponseBody())) { out.write(data); out.flush(); } ...
Но едем дальше, к куда более захватывающей штуке.
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-запроса, мои поздравления.
Ещ для проформы добавлю, что сам обработчик является зависимым от других бинов, поэтому тоже инстанциируется IoC-контейнером.
А вот так происходит его связывание с путем «/api», по которому работают все методы API:
// bind 'REST' API server.createContext("/api").setHandler(notDI.getInstance(RestAPI.class));
Едем дальше, к очередному адскому цирку веселью.
Работа с записями гостевой
Это собственно основная бизнес-логика гостевой, для минимальной реализации надо чтобы приложение умело:
- Отображать существующие записи
- Добавлять новую запись
- Удалять выбранную запись (только для авторизированных пользователей)
Что и было реализовано в нашем замечательном проекте.
Отображение записей
Начнем рассказ с логики отображения — она внезапно достаточно нетривиальна.
Дело в том что я реализовал полноценную шаблонизацию еще и на клиентской стороне — также как это работает в больших фреймворках аля Angular/React, но разумеется с сильным упрощением.
На странице есть вот такой статичный скрытый блок, содержащий шаблон отображаемой записи:
<!-- this is hidden block, that stores GB record template --> <div id="message-template" class="card row" style="display:none;"> <h4> <span id="message-title">[title]</span> </h4> <p> ${msg(gb.text.from)} <span id="message-author">[author]</span> ${msg(gb.text.at)} <span id="message-date">[date]</span> </p> <p id="message-text">[text]</p> <div style="float:right;"> ${if(gb.isAuthenticated) <a href="#" id="deleteBtn" class="btn primary" confirm="${msg(gb.text.btn.delete.confirm)}"> ${msg(gb.text.btn.delete)} </a> } </div> </div>
Поскольку этот блок хоть и скрыт, но уже находится в DOM-дереве документа — к его элементам применяются все используемые стили оформления.
В момент загрузки страницы выполняется стандартный обработчик:
window.onload = () => { .. // and finally - call to load GB records gb.loadRecords(); };
В котором вызывается метод загрузки данных loadRecords(), вот что внутри:
loadRecords() { // create new constant that points to 'this', // to obtain self instance inside handlers (see below) const self = this; // make API call to retrieve list of GB records fetch('/api/records', { method: 'GET', headers: {} }) .then(response => response.json()).then(records => { console.log('loaded records:', records); // get 'messages' element on page and reset its inner contents let messagesEl = document.querySelector('#messages'); messagesEl.innerHTML = ""; // add each GB record, found in JSON to page, // using record template records.forEach(record => { self.addRecordFromTemplate(messagesEl, record, false); }); }).catch(error => { console.log("cannot add: ", error); }); }
const self = this;
Это «дедовский» и потому самый надежный способ сохранить ссылку на родительский объект, иначе при вызове из замыкания значение this заменится и будет вести на само самыкание.
self.addRecordFromTemplate(messagesEl, record, false);
без такого self работать не будет — метод класса просто не найдется.
Дальше происходит формирование и отправка GET-запроса к серверу с помощью Fetch API, причем ответ сразу преобразуется в JSON средствами самого fetch
О таком функционале кстати мало кто знает вообще, ну измалолетнихсовременных веб-разработчиков.
Далее запускается обработка уже массива элементов — все это практически одной строкой:
fetch('/api/records', { method: 'GET', headers: {} }) .then(response => response.json()).then(records => {
Дальше начинается черная магия и чудеса та самая логика, которую от вас обычно скрывает большой фреймворк вроде Angular или React:
происходит подстановка значений полей объекта JSON в шаблон записи.
Затем уже заполненный шаблон добавляется к DOM-дереву в виде нового элемента:
// get 'messages' element on page and reset its inner contents let messagesEl = document.querySelector('#messages'); messagesEl.innerHTML = ""; // add each GB record, found in JSON to page, using record template records.forEach(record => { self.addRecordFromTemplate(messagesEl, record, false); });
Вот так выглядит метод, реализующий заполнение шаблона записи гостевой:
addRecordFromTemplate(messagesEl, record, prepend) { // create new element, cloned from template let cloneEl = document.querySelector('#message-template') .cloneNode(true); // set id cloneEl.setAttribute('id', 'id_' + record.id); // inject content cloneEl.querySelector('#message-title').innerHTML = record.title; cloneEl.querySelector('#message-text').innerHTML = record.message; cloneEl.querySelector('#message-author').innerHTML = record.author; cloneEl.querySelector('#message-date').innerText = new Date(parseInt(record.created)).toLocaleString(); // and action buttons const deleteBtn = cloneEl.querySelector('#deleteBtn'); // delete button will not exist on page, // if user is not authenticated if (deleteBtn) { deleteBtn.addEventListener('click', (e) => { e.preventDefault(); // 'confirm' message will be extracted from attribute if (window.confirm(deleteBtn.getAttribute('confirm'))) { gb.deleteRecord(record.id); } }); } // append to the end or prepend to top if (prepend) { messagesEl.insertBefore(cloneEl, messagesEl.firstChild); } else { messagesEl.appendChild(cloneEl); } // and finally show new element cloneEl.style.display = ''; }
Первым делом мы берем DOM-элемент шаблона записи и клонируем его:
let cloneEl = document.querySelector('#message-template') .cloneNode(true);
Клонированный элемент будет отсоединен от DOM, но при этом будут работать все методы поиска и выборки элементов внутри:
cloneEl.querySelector('#message-title').innerHTML = record.title;
Поэтому мы совершенно спокойно заполняем шаблон, делая нужные выборки.
Обратите внимание как происходит подстановка даты:
cloneEl.querySelector('#message-date').innerText = new Date( parseInt(record.created)).toLocaleString();
Это циничный хак для того чтобы получить полную дату и время в локализованном формате, без всего геммороя с обработкой дат на сервере.
Минус в том что нет привязки к выбранному пользователем языку локализации и дата всегда будет показываться в текущей локали браузера.
Да это тоже ради упрощения реализации.
А вот ID записи подставляется в виде атрибута элемента:
// set id cloneEl.setAttribute('id', 'id_' + record.id);
Зачем?
Чтобы можно было добраться до этого ID в DOM-элементе, если нужны операции работы с отдельной записью.
Дальше происходит создание и связывание с элементом обработчика удаления — который запускается по клику на управляющую кнопку:
Он отображает стандартный диалог с подтверждением удаления:
const deleteBtn = cloneEl.querySelector('#deleteBtn'); // delete button will not exist on page, // if user is not authenticated if (deleteBtn) { deleteBtn.addEventListener('click', (e) => { e.preventDefault(); // 'confirm' message will be extracted from attribute if (window.confirm(deleteBtn.getAttribute('confirm'))) { gb.deleteRecord(record.id); } }); }
Далее происходит добавление нашего клонированного элемента в DOM-дерево:
// append to the end or prepend to top if (prepend) { messagesEl.insertBefore(cloneEl, messagesEl.firstChild); } else { messagesEl.appendChild(cloneEl); }
Как видите тут работает переключатель, который определяет как именно добавлять — в конец списка записей или в начало.
Зачем это нужно?
Чтобы после отправки формы добавления новой записи, сразу добавить ее в отображаемый список без полной перезагрузки страницы.
Наконец самым последним шагом происходит снятие признака скрытия элемента:
cloneEl.style.display = '';
После этого блок с записью гостевой наконец будет виден на странице.
А это был только фронтэнд — даже не половина, а четверть всей логики работы.
Отображение записей : бекэнд
Логика работы с записями гостевой находится внутри обработчика нашего самопального REST, который уже описан выше.
Вот так выглядит часть, отвечающая за отдачу записей:
.. switch (url) { // respond list of records case "/api/records" -> { final List<BookRecord> rvalues = new ArrayList<>( storage.getAllRecords()); // process sorting if (params.containsKey("sort")) { final String sortKey = params.get("sort"); switch (sortKey) { case "id" -> rvalues.sort(Comparator.comparing(u -> u.id)); case "title" -> rvalues.sort(Comparator.comparing(u -> u.title)); case "created" -> rvalues.sort(Comparator.comparing(u -> u.created)); } } else rvalues.sort(Comparator.comparing(u -> u.created, Comparator.reverseOrder())); // build list of objects Json.toJson(out, rvalues); } ..
Сначала получаем записи из хранилища:
final List<BookRecord> rvalues = new ArrayList<>( storage.getAllRecords());
Как именно работает чтение и запись самих данных гостевой на диск детально описано в следующей главе — там тоже много интересного.
А пока едем по логике вниз.
Лулзов ради было добавлено еще и управление сортировкой записей.
В интерфейсе ее нет — чтобы не увеличивать объем кода, но можете вызвать API указав параметр и насладиться:
curl -i -X GET "http://localhost:8500/api/records?sort=title"
По-умолчанию (как это и вызывается из интерфейса) будет работать сортировка по дате создания — от самых свежих записей гостевой к самым старым:
rvalues.sort(Comparator.comparing(u -> u.created, Comparator.reverseOrder()));
Последним шагом мы формируем JSON с помощью нашего самопального парсера и отдаем клиенту:
Json.toJson(out, rvalues);
Если вы еще не ох#ели от масштаба работ испугались — сообщаю что все описанное было только про получение записей гостевой.
А ведь еще есть логика добавления и удаления.
Рассказываю как происходит добавление новой записи.
Добавление новой записи
Отправка данных работает как по нажатию кнопки «Отправить» (или «Submit» — в английской версии) — что крайне банально, так и по нажатию Enter, как это принято «в лучших домах Парижу».
Обработчики для обоих событий регистрируются по завершению загрузки страницы:
window.onload = () => { // add 'click' event handler to 'submit' button document.querySelector('#submitBtn') .addEventListener('click', (e) => { e.preventDefault(); gb.addRecord(); }); // add form submit handler (when user press 'enter' in form) document.querySelector('#newRecordForm') .addEventListener('submit', (e) => { e.preventDefault(); gb.addRecord(); }); ... };
В обоих случаях вызывается одна и та же функция addRecord(), вот что она делает:
addRecord() { // bind current instance to variable, // to access other methods of same class from listener callbacks const self = this; // extract form fields let author = self.escapeHTML(document .querySelector('#authorInput').value); ... // make API call to add new post fetch('/api/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, author: author, message: message }); }).then(response => { // if there was an error - show the alert block // and throw exception if (!response.ok) { document.querySelector('#errorAlert').style.display = ''; throw new Error(response.statusText); } else { document.querySelector('#errorAlert').style.display = 'none'; } // respond parsed json, if added successfully // json will contain our newly created record return response.json(); }).then(record => { // if API call was successful - reset form and // add created record to list, without page reload console.log('new record:', record); document.querySelector('#newRecordForm').reset(); self.addRecordFromTemplate( document.querySelector('#messages'), record, true); }).catch(error => { console.log("cannot add: ", error); }); }
Сразу после уже описанного выше трюка с const self, происходит чтение введенных значений из полей формы:
let author = self.escapeHTML(document .querySelector('#authorInput').value);
Обратите внимание на функцию escapeHTML — она тоже «самопальная», была реализована в рамках этого проекта и служит для очень простого экранирования символов HTML-разметки в отправляемых браузером данных:
escapeHTML(unsafe) { return unsafe.replace(/[\u0000-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u00FF]/g, c => '&#' + ('000' + c.charCodeAt(0)).slice(-4) + ';'); }
Такая реализация — запредельно простая на нынешние времена, поэтому ее нельзя применять в реальных боевых проектах.
Она не защитит от XSS-атак и первый же мамкин хацкер поставит вашу систему раком.
Ваять собственную реализацию такой штуки стоит только при наличии большого опыта и компетенций, если у вас их нет — используйте готовые решения, не подвергайте риску проект из-за вашего «самопала».
Но вернемся к описанию логики работы.
Следующим шагом происходит вызов API сервера c помощью все того же метода fetch():
fetch('/api/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, author: author, message: message }); ...
При вызове вручную формируется JSON-строка, в которую подставляются введенные значения.
Затем эта строка отправляется на сервер в теле POST-запроса.
Ответ сервера обрабатывается только в две стадии: проверка статуса ответа и парсинг данных JSON из тела ответа.
Полноценный Preflight-запрос как в модном Angular я не стал реализовывать — он меня и так выбешивает в самом Ангуляре, извините.
... }).then(response => { // if there was an error - show the alert block // and throw exception if (!response.ok) { document.querySelector('#errorAlert').style.display = ''; throw new Error(response.statusText); } else { document.querySelector('#errorAlert').style.display = 'none'; } // respond parsed json, if added successfully // json will contain our newly created record return response.json(); ...
if (!response.ok) { .. }
на самом деле является частью самого Fetch API:
An accurate check for a successfulfetch()
would include checking that the promise resolved, then checking that theResponse.ok
property has a value of true.
А поле Response.ok принимает значение true когда сервер возращает статус в диапазоне успешных статусов:
Theok
read-only property of theResponse
interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.
Если сервер вернул статус вне этого диапазона (например 500 или 403) — будет показан блок с общим сообщением об ошибке:
document.querySelector('#errorAlert').style.display = '';
Я не очень люблю показывать детальные сообщения пользователю и в своих реальных проектах — пользователи сильно отупели в наши дни и сложные технические описания ошибок их пугают.
Поэтому такой подход с «абстрактной ошибкой» на самом деле очень даже рабочий и применяется повсеместно далеко не только мной:
Следующим шагом выбрасывается искусственная ошибка:
throw new Error(response.statusText);
Это нужно для того чтобы вместо следующего уровня обработки, отвечающего за парсинг JSON, сразу выполнился общий обработчик ошибок:
}).catch(error => { console.log("cannot add: ", error); });
Таким образом попытка разбора JSON не запустится и не появится вложенных ошибок — как в типовом говнокоде с длинной соплей из вот таких вызовов:
}).then(response => { }).then(response => { }).then(response => { }).then(response => { }).then(response => { }).then(response => { ....
Только не врите что «в наших проектах такого нет и вообще процветает code review» — одно другого еще ни разу не исключило на моей практике :)
Если с ответом от сервера все было хорошо — блок с сообщением об ошибке скрывается:
document.querySelector('#errorAlert').style.display = 'none';
Почему он скрывается, если этот блок и так по-умолчанию скрыт?
Потому что есть повторная обработка и отправка — можно ведь нажать кнопку дважды.
И во всех этих случаях необходимо перевести динамически обновляемые элементы в их начальное состояние.
Это кстати тоже одна из типовых задач современного жирного веб-фрейморка — ваш Angular делает нечто подобное внутри себя, не рассказывая и не показывая вам как прикладному разработчику.
Наконец последним вызовом происходит парсинг JSON — все также средствами Fetch API:
return response.json();
Как видите тут стоит ключевое слово return — в этом месте работа данного уровня обработки завершается.
И немедленно начинается следующая (а х#ле вы думали):
... }).then(record => { // if API call was successful - reset form and // add created record to list, without page reload console.log('new record:', record); document.querySelector('#newRecordForm').reset(); self.addRecordFromTemplate(document.querySelector('#messages'), record, true); }) ...
Тут record это уже объект JSON, с именованными полями и типами:
Сбрасываем форму ввода нового сообщения, используя наверное самый стандартный и скучный способ — вызов метода reset ():
document.querySelector('#newRecordForm').reset();
Затем вызываем метод, отвечающий за добавление одной записи гостевой в DOM-дерево — я уже его описывал выше, поэтому повторяться не буду.
Обратите внимание на последний аргумент — true тут означает что запись будет добавлена сверху списка записей а не в самый низ.
Разумеется я также добавил валидацию к этой форме, хотя и самую простую:
Валидация происходит на стороне сервера и при попытке отправить пустые поля — сервер вернет ошибку.
Еще неза#балисьустали читать?
Тогда спешу сообщить что это была только половина, теперь раскроем серверную сторону, отвечающую за добавление новой записи.
Добавление новой записи: бекэнд
Код, отвечающий за обработку вызова API для создания новой записи находится все в том же обработчике RestAPI, в методе handle():
... // process 'add new record' feature case "/api/add" -> { if (checkIfNonPostRequest(exchange)) return; // read input json final String req = new String(exchange.getRequestBody() .readAllBytes(), StandardCharsets.UTF_8); LOG.log(Level.FINE, "req=%s".formatted(req)); // parse it into key-value final Map<String, String> jsonParsed = Json.parseJson(req); if (debugMessages) for (Map.Entry<String, String> e : jsonParsed.entrySet()) LOG.log(Level.FINE, "k=%s v=%s" .formatted(e.getKey(), e.getValue())); // create new record final BookRecord newRecord = new BookRecord(UUID.randomUUID(), jsonParsed.getOrDefault("title", null), jsonParsed.getOrDefault("author", null), jsonParsed.getOrDefault("message", null), new Date()); if (!validateLikeJsr303(newRecord)) { respondBadRequest(exchange); return; } // store if (!storage.addRecord(newRecord)) { respondBadRequest(exchange); return; } // add to response Json.toJson(out, newRecord); } ...
Проверяем что получен именно POST-запрос, возвращаем ошибку для всех остальных типов:
if (checkIfNonPostRequest(exchange)) return;
Такой жесткий отсев обязателен — чтобы сократить количество возможных ошибок при парсинге JSON, который находится в теле запроса.
А для GET и HEAD запросов тело будет разумеется пустым.
Читаем тело запроса в виде строки в кодировке UTF-8:
final String req = new String(exchange.getRequestBody() .readAllBytes(), StandardCharsets.UTF_8);
Согласно спецификации JSON:
JSON text SHALL be encoded in UTF-8, UTF-16, or UTF-32.
The default encoding is UTF-8, and JSON texts that are encoded in UTF-8 are
interoperable in the sense that they will be read successfully by the
maximum number of implementations; there are many implementations
that cannot successfully read texts in other encodings (such as
UTF-16 and UTF-32).
Что, думали JSON поддерживает один только стандартный UTF-8? Или не знали о существовании еще UTF-16 и UTF-32 вообще?
А я предупреждал что формат JSON на самом деле куда сложнее чем кажется и считается обывателями.
Поэтому разработка полнофункционального парсера JSON, который бы полностью соответствовал спецификациям — дело сложное и небыстрое.
К счастью я про все эти вещи знаю и помню в нашем маленьком проекте используется локализация только на русский язык, все символы которого помещаются в стандартный UTF-8.
В отличие от например китайского, где для нормального отображения нужен аж UTF-32:
The use of a fixed 16-bit format limited Unicode 1.0 support to only 65,536 codepoints, which form the Basic Multilingual Plane (see below). Chinese support was added in 1992 and included 20,902 Chinese, Japanese and Korean (CJK) Unified Ideographs. This left out many Chinese characters. In 1996, a mechanism was implemented in Unicode 2.0 that removed the limitation of character code points to 16 bits, allowing the full range of Chinese characters and historic scripts to be supported. Chinese radicals were added in 1999 in Unicode 3.0. In 2001 an 42,711 additional CJK Unified Ideographs were added, which made Unicode now usable for Chinese text. In 2009 an 4,149 additional CJK Unified Ideographs, making a total of 70,000 CJK characters.
Так что имейте это ввиду, если полезете адаптировать мой код под ваши мультиязычные реалии.
Но вернемся к логике создания новой записи гостевой — нить повествования еще не утеряна?
Дальше происходит собственно парсинг JSON (как работает сам парсер — уже описано выше):
// parse it into key-value final Map<String, String> jsonParsed = Json.parseJson(req);
Затем идет достаточно тупое отображение отладочной информации:
if (debugMessages) for (Map.Entry<String, String> e : jsonParsed.entrySet()) LOG.log(Level.FINE, "k=%s v=%s" .formatted(e.getKey(), e.getValue()));
Если вы включили отладку — запустив веб-приложение с ключем:
-DappDebug=true
то в консоли будут отображаться все поля JSON-объектов (и много чего еще).
Дальше происходит создание объекта BookRecord — упрощенного аналога Entity класса из вашей любимой JPA:
final BookRecord newRecord = new BookRecord(UUID.randomUUID(), jsonParsed.getOrDefault("title", null), jsonParsed.getOrDefault("author", null), jsonParsed.getOrDefault("message", null), new Date());
Как видите в качестве primary key используется UUID, а также сразу подставляется текущая дата в поле created.
Дальше вызывается валидация, метод своим названием смело пародирует навороченную спецификацию JSR303, отвечающую за автоматическую валидацию полей в JEE-окружении:
if (!validateLikeJsr303(newRecord)) { respondBadRequest(exchange); return; }
Вот так выглядит сам метод, реализующий валидацию нашей мини-сущности:
private boolean validateLikeJsr303(BookRecord newRecord) { if (newRecord == null) return false; if (newRecord.author == null || newRecord.author.isBlank()) return false; if (newRecord.title == null || newRecord.title.isBlank()) return false; return newRecord.message != null && !newRecord.message.isBlank(); }
Тупо, просто и очень надежно, поэтому применимо в большинстве случаев где встречается хоть какая-то валидация.
А аннотации JSR303 оставьте лучше детямдля резюмеиграться.
Если запись успешно прошла валидацию — она отправляется в хранилище:
if (!storage.addRecord(newRecord)) { respondBadRequest(exchange); return; }
Как работает сохранение данных внутри я подробно описал ниже, пока же ограничимся тем фактом, что в случае ошибки сохранения сервер вернет сообщение об ошибке (в виде заголовка 400 Bad Request).
Если же сохранение отработало — сериализуем сохраненную запись в строку и отдаем клиенту:
Json.toJson(out, newRecord);
У возвращаемой записи будет виден ее ID.
Наконец последняя основная функция бизнес-логики нашей маленькой гостевой.
Удаление записи
Если вы дочитали до этого места — мои поздравления ибо терпение ваше поистину безгранично.
Начнем с того что сама кнопка удаления добавляется к шаблону записи гостевой только если пользователь авторизован.
Происходит это на серверной стороне, в момент генерации страницы шаблонизатором:
<div style="float:right;"> ${if(gb.isAuthenticated) <a href="#" id="deleteBtn" class="btn primary" confirm="${msg(gb.text.btn.delete.confirm)}"> ${msg(gb.text.btn.delete)} </a> } </div>
Т.е. если пользователь не авторизован — весь блок внутри тега с условием:
${if(gb.isAuthenticated)
..будет вырезан из ответа сервера.
такая реализация — самый лучший и самый правильный вариант с точки зрения безопасности.
В этом случае никакие части обработки логики, предназначенной для авторизированных пользователей или администраторов не попадут на клиентскую сторону просто так — по запросу анонимусов.
Что очевидно затруднит поиск уязвимостей.
Берите на вооружение, если занимаетесь разработкой прошивок для устройств с веб-интерфейсом.
Напомню что обработчик нажатия кнопки «Удалить» устанавливается в момент заполнения шаблона записи данными из JSON — этот процесс уже описан выше в статье.
Внутри этого обработчика вызывается функция deleteRecord():
deleteRecord(recordId) { console.log("removing record: ", recordId); fetch('/api/delete?' + new URLSearchParams({ id: recordId }), { method: 'POST', headers: {} }) .then((response) => { if (response.ok) { gb.loadRecords(); } }).catch(error => { console.log("error on remove record: ", error); }); }
Которая отправляет на сервер POST-запрос, с параметром url, в котором указывается уникальный ID удаляемой записи.
Теперь снова про серверную часть.
Удаление записи: бекэнд
Логика обработки запроса на удаление находится все в том же обработчике RestAPI, в методе handle():
... case "/api/delete" -> { // we allow only POST there if (checkIfNonPostRequest(exchange)) { LOG.log(Level.FINE, "bad request: not a POST method"); return; } // To remove record, user must be authenticated first, // so here we try to get session id from provided cookie header final String sessionId = getCookieValue(exchange, SESSION_KEY); // if there is no session id - respond bad request if (sessionId == null || sessionId.isBlank()) { LOG.log(Level.FINE, "bad request: no session header"); respondBadRequest(exchange); return; } // if session is not exist - respond bad request if (!sessions.isSessionExist(sessionId)) { LOG.log(Level.FINE, "bad request: session not found"); respondBadRequest(exchange); return; } // get the id of record needs to be removed final String recordId = params.getOrDefault("id", null); // if id is blank - respond bad request if (recordId == null || recordId.isBlank()) { LOG.log(Level.FINE, "bad request: recordId is not set"); respondBadRequest(exchange); return; } // try to delete record if (!storage.deleteRecord(recordId)) { LOG.log(Level.FINE, "bad request: cannot delete record: %s" .formatted(recordId)); respondBadRequest(exchange); return; } }
Как видите тут очень много проверок, начинаем разбирать.
Первым делом снова отсекаем не POST-запросы:
if (checkIfNonPostRequest(exchange)) { LOG.log(Level.FINE, "bad request: not a POST method"); return; }
По идее считается хорошим тоном использовать HTTP-метод DELETE для удаления данных, но мой печальный опыт показывает, что если хотите добиться максимальной совместимости со всем веб-зоопарком — не стоит выходить за рамки использования GET и POST запросов.
Ваши любимые «хипстерские» методы PUT, DELETE и PATCH на самом деле когда-то были частью WebDAV — специальным расширением HTTP-протокола, а не его официальной частью.
Поэтому были, есть и еще долго будут самые разнообразные устройства и сервера в сети, не умеющие их обрабатывать или проксировать.
Пытаемся получить ID сессии пользователя из заголовка Cookie:
final String sessionId = getCookieValue(exchange, SESSION_KEY);
Если этого ID нет или он поврежден — возвращаем ошибку и заканчиваем обработку:
if (sessionId == null || sessionId.isBlank()) { LOG.log(Level.FINE, "bad request: no session header"); respondBadRequest(exchange); return; }
return означает немедленный выход из метода, наличие множественных return до сих пор считается «плохой практикой» при разработке на C++ (по ныне неактуальным техническим причинам), но абсолютно никак не влияет ни на скорость ни на объем и сложность генерируемого байткода в Java.
Тем не менее, вам стоит знать, что «multiple vs single return» является темойсрачейспециальной олимпиады уже лет так 20 — изучите вопрос прежде чем попрекать меня за использование плохих практик.
Если ID сессии был указан, но сессия с таким ID не найдена или устарела - точно также отдаем ошибку и завершаем обработку:
if (!sessions.isSessionExist(sessionId)) { LOG.log(Level.FINE, "bad request: session not found"); respondBadRequest(exchange); return; }
Затем пытаемся получить ID удаляемой записи из параметров запроса:
final String recordId = params.getOrDefault("id", null); // if id is blank - respond bad request if (recordId == null || recordId.isBlank()) { LOG.log(Level.FINE, "bad request: recordId is not set"); respondBadRequest(exchange); return; }
Возвращаем ошибку и останавливаем обработку если ID записи не был задан в качестве параметра запроса.
Наконец пытаемся удалить запись:
if (!storage.deleteRecord(recordId)) { LOG.log(Level.FINE, "bad request: cannot delete record: %s" .formatted(recordId)); respondBadRequest(exchange); return; }
Если при удалении произошла ошибка (например запись с таким ID не была найдена в хранилище) — просто возвращаем ошибку клиенту, без детализации.
Как вы могли заметить — в методах API постоянно мелькает некое «хранилище записей», в следующем разделе я как раз собираюсь рассказать что это такое и как оно устроено.
Хранение и чтение данных с диска
Казалось бы в чем может быть проблема записать или считать «какой-то сраный JSON» с/на диск?
Ну мне есть чем вас удивить, потому что моя реализация это упрощенный аналог нижнего слоя работы с данными из самой настоящей СУБД.
Вы такого с JSON точно не делали, уверяю.
Вся логика работы хранилища находится во вложенном классе BookRecordStorage, класс — большой, поэтому целиком я его сюда вставлять не буду, но распишу каждую функцию.
static final int MAX_RECORDS = 100;
Да, я цинично ограничил максимальное количество записей лимитом, все же это демонстрационный проект.
Еще мы будем использовать асинхронные каналы чтения-записи:
// we use async access for more fun, have both read & write into same file. private AsynchronousFileChannel fc;
что позволяет использовать один и тот же файловый дескриптор для одновременной параллельной записи и чтения данных — в одном и том же файле и без блокировок.
Примерно также (но кратно сложнее) работают СУБД со своими файлами данных на диске.
Ну и разумеется мы будем использовать «in-memory cache» для быстрой отдачи записей:
private final Map<UUID, BookRecord> records = new TreeMap<>();
Фактически все операции с данными производятся в первую очередь в памяти а запись на диск происходит в момент добавления записи, сразу после обновления кеша.
Чтение данных с диска происходит только при запуске приложения.
Разумеется столь простая схема будет хорошо работать только с очень небольшим объемом данных — которые возможно разместить в памяти.
В случае полноценной СУБД так просто не будет и придется держать в памяти только самые часто используемые данные, постоянно подгружая и выгружая все остальные.
Инициализация хранилища запускается в методе main():
// create GB storage and load records from data file on disk final BookRecordStorage storage = notDI .getInstance(BookRecordStorage.class);
Этот класс тоже является управляемым и инстанциируется из IoC-контейнера, с автоматической подстановкой зависимостей.
Следующим шагом вызывается метод загрузки данных:
storage.load();
public synchronized void load() throws IOException { if (fc != null) throw new IllegalStateException("Already loaded!"); final File storageFile = new File("data.json"); fc = AsynchronousFileChannel.open(storageFile.toPath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.READ); LOG.log(Level.FINE, "Reading data file %s" .formatted(storageFile.getAbsolutePath())); // create some initial records, if none exist if (storageFile.length() == 0) { LOG.log(Level.FINE,"Adding initial records"); for (int i = 1; i <= 10; i++) { final BookRecord r = new BookRecord(UUID.randomUUID(), "Some title %d".formatted(i), "alex " + i, "test message " + i, new Date()); records.put(r.id, r); } final StringBuilder sb = new StringBuilder(); Json.toJson(sb, records.values()); fc.write(ByteBuffer.wrap(sb.toString().getBytes()), fc.size()); } else { LOG.log(Level.FINE,"Reading existing records"); // reads persisted data AsyncFileReaderJson.readBlocks(fc, (json) -> { // just don't add more records to memory store // if there were too many in data file if (records.size() > MAX_RECORDS) return null; final Map<String, String> jsonParsed = Json.parseJson(json); LOG.log(Level.FINE, "loaded %d values from json block: %s" .formatted(jsonParsed.size(), json)); final BookRecord newRecord = new BookRecord( UUID.fromString(jsonParsed.get("id")), jsonParsed.get("title"), jsonParsed.get("author"), jsonParsed.get("message"), new Date(Long.parseLong(jsonParsed.get("created")))); records.put(newRecord.id, newRecord); return null; }); } }
Первым делом проверяем что канал чтения-записи в файл данных уже инициализирован:
if (fc != null) throw new IllegalStateException("Already loaded!");
Если переменная, отвечающая за канал не равна нулю — подразумевается что файл с данными уже открыт, а значит метод load() уже был вызван и отработал успешно, поскольку все ошибки в его работе не ловятся а пробрасываются наверх.
Ради упрощения файл с данными сделан статичным — с одним возможным именем и расположением в каталоге запуска:
final File storageFile = new File("data.json");
Следующим шагом происходит инициализация асинхронного канала чтения-записи:
fc = AsynchronousFileChannel.open(storageFile.toPath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.READ);
Параметр StandardOpenOption.CREATE означает что файл с данными будет автоматически создан, если его нет на диске.
Дальше происходит проверка на размер файла данных — если файл пустой (размер равен 0), будут добавлены тестовые записи:
if (storageFile.length() == 0) { .. }
Сначала добавляем сгенерированные тестовые записи в in-memory cache:
for (int i = 1; i <= 10; i++) { final BookRecord r = new BookRecord(UUID.randomUUID(), "Some title %d".formatted(i), "alex " + i, "test message " + i, new Date()); records.put(r.id, r); }
Затем сериализуем все записи в одну длинную строку:
final StringBuilder sb = new StringBuilder(); Json.toJson(sb, records.values());
И записываем в файл данных одним большим массивом байт:
fc.write(ByteBuffer.wrap(sb.toString().getBytes()), fc.size());
Это эффективный метод для тестовых данных, которых не бывает слишком много.
Разумеется для сколь-нибудь больших объемов стоит реализовывать блочную запись в цикле, без сериализации в одну большую строку.
Куда более интересные вещи происходят если файл с данными не пустой — в этом случае как раз и происходит поблочное чтение:
// reads persisted data AsyncFileReaderJson.readBlocks(fc, (json) -> { // just don't add more records to memory store // if there were too many in data file if (records.size() > MAX_RECORDS) return null; final Map<String, String> jsonParsed = Json.parseJson(json); LOG.log(Level.FINE, "loaded %d values from json block: %s" .formatted(jsonParsed.size(), json)); final BookRecord newRecord = new BookRecord(UUID .fromString(jsonParsed.get("id")), jsonParsed.get("title"), jsonParsed.get("author"), jsonParsed.get("message"), new Date(Long.parseLong(jsonParsed.get("created")))); records.put(newRecord.id, newRecord); return null; });
Второй аргумент в вызове это замыкание, которое вызывается изнутри метода readBlocks для обработки распознанного блока — начинающегося с { и заканчивающегося }.
Внутри происходят вообщем-то очевидные вещи, например пропуск обработки если хранилище уже переполнено:
if (records.size() > MAX_RECORDS) return null;
final Map<String, String> jsonParsed = Json.parseJson(json);
Создание «мини-сущности» BookRecord из распознанных именованных полей:
final BookRecord newRecord = new BookRecord(UUID .fromString(jsonParsed.get("id")), jsonParsed.get("title"), jsonParsed.get("author"), jsonParsed.get("message"), new Date(Long.parseLong(jsonParsed.get("created"))));
И добавление ее в in-memory cache:
records.put(newRecord.id, newRecord);
Но разумеется это тоже еще не конец и надо лезть еще глубже.
Внутри класса BookRecordStorage, отвечающего за хранилище записей есть еще один класс AsyncFileReaderJson, который отвечает за поблочное чтение JSON-записей из асинхронного канала.
Вот так выглядит обращение к нему во время начальной загрузки:
AsyncFileReaderJson.readBlocks(fc, (json) -> { .. });
А вот так выглядит код реализации:
static class AsyncFileReaderJson { private static final int MAX_LINE_SIZE = 4096 * 2; /** * Read bytes from an {@code AsynchronousFileChannel}, which are decoded into characters * using the UTF-8 charset. * The resulting characters are parsed by line and passed to the destination buffer. * * @param asyncFile the nio associated file channel. */ static void readBlocks( AsynchronousFileChannel asyncFile, Function<String, Void> onReadJson) { readBlocks(asyncFile, 0, 0, new byte[1024],new byte[MAX_LINE_SIZE], 0, false, onReadJson); } /** * There is a recursion on `readLines()`establishing a serial order among: * `readLines()` -> `produceLine()` -> `onProduceLine()` -> `readLines()` -> and so on. * It finishes with a call to `close()`. * * @param asyncFile the nio associated file channel. * @param position current read or write position in file. * @param bufSize total bytes in buffer. * @param buffer buffer for current producing line. * @param targetBlock the transfer buffer. * @param blockPos current position in producing line. */ static void readBlocks( AsynchronousFileChannel asyncFile, long position, // current read or write position in file int bufSize, // total bytes in buffer byte[] buffer, // buffer for current producing line byte[] targetBlock, // the transfer buffer int blockPos, boolean foundStart, Function<String, Void> onReadJson) { for (int bp = 0; bp < bufSize; bp++) { if (buffer[bp] == '{') { foundStart = true; continue; } if (foundStart && buffer[bp] == '}') { produceJson(targetBlock, blockPos, onReadJson); blockPos = 0; foundStart = false; continue; } if (foundStart) targetBlock[blockPos++] = buffer[bp]; } final int lastBlockPos = blockPos; // we need a //final variable captured // in the next lambda final boolean finalFoundStart = foundStart; readBytes(asyncFile, position, buffer,buffer.length, (err, res) -> { if (err != null) return; if (res <= 0) { if (lastBlockPos > 0) produceJson(targetBlock, lastBlockPos, onReadJson); } else readBlocks(asyncFile, position + res, res, buffer, targetBlock, lastBlockPos, finalFoundStart, onReadJson); }); } /* * Asynchronous read chunk operation, callback based. */ static void readBytes( AsynchronousFileChannel asyncFile, long pos, // current read or write pos in file byte[] data, // buffer for current producing line int size, java.util.function.ObjIntConsumer<Throwable> completed) { if (completed == null) throw new RuntimeException("completed cannot be null"); if (size > data.length) size = data.length; if (size == 0) { completed.accept(null, 0); return; } asyncFile.read(ByteBuffer.wrap(data, 0, size), pos, null, new java.nio.channels.CompletionHandler<>() { @Override public void completed(Integer result, Object attachment) { completed.accept(null, result); } @Override public void failed(Throwable exc, Object attachment) { completed.accept(exc, 0); } }); } /** * This is called only from readLines() callback and performed from a background IO thread. * * @param jsonBlock the transfer buffer. * @param bpos current position in producing json block. */ private static void produceJson(byte[] jsonBlock, int bpos, Function<String, Void> onReadJson) { LOG.log(Level.FINE, "Produce json ,sz: %d" .formatted(jsonBlock.length)); onReadJson.apply(new String(jsonBlock, 0, bpos, StandardCharsets.UTF_8)); } } }
Тут стоит обратить внимание на основной цикл, в котором происходит поиск управляющих символов '{' и '}', которые являются признаками начала и конца блока с данными:
for (int bp = 0; bp < bufSize; bp++) { if (buffer[bp] == '{') { foundStart = true; continue; } if (foundStart && buffer[bp] == '}') { produceJson(targetBlock, blockPos, onReadJson); blockPos = 0; foundStart = false; continue; } if (foundStart) targetBlock[blockPos++] = buffer[bp]; }
В этом классе происходит поблочное чтение файла с записями гостевой, далее каждый блок анализируется на наличие управляющих символов.
Как только находится символ '{' — выставляется признак нахождения начала блока:
if (buffer[bp] == '{') { foundStart = true; continue; }
И происходит накопление данных в специальный буфер:
if (foundStart) targetBlock[blockPos++] = buffer[bp];
Как только находится закрывающий символ '}' — вызывается функция обработки блока:
if (foundStart && buffer[bp] == '}') { produceJson(targetBlock, blockPos, onReadJson); blockPos = 0; foundStart = false; continue; }
Внутри которой происходит создание строки в кодировке UTF-8 из собранного массива байт и вызов функтора, где и происходит дальнейшая обработка (описана выше):
private static void produceJson(byte[] jsonBlock, int bpos, Function<String, Void> onReadJson) { LOG.log(Level.FINE, "Produce json ,sz: %d" .formatted(jsonBlock.length)); onReadJson.apply(new String(jsonBlock, 0, bpos, StandardCharsets.UTF_8)); }
Теперь переходим к следующей интересной теме.
Добавление записи в хранилище
За добавление новой записи гостевой в хранилище отвечает метод addRecord():
public boolean addRecord(BookRecord record) { if (records.size() > MAX_RECORDS) return false; boolean wasEmpty = records.isEmpty(); records.put(record.id, record); final StringBuilder sb = new StringBuilder(); if (!wasEmpty) sb.append(","); Json.toJson(sb, record); sb.append("]"); try { // size -1 is required to wipe out previous ']' char return fc.write(ByteBuffer.wrap(sb.toString().getBytes()), fc.size() - 1).get() > 0; } catch (IOException | java.util.concurrent.ExecutionException | InterruptedException e) { throw new RuntimeException("Cannot add record", e); } }
Первым делом проверяется лимит на размер хранилища, если он превышен - новые записи не добавляются:
if (records.size() > MAX_RECORDS) return false;
Дальше определяем пустое сейчас in-memory хранилище или в нем уже есть записи:
boolean wasEmpty = records.isEmpty();
records.put(record.id, record);
Дальше сериализуем запись в строку и добавляем запятую если хранилище не было пустым:
final StringBuilder sb = new StringBuilder(); if (!wasEmpty) sb.append(","); Json.toJson(sb, record); sb.append("]");
Это нужно для правильного формирования структуры JSON при записи:
try { // size -1 is required to wipe out previous ']' char return fc.write(ByteBuffer.wrap(sb.toString().getBytes()), fc.size() - 1).get() > 0; } catch (IOException | java.util.concurrent.ExecutionException | InterruptedException e) { throw new RuntimeException("Cannot add record", e); }
Затираем последний символ ']' , который означает завершение массива, поскольку мы фактически добавляем запись в конец массива JSON.
Обратите внимание что запись происходит через тот же самый асинхронный канал ввода-вывода что и чтение данных.
Наконец переходим к последней операцией с данными в хранилище.
Удаление записи гостевой из хранилища
За операцию удаления отвечает метод deleteRecord() и в нем тоже есть свои сюрпризы:
public synchronized boolean deleteRecord(String uuid) { final UUID u = UUID.fromString(uuid); if (!records.containsKey(u)) return false; else records.remove(u); // For maximum simplicity, we put position at the beginning of // data file and then dump all json // Then we just truncate data file to required size // - to wipe out garbage. final StringBuilder sb = new StringBuilder(); Json.toJson(sb, records.values()); final byte[] b = sb.toString().getBytes(); fc.write(ByteBuffer.wrap(b), 0); try { return fc.truncate(b.length)!=null; } catch (IOException e) { throw new RuntimeException("Cannot truncate data file", e); } }
final UUID u = UUID.fromString(uuid);
на самом деле проверяется корректность ключа записи — что был передан именно валидный UUID.
Если это не так — будет выброшена ошибка.
Следующим шагом происходит поиск записи по ключу и ее удаление если таковая нашлась:
if (!records.containsKey(u)) return false; else records.remove(u);
Затем мы сериализуем все оставшиеся записи в строку JSON:
final StringBuilder sb = new StringBuilder(); Json.toJson(sb, records.values());
и записываем в начало файла с данными:
final byte[] b = sb.toString().getBytes(); fc.write(ByteBuffer.wrap(b), 0);
После чего просто обрезаем файл до длины строки JSON:
try { return fc.truncate(b.length)!=null; } catch (IOException e) { throw new RuntimeException("Cannot truncate data file", e); }
Таким образом обрезается мусор — остатки старых сериализованных данных.
Подобным способом можно вполне успешно работать даже с очень большими данными — заменив разовый вызов сериализации в строку на поблочное чтение и запись.
Локализация
Разве можно делать современный веб-проект только на одном языке?
В современном динамичном мире любое веб-приложение для широких масс должно иметь поддержку минимум двух языков:
английского и местного, в нашем случае — русского.
Лезть сразу в локализацию на китайский, арабский или какие-то из языков Индии не советую — можете обосраться там все сильно неоднозначно.
При этом вся платежеспособная публика из тех мест хорошо владеет английским.
Вообщем да, я тоже реализовал поддержку локализации — без фреймворков и библиотек, одной голой жопой.
Но даже столь простая реализация оказалась на деле сложным делом, скажу честно — было непросто.
Выражения в шаблоне страницы
Для начала вернемся к шаблону страницы:
<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(), который отвечает за получение текстовых сообщений из бандлов происходит с указанием выбранной локали.
Сервис работы с бандлами
Описанное выше это лишь половина — клиентская часть, которая только управляет выбором локализации.
За чтение файлов с локализованными текстами, их загрузку и чтение значений отвечает отдельный вложенный класс:
static class LocaleStorage implements Dependency { private final Map<String, Properties> locales = new HashMap<>(); private Properties mainLocale; // this is fallback bundle, // if some key not present in // specific locale - it will be // taken from here /** * Loads all bundles */ public void load() { try { mainLocale = loadProperties("/i18n/gbMessages.properties"); locales.put("ru", loadProperties("/i18n/gbMessages_ru.properties")); } catch (IOException e) { throw new RuntimeException("Cannot load i18n bundle",e); } } public List<String> getSupportedLocales() { return List.of("en", "ru"); } /** * Resolves translation for specified key * @param key * string key, like 'gb.text.login.accessDenied' * @param locale * specified locale * @return * translated string */ public String resolveKey(String key, String locale) { if (locale != null && !locale.isBlank() && locales.containsKey(locale) && locales.get(locale).containsKey(key)) return (String) locales.get(locale).get(key); return mainLocale.containsKey(key) ? (String) mainLocale.get(key) : "??%s??".formatted(key); } private Properties loadProperties(String name) throws IOException { LOG.log(Level.FINE,"load properties %s".formatted(name)); final URL u = getClass().getResource(name); if (u == null) throw new FileNotFoundException("Resource not found: %s" .formatted(name)); final Properties p = new Properties(); try (InputStream in = u.openStream()) { p.load(in); } return p; } }
Парсер булевых выражений
Наконец последняя, но крайне интересная тема данного проекта — свой собственный парсер булевых выражений.
Нужен он для того чтобы превратить сложные выражения записанные в виде строки вроде:
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, поскольку задача реализации подобного парсера является одним из домашних заданий в серьезных ВУЗах, где серьезно учат компьютерным наукам.
Эпилог
Как видите есть веские причины по которой на свете существуют готовые библиотеки и сложные фреймворки — если вы не готовы угореть по хардкору полностью кастомной разработки, то лучше все же использовать что-то готовое.
Уровень компетенций, сложность и объем разработки полностью с нуля думаю теперь стал вполне очевиден — это ни разу не накидывание готовых компонентов в уютном фреймворке.
Например я смог такое реализовать только после 20ти лет опыта разработки, еще и специально и глубоко изучая как устроена и работает каждая часть и слой современной веб-системы.
Помните об этом прежде чем садиться за разработку чего-то «с нуля» и с желанием всех переиграть.
P.S.
Я честно пытался расписать максимально подробно всю реализованную логику но видимо остались темные пятна, так что будут правки и обновления - как кода так и текста.
P.P.S
Как думаете что будет если попросить ChartGPT реализовать подобный проект?
Поскольку задача разработки с нуля не является массовой и популярной — нет большого количества доступного кода и примеров для обучения.
Так что до SkyNet еще далеко — роботы ищут компилятор.