«Голая Java» или разработка без всего
- вторник, 24 сентября 2024 г. в 00:00:09
Рассказываю что можно сделать на одном только голом JDK. Это старое и ныне почти забытое искусство разработки без внешних библиотек и фреймворков. Работать будем «как в былинные времена» — киркой и лопатой голыми руками и немного мозгом.
Disclaimer:
В нынешние интересные времена, когда один только boilerplate (шаблон проекта) может занимать на диске гигабайт, а количество библиотек в самом обычном проекте приближается к паре сотен — данная статья может нанести психическую травму неподготовленному читателю и заставить задуматься о правильности выбора профессии.
Обязательно посоветуйтесь с вашим психотерапевтом если родились после 2000х прежде чем читать дальше.
Disclaimer №2:
Конечно же я не призываю полностью отказываться от фреймворков и библиотек в рабочих проектах, данная статья — лишь демонстрация, что подобная разработка все еще вообще возможна.
Поскольку современные Java-разработчики почему-то считают, что без пары десятков библиотек Apache Commons, Spring и JPA с Hibernate разработки быть не может, а сразу за порогом любимого фреймворка начинается «страшный C++» и ходят люди с песьими головами.
Эта объемная работа предназначена в первую очередь для профессионалов разработки на Java, которые уже имеют практический опыт с большими фреймворками, так популярными в этом болоте среде и смогут в полной мере оценить всю сложность работы «без всего».
Вот такое:
Это самая обычная на первый взгляд гостевая книга — древний аналог «стены» из ВКонтакта.
Еще это веб‑приложение на Java (и немного на JavaScript), сильно упрощенный аналог самой популярной связки из Spring Boot + Thymeleaf, которые используются для современной разработки каждый день.
Но только:
без фреймворков и библиотек.
Готовый проект был по традиции выложен на GitHub.
Хранилище данных на диске
Локализация
Авторизация, роли и разграничение доступа
Добавление, просмотр и удаление записей гостевой
И все это сделано и работает на одном только JDK, без каких-либо внешних библиотек:
Без сервлетов, сервлет-контейнеров, серверов приложений и так далее.
Одна голая Java и все.
Парсер и генератор JSON
Шаблонизатор страниц
Парсер выражений (Expression Language «а‑ля рюс»)
IoC-Контейнер
Напоминаю что все это реализовано с нуля в рамках проекта, без каких-либо внешних библиотек.
Наверное прикинув сейчас размеры каких-нибудь Wildfly, Spring, Thymeleaf или еще каких монстров вы подумали что слегка устанете это все читать?
Немного успокою:
~800 строк кода, ~1200 с комментариями
70кб итоговый «бинарник»
Технически наш проект будет представлять собой встроенный HTTP‑сервер с упакованными внутрь ресурсами — как в Spring Boot. В качестве движка веб‑сервера будет использоваться «тайный» класс специального назначения com.sun.net.httpserver
, который «тайно» присутствует в JRE и JDK начиная аж с версии 1.8, а ныне вообще является официально поддерживаемым для внешнего использования.
Если вам очень сильно надо использовать устаревший или нестандартный JRE — можете взять один из форков этого сервера, который был вытащен из исходного кода JDK и очищен от всех зависимостей.
Я не стал так поступать чтобы не увеличивать размер кодовой базы демонстрационного проекта в два раза — все же обработка HTTP на голых сокетах достаточно объемна.
Упрощенная логика использования выглядит так:
import com.sun.net.httpserver.*;
import java.net.*;
import java.io.*;
public class Test {
public static void main(String[] args) throws Exception {
// создаем объект http сервера
HttpServer server = HttpServer.create(
new InetSocketAddress(8000), 0);
// добавляем контекст
server.createContext("/test", new MyHandler());
// запускаем
server.start();
}
/**
Пример обработчика.
Все настолько просто что поймут даже зумеры и дети.
*/
static class MyHandler implements HttpHandler {
/**
Вызов обработчика при совпадении контекста,
к которому он привязан.
*/
@Override
public void handle(HttpExchange t) throws IOException {
// тестовая строка
final String response = "Это тест";
// устанавливаем код 200 = ОК и размер отправляемых данных
t.sendResponseHeaders(200, response.length());
// пишем в поток вывода данные, которые отправятся пользователю.
try (OutputStream os = t.getResponseBody();) {
os.write(response.getBytes("UTF-8")); os.flush();
}
}
}
}
Можете легко собрать руками:
javac -cp . Test.java
и запустить:
java -cp . Test
Но конечно у нас в проекте все будет сложнее, поскольку есть и статичные ресурсы и специальная обработка шаблонов и еще всякие непотребства. Еще у нас будет почти настоящий REST API и некое подобие SPA:
аж целый отдельный класс на Javascript ECMA6, на котором сделан весь интерактив.
И еще один, отвечающий за авторизацию. Плюс немного CSS — для стильности и целая одна иконка. Куда же без иконки-то?
Разумеется для нормальной разработки стоит использовать какую-то внешнюю систему сборки, но поскольку мы идем путем бусидо лишений и страданий — будем использовать исключительно средства JDK и ничего больше:
javac, jar и.. все.
Я использовал достаточно свежие фичи в проекте, поэтому необходимо собирать с помощью современных версий JDK — 17 и выше.
Вот так выглядит «тру» компиляция без всего:
javac -cp ./src/main/java -d target/classes src/main/java/com/Ox08/noframeworks/FeelsLikeAServer.java
Для упрощения жизни, был написан простой shell-скрипт, повторяющий шаги сборки из обычного Apache Maven:
#!/bin/sh
# очищаем каталог сборки
rm -rf target/
# компилируем
javac -cp ./src/main/java -d target/classes src/main/java/com/Ox08/noframeworks/FeelsLikeAServer.java
# копируем ресурсы
cp -R ./src/main/resources/* target/classes/
# формируем манифест для создания исполнимого JAR-файла
echo 'Manifest-Version: 1.0' > target/manifest.mf
echo 'Main-Class: com.Ox08.noframeworks.FeelsLikeAServer' >> target/manifest.mf
# упаковываем результат сборки в JAR-файл
jar cfm target/likeAServer.jar target/manifest.mf -C target/classes .
В результате сборки появится файл likeAServer.jar в каталоге target.
Запустить собранное приложение можно следующим образом:
java -jar target/likeAServer.jar
Вот так выглядит запущенное приложение в работе:
Теперь рассказываю как оно все работает.
Все реализовано в виде одного класса с некоторой вложенностью, точкой запуска является стандартная функция:
public static void main(String[] args) {}
Только без всей этой черной магии с загрузкой классов, характерной для Spring и всех больших серверов приложений.
Вот так выглядит общая структура класса и функция запуска (без учета вложенных классов):
/**
Да, это все - один класс.
*/
public class FeelsLikeAServer {
// JUL логгер, один и общий.
private final static Logger LOG = Logger.getLogger("NOFRAMEWORKS");
// признак включения отладки
private static boolean debugMessages;
/**
Вот она - та самая дырка: точка входа в приложение. Отсюда оно запускается.
*/
public static void main(String[] args) throws IOException {
// Получить номер порта из входящих параметров, если не указан - будет 8500
// Если кто вдруг не знает, параметры указываются как -DappPort=9000
final int port = Integer.parseInt(System.getProperty("appPort", "8500"));
// проверка на включение отладочных сообщений.
debugMessages = Boolean.parseBoolean(
System.getProperty("appDebug", "false"));
// если включена отладка - делаем доп. настройку JUL логгера
// для показа FINE уровня
if (debugMessages) {
LOG.setUseParentHandlers(false);
final Handler systemOut = new ConsoleHandler();
systemOut.setLevel(Level.FINE);
LOG.addHandler(systemOut);
LOG.setLevel(Level.FINE);}
}
// создание DI контейнера
final TinyDI notDI = new TinyDI();
// инициализация - указываем все классы являющиеся зависимостями
notDI.setup(List.of(Users.class,Sessions.class,LocaleStorage.class,
BookRecordStorage.class,RestAPI.class,Expression.class,
Json.class,PageHandler.class,ResourceHandler.class));
// получение уже созданного контейнером инстанса сервиса Users
// он отвечает за работу с пользователями
final Users users = notDI.getInstance(Users.class);
// загрузка списка пользователей
users.load();
// получение инстанса сервиса с записями в гостевой
final BookRecordStorage storage = notDI.getInstance(BookRecordStorage.class);
// загрузка их с диска
storage.load();
// загрузка локализованных строк
final LocaleStorage localeStorage = notDI.getInstance(LocaleStorage.class);
localeStorage.load();
// инициализация встроенного HTTP-сервера
final HttpServer server = HttpServer.create(new InetSocketAddress(port), 50);
// подключение обработчика страниц
server.createContext("/").setHandler(notDI.getInstance(PageHandler.class));
// .. обработчика статичных ресурсов
final ResourceHandler rs = notDI.getInstance(ResourceHandler.class);
server.createContext("/static").setHandler(rs);
server.createContext("/favicon.ico").setHandler(rs);
// .. обработчика REST API
server.createContext("/api").setHandler(notDI.getInstance(RestAPI.class));
LOG.info("FeelsLikeAServer started: http://%s:%d . Press CTRL-C to stop"
.formatted(server.getAddress().getHostString(), port));
// запуск сервера
server.start();
}
..
А пока кратко разберем что тут происходит и зачем:
// создание DI контейнера
final TinyDI notDI = new TinyDI();
// инициализация - указываем все классы являющиеся зависимостями
notDI.setup(List.of(Users.class,Sessions.class,LocaleStorage.class,
BookRecordStorage.class,RestAPI.class,Expression.class,
Json.class,PageHandler.class,ResourceHandler.class));
TinyDI это отдельный вложенный класс менеджера зависимостей, в этом месте происходит его инстанциация. Затем ему передается список зависимостей — классов, которые используют друг-друга и которые необходимо связать между собой.
Дальше мы получаем уже готовые экземпляры обслуживаемых классов и делаем их дальнейшую настройку:
// получение уже созданного контейнером инстанса сервиса Users
// он отвечает за работу с пользователями
final Users users = notDI.getInstance(Users.class);
// загрузка списка пользователей
users.load();
// получение инстанса сервиса с записями в гостевой
final BookRecordStorage storage = notDI.getInstance(BookRecordStorage.class);
// загрузка их с диска
storage.load();
// загрузка локализованных текстов
final LocaleStorage localeStorage = notDI.getInstance(LocaleStorage.class);
localeStorage.load();
Метод load()
в данном случае — сильно упрощенный аналог @PostConstruct
аннотации, который вызывается вручную согласно логике работы приложения.
Дальше происходит инстанциация и настройка движка HTTP-сервера:
final HttpServer server = HttpServer.create(new InetSocketAddress(port), 50);
server.createContext("/").setHandler(notDI.getInstance(PageHandler.class));
final ResourceHandler rs = notDI.getInstance(ResourceHandler.class);
server.createContext("/static").setHandler(rs);
server.createContext("/favicon.ico").setHandler(rs);
server.createContext("/api").setHandler(notDI.getInstance(RestAPI.class));
LOG.info("FeelsLikeAServer started: http://%s:%d . Press CTRL-C to stop"
.formatted(server.getAddress().getHostString(), port));
server.start();
Выставляются обработчики контента а в последней строке происходит непосредственно запуск HTTP-сервера. Вызов метода start()
является блокирующим, поэтому на этом месте произойдет блокировка ввода.
Завершить приложение можно будет только по нажатию Ctrl-C. Или kill -9
По-умолчанию сервер запускается на порту 8500, откройте в браузере адрес:
и сможете узреть нашу гостевую:
Да, когда-то давно так начинался знаменитый Spring Framework — как контейнер для автоматического управления зависимостями:
Внедрение зависимости (англ. Dependency injection, DI) — процесс предоставления внешней зависимости программному компоненту. Является специфичной формой «инверсии управления» (англ. Inversion of control, IoC), когда она применяется к управлению зависимостями. В полном соответствии с принципом единственной ответственности объект отдаёт заботу о построении требуемых ему зависимостей внешнему, специально предназначенному для этого общему механизму[1].
Расскажу в кратце как это работает с точки зрения «пользователя» — обычного разработчика, который использует DI и IoC в своем проекте. Допустим есть классы:
class Moo {
public Moo(Zoo z, Foo f) {}
}
class Foo {
}
class Zoo {
public Zoo(Foo f) {}
}
Для того чтобы инициализировать класс Moo, содержащий зависимости от двух других классов без DI-контейнера, придется последовательно инициализировать все зависимые классы, подставляя параметры в конструкторы:
Foo f = new Foo();
Zoo z = new Zoo(f);
Moo m = new Moo(z,f);
Теперь представьте объем подобного кода для типового проекта, где каждая вставка @Autowired
или @Inject
является признаком зависимости от другого бина.
Вот для примера небольшой кусочек из примера для JHipster:
public UserService(
UserRepository userRepository,
PasswordEncoder passwordEncoder,
AuthorityRepository authorityRepository,
CacheManager cacheManager
) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.authorityRepository = authorityRepository;
this.cacheManager = cacheManager;
}
...
Чтобы не утонуть во всех этих массах однотипного говнокода и были придуманы DI-контейнеры, которые сами выстраивают цепочки зависимостей и согласно ним инициализируют классы.
Для максимальной простоты, я реализовал внедрение зависимостей исключительно через конструктор (без полей или сеттеров-геттеров), причем конструктор должен быть единственным.
Инициализация контейнера, построение дерева зависимостей и инстанциация зависимых классов — все происходит в один шаг вызовом метода:
public synchronized void setup(List<Class<?>> inputCl) {}
После этого или пан или пропан либо все зависимости инициализируются либо выбрасывается ошибка. Если загрузка прошла успешно, можно получить инстанс бина вызовом метода:
public <T> T getInstance(Class<T> clazz) {}
Да, это прямой аналог метода getBean() из ApplicationContext в Spring:
@Autowired
private ApplicationContext context;
..
SomeClass sc = (SomeClass)context.getBean(SomeClass.class);
Вот так выглядит метод инициализации целиком:
public synchronized void setup(List<Class<?>> inputCl) {
if (this.totalDeps > 0)
throw new IllegalStateException("Already initialized!");
if (inputCl == null || inputCl.isEmpty())
throw new IllegalStateException("There should be dependencies!");
// we use 0 as marker for 'no dependencies'
this.totalDeps = inputCl.size() + 1;
// build adjuction array
for (int i = 0; i < totalDeps; i++)
adj.add(new ArrayList<>());
// build classes indexes, set initial class number
this.cl = new Class[totalDeps]; this.cdc = 1;
// build dependencies tree, based on class constructor
for (Class<?> c : inputCl) {
final List<Class<?>> dependsOn = new ArrayList<>();
for (Class<?> p : c.getDeclaredConstructors()
[0].getParameterTypes())
if (Dependency.class.isAssignableFrom(p))
dependsOn.add(p);
// add class number
addClassNum(c, dependsOn);
}
// make topological sort
final int[] ans = topoSort(adj);
final List<Integer> deps = new ArrayList<>();
// put marks for 'zero-dependency',
// when class does not depend on others
for (int node : ans)
if (node > 0)
deps.add(node);
// reverse to get least depend on top
Collections.reverse(deps);
// and instantiate one by one
for (int i : deps) instantiate(cl[i]);
}
Тут происходит определение зависимых классов путем поиска аргументов у конструктора по-умолчанию:
for (Class<?> p : c.getDeclaredConstructors()[0].getParameterTypes())
if (Dependency.class.isAssignableFrom(p))
dependsOn.add(p);
..
Dependency это специальный интерфейс, который используется как маркер зависимости, все зависимые классы должны обязательно его иметь:
static class Sessions implements Dependency {
..
}
Что нужно для отделения «мух от котлет» — для понимания какие из зависимых классов являются управляемыми, а какие — нет.
Для построения дерева зависимостей используется Topological sort:
final int[] ans = topoSort(adj);
final List<Integer> deps = new ArrayList<>();
// put marks for 'zero-dependency', when class does not depend on others
for (int node : ans) if (node > 0) deps.add(node);
// reverse to get least depend on top
Collections.reverse(deps);
Вот так выглядит реализация такой сортировки:
static int[] topoSort(ArrayList<ArrayList<Integer>> adj) {
final int[] indegree = new int[adj.size()];
for (ArrayList<Integer> integers : adj)
for (int it : integers) indegree[it]++;
final Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < adj.size(); i++)
if (indegree[i] == 0)
q.add(i);
final int[] topo = new int[adj.size()];
int i = 0;
while (!q.isEmpty()) {
topo[i++] = q.remove();
for (int it : adj.get(topo[i - 1]))
if (--indegree[it] == 0)
q.add(it);
}
return topo;
}
Смысл кода выше — в том чтобы отсортировать список от менее зависимых классов к более зависимым, а количество зависимостей используется в качестве весов.
Для примера с тремя зависимыми классами Foo,Zoo и Moo выше это будет выглядеть как-то так:
Foo — 0
Zoo — 1
Moo — 2
В результате всех операций мы получаем список классов, отсортированных по количеству зависимостей и готовых к инициализации:
// and instantiate one by one
for (int i : deps)
instantiate(cl[i]);
Инстанциация класса происходит с помощью Reflection API и выглядит следующим образом :
private void instantiate(Class<?> clazz) {
if (clazz == null)
throw new IllegalStateException("Cannot create instance for null!");
LOG.log(Level.FINE, "Creating instance of %s"
.formatted(clazz.getName()));
// we just take first public constructor for simplicity
final java.lang.reflect.Constructor<?> c = clazz
.getDeclaredConstructors()[0];
final List<Object> params = new ArrayList<>();
// lookups constructor params in 'instances storage'
for (Class<?> p : c.getParameterTypes())
if (Dependency.class.isAssignableFrom(p)
&& services.containsKey(p))
params.add(services.get(p));
// try to instantiate
try {
services.put(clazz, c.newInstance(params.toArray()));
} catch (InstantiationException
| java.lang.reflect.InvocationTargetException
| IllegalAccessException e) {
throw new RuntimeException("Cannot instantiate class: %s"
.formatted(clazz.getName()), e);
}
}
Предполагается, что на момент создания класса все его зависимости уже загружены в контейнер, поэтому достаточно их вытащить по имени и подставить в вызов конструктора с использованием Reflection API.
Если инициализация прошла успешно, бин добавляется в контейнер и сам становится доступен для подключения в виде зависимости.
Весь код целиком этого мини-контейнера можно посмотреть по ссылке.
Да, все это также реализовано без каких-либо внешних библиотек и фреймворков — голыми руками. Разумеется не стоит так делать в большом коммерческом проекте, если только вы не владеете предметом и четко представляете что делаете.
Начнем с самого простого — с сессий, вот так выглядит класс для управления сессиями пользователей:
static class Sessions implements Dependency {
public static final int MAX_SESSIONS = 5,//max allowed sessions
SESSION_EXPIRE_HOURS = 8; // session expiration, in hours
private final Map<String, Session> sessions = new HashMap<>();
private final Map<String, String> registeredUsers = new HashMap<>();
..
public Session getSession(String sessionId) {
return !isSessionExist(sessionId) ? null :
sessions.get(sessionId);}
..
public boolean isSessionExist(String sessionId) {
// if there is no session registered with such id
// respond false
if (!sessions.containsKey(sessionId))
return false;
// extract session entity
final Session s = sessions.get(sessionId);
// checks for expiration time
// Logic is: [session created]...
// [now,session not expired]....
// [+8 hours]....
// [now,session expired]
if (s.created.plusHours(SESSION_EXPIRE_HOURS)
.isBefore(java.time.LocalDateTime.now())) {
LOG.log(Level.INFO,
"removing expired session: %s for user: %s"
.formatted(s.sessionId, s.user.username));
sessions.remove(sessionId); return false;
}
return true;
}
..
public synchronized String registerSessionFor(Users.User user) {
// disallow creation if max sessions limit is reached
if (registeredUsers.size() > MAX_SESSIONS)
return null;
// disallow creation if there is existing session
if (registeredUsers.containsKey(user.username))
return null;
// create new session id
final String newSessionId = UUID.randomUUID().toString();
sessions.put(newSessionId, new Session(newSessionId,
java.time.LocalDateTime.now(), user));
registeredUsers.put(user.username, newSessionId);
return newSessionId;
}
..
public synchronized boolean unregisterSession(String sessionId) {
if (!sessions.containsKey(sessionId))
return false;
registeredUsers.remove(sessions.remove(sessionId).user.username);
return true;
}
..
public record Session(String sessionId,
java.time.LocalDateTime created, Users.User user) {}
}
Как видно из самого начала класса:
public static final int MAX_SESSIONS = 5,//max allowed sessions
SESSION_EXPIRE_HOURS = 8; // session expiration, in hours
тут реализованы ограничения на количество сессий и их время жизни:
через 8 часов сессия авторизировавшегося пользователя
превратится в тыквуперестает быть валидной и удаляется.
Для хранения сессий используются два key-value связки:
private final Map<String, Session> sessions = new HashMap<>();
private final Map<String, String> registeredUsers = new HashMap<>();
В первой находятся сами сессии, с ключом в виде уникального ID, во второй находится связка между логином пользователя и ID сессии — для того чтобы вытаскивать сессию по логину пользователя.
Чтобы не реализовывать отдельную логику для проверки устаревания и удаления устаревших сессий, все это происходит непосредственно в методе проверки существования сессии:
public boolean isSessionExist(String sessionId) {
// if there is no session registered with such id
// respond false
if (!sessions.containsKey(sessionId))
return false;
// extract session entity
final Session s = sessions.get(sessionId);
// Checks for expiration time
// Logic is:
// [session created]...
// [now,session not expired]....
// [+8 hours]....[now,session expired]
if (s.created.plusHours(SESSION_EXPIRE_HOURS)
.isBefore(java.time.LocalDateTime.now())) {
LOG.log(Level.INFO,
"removing expired session: %s for user: %s"
.formatted(s.sessionId, s.user.username));
sessions.remove(sessionId);
return false;
}
return true;
}
Вот так выглядит регистрация новой сессии для пользователя:
public synchronized String registerSessionFor(Users.User user) {
// disallow creation if max sessions limit is reached
if (registeredUsers.size() > MAX_SESSIONS)
return null;
// disallow creation if there is existing session
if (registeredUsers.containsKey(user.username))
return null;
// create new session id
final String newSessionId = UUID.randomUUID().toString();
sessions.put(newSessionId, new Session(newSessionId,
java.time.LocalDateTime.now(), user));
registeredUsers.put(user.username, newSessionId);
return newSessionId;
}
Заодно в этом методе происходит проверка на количество допустимых сессий и если этот лимит превышен — регистрации не произойдет. И проверка на повторную регистрацию — чтобы не было затирания предыдущей сессии.
Для упрощения реализации, возврат null из этой функции означает ошибку, если же регистрация прошла успешно — вернется ID сессии.
Теперь переходим к пользователям, за работу с которыми отвечает другой вложенный класс:
static class Users implements Dependency {
private final Map<String, User> users = new TreeMap<>();
..
public void load() {
addUser(new User("admin", "admin", "Administrator", true));
addUser(new User("alex", "alex", "Alex", false));
}
..
public boolean isUserExists(String username) {
return username != null && !username.isBlank()
&& users.containsKey(username);
}
..
public User getUserByUsername(String username) {
return users.getOrDefault(username, null);
}
..
public void addUser(User user) { users.put(user.username(), user); }
..
public record User(String username,
String password,
String name, boolean isAdmin) {}
}
Этот класс — упрощенный аналог UserDetailsService из Spring Security, совмещенный с репозиторием для хранения записей о пользователях. Как видите все пользователи зашиты в код:
public void load() {
addUser(new User("admin", "admin", "Administrator", true));
addUser(new User("alex", "alex", "Alex", false));
}
Это было сделано для упрощения реализации, но ничего не мешает вставить в этом месте чтение из JSON/XML/СУБД лишь чуть усложнив логику. Также ради упрощения я реализовал разделение ролей админа и обычного пользователя одним булевым признаком isAdmin:
public record User(String username,
String password, String name, boolean isAdmin) {}
Авторизация работает путем формирования на стороне браузера JSON с полями логина и пароля, c последующей отправкой этого JSON на сервер POST‑запросом с помощью асинхронного API — все как в больших проектах на SPA.
Далее сервер обрабатывает POST-запрос, парсит JSON, вытаскивает введенные пользователем логин с паролем и проверяет.
Если учетные данные совпали — сервер создает сессию, выставляет авторизационный Cookie отдельным заголовком и возвращает url для перехода после авторизации. Если нет — сервер возвращает ошибку, которая отображается в браузере (см. скриншот выше)
Такая реализация близка к современным веб‑системам, построенным по модели SPA и позволяет определенный интерактив: например отображение сообщения об ошибке происходит без перезагрузки страницы.
Очень надеюсь на адекватность читающих — что вы не воспримете описанное как руководство к действию и никогда не опуститесь до подобной самопальной реализации парсера JSON в боевом проекте.
Не надо так делать. Никогда.
Чтобы вам там ни казалось, формат JSON — сложный, не стоит браться за реализацию своего парсера с нуля если у вас недостаточно опыта или времени. Все описанное — лишь демонстрация что подобное вообще возможно, причем оставаясь в рамках минимально возможного объема кода.
Опишу все ограничения, чтобы вы «не раскатывали губу» заранее:
Нет поддержки вложенности
Ручная сериализация, без рефлексии — по заранее определенным полям
Нет типов - все поля обрабатываются как строка
Нет обработки массивов при парсинге
Фактически вся обработка сводится к разбору вот таких примитивов:
{
"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;
}
}
Замечу что ключи должны быть уникальными, поскольку такая реализация парсера дубли просто затрет. Но для нашей упрощенной реализации это допустимо.
Теперь разберем процесс сериализации в строку, он состоит из нескольких уровней, на самом низком это выглядит вот так:
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":"Дооо дооо дооооо дооооо"
Следующий уровень это последовательные вызовы данного метода для всех полей объекта:
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":"Дооо дооо дооооо дооооо"
}]
Но едем дальше, на очереди следущая интересная тема.
«Чад кутежа во славу самопала» был бы неполным без своей реализации шаблонизатора — упрощенного аналога Thymeleaf, разумеется с крайне ограниченным функционалом.
В качестве шаблонов используются HTML-файлы со специальными управляющими блоками внутри — по прямой аналогии с Thymeleaf.
Расскажу сначала как это выглядит со стороны самих шаблонов.
Классика шаблонизации страниц — разделение на один или несколько общих шаблонов, с последующей подстановкой внутрь именованных частей, заданных на каждой отдельной странице. Именно такой подход мы и будем использовать.
Вот так выглядит общий шаблон:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
..
</head>
<body class="c">
<div class="row" >
<b class="col">
<!-- inject section 'header' below -->
${inject(header)}
</b>
</div>
...
</body>
</html>
Cо стороны страницы использование родительского шаблона активируется специальным тегом:
<!-- instruct to use main template -->
${template(template/main.html)}
А так задаются данные для подстановки в именованную секцию:
<!-- the 'header' section -->
${section(header)
<h4>${msg(gb.text.login.title)}</h4>
}
В результате при формировании страницы login.html
будет взят шаблон template/main.html
, в котором вместо ${inject(header)} будет подстановка текстового блока из login.html
:
<h4>${msg(gb.text.login.title)}</h4>
Но перед этим еще произойдет препроцессинг — блок ${msg (gb.text.login.title)}
будет заменен на строку из локализованного бандла:
gb.text.login.title=Please authenticate
Итог работы выглядит следующим образом:
<h4>Please authenticate</h4>
Ну разве не чудо?
Наш самостийный и краснознаменный шаблонизатор поддерживает подстановку локализованных текстовых сообщений из бандлов:
<div class="6 col">
<label for="titleInput">${msg(gb.text.newmessage.title)}</label>
<input type="text" class="card w-100"
id="titleInput"
placeholder="${msg(gb.text.newmessage.title.placeholder)}"/>
</div>
Тег ${msg(gb.text.newmessage.title)}
является указанием на использование подстановки локализованного текстового значения из бандла.
Разумеется шаблонизатор ограниченно поддерживает глобальные переменные:
<span style="padding-right:0.5em;">${msg(user.name)}</span>
В данном случае будет подставлено имя текущего пользователя, если он был авторизован.
Наконец наверное самое веселое — поддержка выражений, разумеется также сильно ограниченная:
${if(url eq /login.html)
<a class="btn"
href="/">${msg(gb.text.login.btn.back)}</a>
}
Для этого был реализован аж целый мини-движок для разбора логики сложных булевых выражений:
true && ( false || ( false && true ) )
Но вместо true/false будет подстановка вычисленных значений, типа такого:
${if(!gb.isAuthenticated)
<a class="btn" href="/login.html">${msg(gb.text.login)}</a>
}
Начну с самого начала, тут происходит установка обработчика, отвечающего за выдачу страниц:
final HttpServer server = HttpServer.create(new InetSocketAddress(port), 50);
// setup page handler and bind it to /
server.createContext("/").setHandler(notDI.getInstance(PageHandler.class));
Поскольку мы имеем дело с встроенным и максимально упрощенным HTTP-сервером (это вам не Jetty), всю логику — аналог сервлетов необходимо помещать в специальные обработчики, реализующие интерфейс HttpHandler:
class MyHandler implements HttpHandler {
public void handle(HttpExchange t) throws IOException {
InputStream is = t.getRequestBody();
read(is); // .. read the request body
String response = "This is the response";
t.sendResponseHeaders(200, response.length());
OutputStream os = t.getResponseBody();
os.write(response.getBytes());
os.close();
}
}
Полный код обработчика для отдачи страниц с шаблонизатором можно посмотреть по ссылке. Ниже я по шагам разберу как он работает.
Во-первых сам обработчик имеет зависимости, поэтому его жизненный цикл управляется IoC-контейнером:
PageHandler(Sessions sessions, Expression expr) {}
Бины Sessions (отвечает за сессии пользователей) и Expression (за вычисляемые выражения) инициируются до нашего обработчика и затем подставляются в конструктор.
Дальше происходит чтение главного шаблона из ресурсов приложения:
templates.put("template/main.html",
new String(
getResource("/static/html/template/main.html")));
Данные шаблона добавляются в key-value хранилище, в качестве ключа используется путь, который указывается в теге $template:
<!-- instruct to use main template -->
${template(template/main.html)}
Затем загружаются сами страницы:
resources.put("/index.html",
new StaticResource(
getResource("/static/html/index.html"), "text/html"));
resources.put("/login.html",
new StaticResource(
getResource("/static/html/login.html"), "text/html"));
и помещаются в другое хранилище, где ключем является URL страницы, по которому она доступна пользователям, например: /login.html
На этом процесс инициализации обработчика страниц заканчивается, остальная логика находится уже в методе обработки, вызываемом на каждый входящий HTTP-запрос:
@Override
public void handle(HttpExchange exchange) throws IOException { .. }
Первым делом выполняется проверка и очистка URL, взятая из HTTP-запроса:
String url = getUrl(exchange.getRequestURI());
Метод getUrl()
находится в классе AbstractHandler
, и отвечает за проверку на пустоту и начальную очистку строки запроса:
protected String getUrl(URI u) {
return (u != null ? u.getPath() : "").toLowerCase().trim();
}
Перевод в нижний регистр нужно для последующего сравнения с доступными страницами, регистрация которых выполняется в нижнем регистре.
Дальше происходит получение «сырых» данных шаблона по URL:
final StaticResource resource = resources.get(url);
Поскольку одинаковый механизм используется как для статичных файлов так и для шаблонов - необходима проверка на тип MIME, чтобы отсеять файлы не являющиеся шаблонами или страницами:
if (!"text/html".equals(resource.mime)) {
respondData(exchange, resource.data);
return;
}
Следующим шагом создается «рантайм» для шаблонизатора — HashMap, в который помещаются все ресурсы, доступные из шаблона:
// build rendering runtime
final TypedHashMap<String, Object> runtime = new TypedHashMap<>();
Добавляются ссылки на все доступные шаблоны:
// put all available templates to let expression parser found them
runtime.put(Expression.ALL_TEMPLATES_KEY,templates);
Добавляется выбранный язык или язык по-умолчанию, а также текущий URL страницы:
// put current language and current page url
runtime.put("lang", lang == null || lang.isBlank() ? "en" : lang);
runtime.put("url",url);
Добавляется признак авторизации пользователя:
// check if user session exist
final boolean sessionExist = sessions.isSessionExist(sessionId);
LOG.info("got session: %s exist? %s".formatted(sessionId, sessionExist));
runtime.put("gb.isAuthenticated", sessionExist);
Напомню как выглядит его использование из шаблона:
${if(gb.isAuthenticated)
<a href="#" id="deleteBtn"
class="btn primary"
confirm="${msg(gb.text.btn.delete.confirm)}">
${msg(gb.text.btn.delete)}
</a>
}
Далее в окружение шаблонизатора добавляется информация о текущем пользователе:
// put current user's name to been displayed in top of page
if (sessionExist)
runtime.put("user.name", sessions.getSession(sessionId).user.name);
Наконец мы подходим к самой генерации страницы, поскольку она сложная и могут быть ошибки в шаблонах — вся логика обернута в блок try-catch
:
try {
final String source = new String(resource.data);
expr.parseTemplate(source, runtime,
(line)-> expr.buildTemplate(line.expr,line.runtime));
final String merged = runtime.containsKey(Expression.PAGE_TEMPLATE_KEY) ?
expr.mergeTemplate(runtime.getTyped(Expression.PAGE_TEMPLATE_KEY,null),
runtime) : source;
respondData(exchange, expr.parseTemplate(merged, runtime,
(line)-> expr.parseExpr(line.expr,line.runtime))
.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
LOG.log(Level.WARNING,
"Cannot parse template: %s".formatted(e.getMessage()), e);
respondBadRequest(exchange);
}
Теперь рассмотрим каждый шаг генерации шаблона, первый важный шаг это связывание всех частей шаблона в единый HTML:
expr.parseTemplate(source, runtime,
(line)-> expr.buildTemplate(line.expr,line.runtime));
Причем третий аргумент это на самом деле замыкание, внутри которого вызывается метод подстановки в строке:
(line)-> expr.buildTemplate(line.expr,line.runtime)
Следующим шагом запускаем обработку всех выражений:
respondData(exchange, expr.parseTemplate(merged, runtime,
(line)-> expr.parseExpr(line.expr,line.runtime))
.getBytes(StandardCharsets.UTF_8));
Метод parseTemplate()
в котором происходит связывание частей в единый HTML оказался слишком объемным для цитирования, поэтому целиком его можно посмотреть по ссылке.
В нем происходит последовательное и посимвольное чтение шаблона, где внутри цикла происходит поиск и выборка всех подстановок вида
${..}
В момент определения выражения — когда последовательно считались символы '$', '{', внутренний блок и завершающий символ '}', происходит вызов функции обработки, переданной в качестве аргумента:
out.append(onReadExpr.apply(new Line(expr.toString(), runtime)));
Внутри происходит вызов функции buildTemplate():
(line)-> expr.buildTemplate(line.expr,line.runtime)
В результате работы этой функции, происходит вычленение секций и заполнение рантайма данными из каждой секции.
На следующем шаге эти данные подставляются в готовый шаблон.
Скажу сразу — на самом деле это лишь очень простое подобие RESTful.
Все отличие данного обработчика от отвечающего за шаблонизатор лишь в том что для входящих и исходящих данных используется JSON.
Нет подстановки именованных параметров из url (вроде «/api/records/get/<id>»), нет обработки HEAD, PUT и DELETE запросов — ничего не мешает все это добавить разумеется, но увеличит объем кода.
Поэтому я ограничился самым минимумом функцонала.
Которого как ни странно вполне хватает для управляющего ПО вашего роутера, например.
Вот так выглядит сокращенный исходный код обработчика (убрана только логика обработки методов внутри case — она описана отдельно по каждому логическому блоку):
static class RestAPI
extends AbstractHandler implements HttpHandler, Dependency {
private final BookRecordStorage storage;
private final Users users;
private final Sessions sessions;
private final LocaleStorage localeStorage;
RestAPI(BookRecordStorage storage,
Users users,
Sessions sessions, LocaleStorage localeStorage) {
this.storage = storage;
this.localeStorage = localeStorage;
this.users = users;
this.sessions = sessions;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
// extract url
final String url = getUrl(exchange.getRequestURI()),
query = exchange.getRequestURI().getQuery();
// extract url params
final Map<String, String> params = query != null
&& !query.trim().isBlank() ?
parseParams(exchange.getRequestURI().getQuery()) :
Collections.emptyMap();
// for output json
final StringBuilder out = new StringBuilder();
// we use simple case-switch with end urls
switch (url) {
// respond list of records
case "/api/records" -> {
..
..
}
}
respondData(exchange, out.toString()
.getBytes(StandardCharsets.UTF_8));
}
Помимо уже описанного выше метода getUrl(), который нужен для очистки входяшего урла, тут есть еще парсинг и заполнение «key-value» хранилища параметрами HTTP-запроса:
// extract url params
final Map<String, String> params = query != null
&& !query.trim().isBlank() ?
parseParams(exchange.getRequestURI().getQuery()) :
Collections.emptyMap();
Вот как происходит разбор параметров, указанных в урле HTTP-запроса:
static Map<String, String> parseParams(String query) {
return Arrays.stream(query.split("&"))
.map(pair -> pair.split("=", 2))
.collect(java.util.stream.Collectors
.toMap(pair ->
URLDecoder.decode(pair[0], StandardCharsets.UTF_8),
pair -> pair.length > 1 ?
URLDecoder.decode(pair[1], StandardCharsets.UTF_8) :
"")
);
}
Теперь вы тоже знаете откуда сервлет достает для вас параметры HTTP-запроса.
Подход очень даже рабочий.
Разве можно делать современный веб-проект только на одном языке? Ведь в современном динамичном мире любое веб-приложение для широких масс должно иметь поддержку минимум двух языков:
английского и «местного», в нашем случае — русского.
Поэтому я тоже реализовал поддержку локализации — без фреймворков и библиотек.
Рассказываю как оно работает.
Для начала вернемся к шаблону страницы:
<div class="row">
<label for="messageInput">${msg(gb.text.newmessage.message)}</label>
<textarea class="card w-100" id="messageInput"
rows="3"
placeholder="${msg(gb.text.newmessage.message.placeholder)}">
</textarea>
</div>
Это блок (div) отвечающий за отрисовку формы ввода сообщения:
Как видите вместо слова «Сообщение» и строки «Однажды в студеную зимнюю пору.» в шаблоне указаны только специальные теги с выражениями внутри:
${msg(gb.text.newmessage.message)}
и:
${msg(gb.text.newmessage.message.placeholder)}
Эти выражения обрабатываются парсером при работе шаблонизатора и происходит подстановка — вместо выражения вставляется текстовое значение из .properties-файла, взятое по ключу:
gb.text.newmessage.message=Сообщение
gb.text.newmessage.message.placeholder=Однажды в студеную зимнюю пору..
Файлов .properties несколько, с постфиксами, соответствующими локали:
Выбираются они в зависимости от выбранной пользователем локали.
Выбор локали осуществляется кнопками интерфейса:
По нажатию на которые происходит вызов обработчика:
document.querySelector('#selectEn')
.addEventListener('click', (e) => {
e.preventDefault();
gb.changeLang('en');
});
Который выполняет POST-запрос с выбранной локалью на сервер:
changeLang(lang) {
console.log("change lang to: ", lang);
fetch('/api/locale?' + new URLSearchParams({ lang: lang }),
{ method: 'POST', headers: {} }).then((response) => {
// support for redirection
if (response.redirected) {
location.href = response.url;
}
}).catch(error => {
console.log("error on lang select: ", error);
});
}
В ответе сервера в случае успешного вызова будет редирект — он нужен для того чтобы перезагрузить страницу с уже другой локализацией.
Вот так выглядит обработка запроса на смену локали со стороны сервера:
..
case "/api/locale" -> {
if (!params.containsKey("lang")) {
LOG.log(Level.FINE,
"bad request: no 'lang' parameter");
respondBadRequest(exchange);
return;
}
String lang = params.get("lang");
if (lang == null || lang.isBlank()) {
LOG.log(Level.FINE,
"bad request: 'lang' parameter is empty");
respondBadRequest(exchange);
return;
}
lang = lang.toLowerCase().trim();
if (!localeStorage.getSupportedLocales()
.contains(lang)) {
LOG.log(Level.FINE,
"bad request: unsupported locale: %s"
.formatted(lang));
respondBadRequest(exchange);
return;
}
exchange.getResponseHeaders()
.add("Set-Cookie", "%s=%s; Path=/; Secure; HttpOnly"
.formatted(LANG_KEY, lang));
respondRedirect(exchange, "/index.html");
LOG.log(Level.FINE, "changed lang to: %s"
.formatted(lang));
return;
}
..
Обратите внимание на установку заголовка Set-Cookie — с его помощью сохраняется выбранный пользователем язык, который при следущих запросах передается на сервер.
На стороне сервера в методе обработчика страниц PageHandler.handle() происходит получение выбранного пользователем языка из заголовка Cookie:
lang = getCookieValue(exchange, LANG_KEY);
Если он пуст или не был задан — выбирается английская локаль в качестве значения по-умолчанию:
// put current language and current page url
runtime.put("lang", lang == null || lang.isBlank() ? "en" : lang);
Дальше она устанавливается в рантайм шаблонизатора — т. е. значение локали становится доступно как из самого шаблона так и из логики его обработки, в которой происходит чтение значений из бандла:
...
if (expr.startsWith("msg(")) {
// extract variable name from expression block
String data = expr.substring("msg(".length());
data = data.substring(0, data.indexOf(")"));
LOG.log(Level.FINE, "key: '%s'".formatted(data));
/*
* We support 2 cases:
* 1) direct substitution from provided key-value map
* 2) attempt to get value from i18n bundle
*/
return runtime.containsKey(data) ?
runtime.get(data).toString() :
localeStorage.resolveKey(data, (String) runtime.get("lang"));
}
Как видите вызов метода resolveKey(), который отвечает за получение текстовых сообщений из бандлов происходит с указанием выбранной локали.
Наконец последняя, но крайне интересная тема данного проекта — свой собственный парсер булевых выражений.
Нужен он для того чтобы превратить сложные выражения записанные в виде строки вроде:
String s = "true && ( false || ( false && true ) )";
В одно булевое значение true или false. Это и есть очень простой аналог Expression Language, вернее одной из его ключевых частей. Идея реализации была взята отсюда, затем переработана.
Вот так выглядит лексическое выражение:
expression = factor { "||" factor }
factor = term { "&&" term }
term = [ "!" ] element
element = "T" | "F" | "(" expression ")"
Вот так парсер запускается:
ConditionalParser c =new ConditionalParser(s);
boolean result = c.evaluate();
Как видите на каждое выражение порождается свой экземпляр парсера — это нужно из-за использования рекурсии в реализации:
private static class ConditionalParser {
private final String s;
int index = 0;
ConditionalParser(String src) {
this.s = src;
}
private boolean match(String expect) {
while (index < s.length()
&& Character.isWhitespace(s.charAt(index)))
++index;
if (index >= s.length())
return false;
if (s.startsWith(expect, index)) {
index += expect.length();
return true;
}
return false;
}
private boolean element() {
if (match(Boolean.TRUE.toString()))
return true;
if (match(Boolean.FALSE.toString()))
return false;
if (match("(")) {
boolean result = expression();
if (!match(")"))
throw new RuntimeException("')' expected");
return result;
} else
throw new RuntimeException("unknown token found: %s"
.formatted(s));
}
private boolean term() {
return match("!") != element();
}
private boolean factor() {
boolean result = term();
while (match("&&"))
result &= term();
return result;
}
private boolean expression() {
boolean result = factor();
while (match("||"))
result |= factor();
return result;
}
public boolean evaluate() {
final boolean result = expression();
if (index < s.length())
throw new RuntimeException(
"extra string '%s'"
.formatted(s.substring(index)));
else
return result;
}
}
}
Кстати таких проектов достаточно много на Github, поскольку задача реализации подобного парсера является одним из домашних заданий в ВУЗах, где серьезно учат компьютерным наукам.
Как видите есть веские причины по которым на свете существуют готовые библиотеки и сложные фреймворки — если вы не готовы угореть по хардкору к сложностям полностью кастомной разработки, то лучше все же использовать что-то готовое.
Сложность и объем разработки «полностью с нуля» думаю теперь стал для многих читателей вполне очевиден — это ни разу не накидывание готовых компонентов в уютном фреймворке.
Помните об этом прежде чем садиться за разработку чего-то «с нуля» и с желанием всех переиграть.
К сожалению редактор статьей Хабра не выдерживает таких объемов текста и подвисает даже на урезанной версии, поэтому полную версию статьи (в два раза больше) с описанием всего реализованного функционала вы можете найти в нашем блоге.
Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.
Оживляем давно умершее, чиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.