Рассказываю что можно сделать на одном только голом JDK. Это старое и ныне почти забытое искусство разработки без внешних библиотек и фреймворков. Работать будем «как в былинные времена» — киркой и лопатой голыми руками и немного мозгом.
Disclaimer:
В нынешние интересные времена, когда один только boilerplate (шаблон проекта) может занимать на диске гигабайт, а количество библиотек в самом обычном проекте приближается к паре сотен — данная статья может нанести психическую травму неподготовленному читателю и заставить задуматься о правильности выбора профессии.
Обязательно посоветуйтесь с вашим психотерапевтом если родились после 2000х прежде чем читать дальше.
Disclaimer №2:
Конечно же я не призываю полностью отказываться от фреймворков и библиотек в рабочих проектах, данная статья — лишь демонстрация, что подобная разработка все еще вообще возможна.
Поскольку современные Java-разработчики почему-то считают, что без пары десятков библиотек Apache Commons, Spring и JPA с Hibernate разработки быть не может, а сразу за порогом любимого фреймворка начинается «страшный C++» и ходят люди с песьими головами.
Disclamier №3:
Эта объемная работа предназначена в первую очередь для профессионалов разработки на Java, которые уже имеют практический опыт с большими фреймворками, так популярными в этом болоте среде и смогут в полной мере оценить всю сложность работы «без всего».
Что будем делать
Вот такое:
Это самая обычная на первый взгляд гостевая книга — древний аналог «стены» из ВКонтакта.
Еще это веб‑приложение на Java (и немного на JavaScript), сильно упрощенный аналог самой популярной связки из Spring Boot + Thymeleaf, которые используются для современной разработки каждый день.
Но только:
без фреймворков и библиотек.
Готовый проект был по традиции выложен на GitHub.
Фичи
Хранилище данных на диске
Локализация
Авторизация, роли и разграничение доступа
Добавление, просмотр и удаление записей гостевой
И все это сделано и работает на одном только JDK, без каких-либо внешних библиотек:
Без сервлетов, сервлет-контейнеров, серверов приложений и так далее.
Одна голая Java и все.
Технические фичи
Парсер и генератор JSON
Шаблонизатор страниц
Парсер выражений (Expression Language «а‑ля рюс»)
IoC-Контейнер
Напоминаю что все это реализовано с нуля в рамках проекта, без каких-либо внешних библиотек.
Наверное прикинув сейчас размеры каких-нибудь Wildfly, Spring, Thymeleaf или еще каких монстров вы подумали что слегка устанете это все читать?
Немного успокою:
~800 строк кода, ~1200 с комментариями
70кб итоговый «бинарник»
Технически наш проект будет представлять собой встроенный HTTP‑сервер с упакованными внутрь ресурсами — как в Spring Boot. В качестве движка веб‑сервера будет использоваться «тайный» класс специального назначения com.sun.net.httpserver
, который «тайно» присутствует в JRE и JDK начиная аж с версии 1.8, а ныне вообще является официально поддерживаемым для внешнего использования.
Если вам очень сильно надо использовать устаревший или нестандартный JRE — можете взять один из форков этого сервера, который был вытащен из исходного кода JDK и очищен от всех зависимостей.
Я не стал так поступать чтобы не увеличивать размер кодовой базы демонстрационного проекта в два раза — все же обработка HTTP на голых сокетах достаточно объемна.
Упрощенная логика использования выглядит так:
import com.sun.net.httpserver.*;
import java.net.*;
import java.io.*;
public class Test {
public static void main(String[] args) throws Exception {
// создаем объект http сервера
HttpServer server = HttpServer.create(
new InetSocketAddress(8000), 0);
// добавляем контекст
server.createContext("/test", new MyHandler());
// запускаем
server.start();
}
/**
Пример обработчика.
Все настолько просто что поймут даже зумеры и дети.
*/
static class MyHandler implements HttpHandler {
/**
Вызов обработчика при совпадении контекста,
к которому он привязан.
*/
@Override
public void handle(HttpExchange t) throws IOException {
// тестовая строка
final String response = "Это тест";
// устанавливаем код 200 = ОК и размер отправляемых данных
t.sendResponseHeaders(200, response.length());
// пишем в поток вывода данные, которые отправятся пользователю.
try (OutputStream os = t.getResponseBody();) {
os.write(response.getBytes("UTF-8")); os.flush();
}
}
}
}
Можете легко собрать руками:
javac -cp . Test.java
и запустить:
java -cp . Test
Но конечно у нас в проекте все будет сложнее, поскольку есть и статичные ресурсы и специальная обработка шаблонов и еще всякие непотребства. Еще у нас будет почти настоящий REST API и некое подобие SPA:
аж целый отдельный класс на Javascript ECMA6, на котором сделан весь интерактив.
И еще один, отвечающий за авторизацию. Плюс немного CSS — для стильности и целая одна иконка. Куда же без иконки-то?
Сборка
Разумеется для нормальной разработки стоит использовать какую-то внешнюю систему сборки, но поскольку мы идем путем бусидо лишений и страданий — будем использовать исключительно средства 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 в боевом проекте.
Не надо так делать. Никогда.
Чтобы вам там ни казалось, формат JSON — сложный, не стоит браться за реализацию своего парсера с нуля если у вас недостаточно опыта или времени. Все описанное — лишь демонстрация что подобное вообще возможно, причем оставаясь в рамках минимально возможного объема кода.
Опишу все ограничения, чтобы вы «не раскатывали губу» заранее:
Нет поддержки вложенности
Ручная сериализация, без рефлексии — по заранее определенным полям
Нет типов - все поля обрабатываются как строка
Нет обработки массивов при парсинге
Фактически вся обработка сводится к разбору вот таких примитивов:
{
"id":"98e64df2-d2b5-4997-bedb-75ada485ea62",
"title":"Some title 9",
"author":"alex 9",
"created":"1675173817790",
"message":"test message 9"
}
и превращению полученных данных в Map с полями «ключ-значение».
Код полной реализации, как парсера так и сериализации в строку:
static class Json implements Dependency {
final static Pattern PATTERN_JSON = Pattern
.compile("\"([^\"]+)\":\"*([^,^}\"]+)", Pattern.CASE_INSENSITIVE);
/**
* That's how we do it: parse JSON as grandpa!
* No nested objects allowed.
*
* @param json json string
* @return key-value map parsed from json string
*/
public static Map<String, String> parseJson(String json) {
// yep, we just parse JSON with pattern and
// extract keys and values
final java.util.regex.Matcher matcher = PATTERN_JSON.matcher(json);
// output map
final Map<String, String> params = new HashMap<>();
// loop over all matches
while (matcher.find()) {
String key = null, value = null;
// skip first match group (0 index) ,
// because it responds whole text
for (int i = 1; i <= matcher.groupCount(); i++) {
// First match will be key, second - value
// So we need to read them one by one
final String g = matcher.group(i);
if (key != null)
value = g;
else
key = g;
LOG.log(Level.FINE, "key=%s value=%s g=%s"
.formatted(key, value, g));
if (key != null && value != null) {
params.put(key, value);
key = null;
value = null;
}
}
}
return params;
}
public static void toJson(StringBuilder out,
Collection<BookRecord> records) {
// yep, we build json manually
out.append("[");
boolean first = true;
// build list of objects
for (BookRecord r : records) {
if (first)
first = false;
else
out.append(",");
Json.toJson(out, r);
}
out.append("]");
}
/**
* Build JSON string from BookRecord object
*/
public static void toJson(StringBuilder out, BookRecord r) {
out.append("{\n");
toJson(out, "id", r.id, true);
toJson(out, "title", r.title, true);
toJson(out, "author", r.author, true);
toJson(out, "created", r.created.getTime(), true);
toJson(out, "message", r.message, false);
out.append("}");
}
/**
* Build JSON string with key-value pair
*/
public static void toJson(StringBuilder sb,
String key, Object value, boolean next) {
sb.append("\"")
.append(key)
.append("\":\"")
.append(value)
.append("\"");
if (next)
sb.append(",");
sb.append("\n");
}
}
Теперь разберем особенности реализации.
Парсинг JSON
Начнем с функции разбора JSON:
public static Map<String, String> parseJson(String json) { .. }
Для простоты реализации, весь JSON разбирается одним регулярным выражением:
final static Pattern PATTERN_JSON = Pattern
.compile("\"([^\"]+)\":\"*([^,^}\"]+)", Pattern.CASE_INSENSITIVE);
Вызывается парсер регулярных выражений:
final java.util.regex.Matcher matcher = PATTERN_JSON.matcher(json);
и запускается цикл по найденным блокам:
while (matcher.find()) { .. }
Внутри находится еще один цикл, в котором происходит перебор найденных пар ключ-значение:
String key = null, value = null;
// skip first match group (0 index) ,
// because it responds whole text
for (int i = 1; i <= matcher.groupCount(); i++) {
// First match will be key, second - value
// So we need to read them one by one
final String g = matcher.group(i);
if (key != null)
value = g;
else
key = g;
LOG.log(Level.FINE, "key=%s value=%s g=%s"
.formatted(key, value, g));
if (key != null && value != null) {
params.put(key, value);
key = null;
value = null;
}
}
Замечу что ключи должны быть уникальными, поскольку такая реализация парсера дубли просто затрет. Но для нашей упрощенной реализации это допустимо.
Сериализация JSON
Теперь разберем процесс сериализации в строку, он состоит из нескольких уровней, на самом низком это выглядит вот так:
public static void toJson(StringBuilder sb,
String key, Object value, boolean next) {
sb.append("\"")
.append(key)
.append("\":\"")
.append(value)
.append("\"");
if (next)
sb.append(",");
sb.append("\n");
}
В результате работы этой функции будет сформирована одна пара ключ-значение в формате JSON:
"message":"Дооо дооо дооооо дооооо"
Следующий уровень это последовательные вызовы данного метода для всех полей объекта:
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)
В результате работы этой функции, происходит вычленение секций и заполнение рантайма данными из каждой секции.
На следующем шаге эти данные подставляются в готовый шаблон.
REST API
Скажу сразу — на самом деле это лишь очень простое подобие RESTful.
Все отличие данного обработчика от отвечающего за шаблонизатор лишь в том что для входящих и исходящих данных используется JSON.
Нет подстановки именованных параметров из url (вроде «/api/records/get/<id>»), нет обработки HEAD, PUT и DELETE запросов — ничего не мешает все это добавить разумеется, но увеличит объем кода.
Поэтому я ограничился самым минимумом функцонала.
Которого как ни странно вполне хватает для управляющего ПО вашего роутера, например.
Вот так выглядит сокращенный исходный код обработчика (убрана только логика обработки методов внутри case — она описана отдельно по каждому логическому блоку):
static class RestAPI
extends AbstractHandler implements HttpHandler, Dependency {
private final BookRecordStorage storage;
private final Users users;
private final Sessions sessions;
private final LocaleStorage localeStorage;
RestAPI(BookRecordStorage storage,
Users users,
Sessions sessions, LocaleStorage localeStorage) {
this.storage = storage;
this.localeStorage = localeStorage;
this.users = users;
this.sessions = sessions;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
// extract url
final String url = getUrl(exchange.getRequestURI()),
query = exchange.getRequestURI().getQuery();
// extract url params
final Map<String, String> params = query != null
&& !query.trim().isBlank() ?
parseParams(exchange.getRequestURI().getQuery()) :
Collections.emptyMap();
// for output json
final StringBuilder out = new StringBuilder();
// we use simple case-switch with end urls
switch (url) {
// respond list of records
case "/api/records" -> {
..
..
}
}
respondData(exchange, out.toString()
.getBytes(StandardCharsets.UTF_8));
}
Помимо уже описанного выше метода getUrl(), который нужен для очистки входяшего урла, тут есть еще парсинг и заполнение «key-value» хранилища параметрами HTTP-запроса:
// extract url params
final Map<String, String> params = query != null
&& !query.trim().isBlank() ?
parseParams(exchange.getRequestURI().getQuery()) :
Collections.emptyMap();
Вот как происходит разбор параметров, указанных в урле HTTP-запроса:
static Map<String, String> parseParams(String query) {
return Arrays.stream(query.split("&"))
.map(pair -> pair.split("=", 2))
.collect(java.util.stream.Collectors
.toMap(pair ->
URLDecoder.decode(pair[0], StandardCharsets.UTF_8),
pair -> pair.length > 1 ?
URLDecoder.decode(pair[1], StandardCharsets.UTF_8) :
"")
);
}
Теперь вы тоже знаете откуда сервлет достает для вас параметры HTTP-запроса.
Подход очень даже рабочий.
Локализация
Разве можно делать современный веб-проект только на одном языке? Ведь в современном динамичном мире любое веб-приложение для широких масс должно иметь поддержку минимум двух языков:
английского и «местного», в нашем случае — русского.
Поэтому я тоже реализовал поддержку локализации — без фреймворков и библиотек.
Рассказываю как оно работает.
Выражения в шаблоне страницы
Для начала вернемся к шаблону страницы:
<div class="row">
<label for="messageInput">${msg(gb.text.newmessage.message)}</label>
<textarea class="card w-100" id="messageInput"
rows="3"
placeholder="${msg(gb.text.newmessage.message.placeholder)}">
</textarea>
</div>
Это блок (div) отвечающий за отрисовку формы ввода сообщения:
Как видите вместо слова «Сообщение» и строки «Однажды в студеную зимнюю пору.» в шаблоне указаны только специальные теги с выражениями внутри:
${msg(gb.text.newmessage.message)}
и:
${msg(gb.text.newmessage.message.placeholder)}
Эти выражения обрабатываются парсером при работе шаблонизатора и происходит подстановка — вместо выражения вставляется текстовое значение из .properties-файла, взятое по ключу:
gb.text.newmessage.message=Сообщение
gb.text.newmessage.message.placeholder=Однажды в студеную зимнюю пору..
Файлов .properties несколько, с постфиксами, соответствующими локали:
Выбираются они в зависимости от выбранной пользователем локали.
Интерфейс
Выбор локали осуществляется кнопками интерфейса:
По нажатию на которые происходит вызов обработчика:
document.querySelector('#selectEn')
.addEventListener('click', (e) => {
e.preventDefault();
gb.changeLang('en');
});
Который выполняет POST-запрос с выбранной локалью на сервер:
changeLang(lang) {
console.log("change lang to: ", lang);
fetch('/api/locale?' + new URLSearchParams({ lang: lang }),
{ method: 'POST', headers: {} }).then((response) => {
// support for redirection
if (response.redirected) {
location.href = response.url;
}
}).catch(error => {
console.log("error on lang select: ", error);
});
}
В ответе сервера в случае успешного вызова будет редирект — он нужен для того чтобы перезагрузить страницу с уже другой локализацией.
API бекэнда
Вот так выглядит обработка запроса на смену локали со стороны сервера:
..
case "/api/locale" -> {
if (!params.containsKey("lang")) {
LOG.log(Level.FINE,
"bad request: no 'lang' parameter");
respondBadRequest(exchange);
return;
}
String lang = params.get("lang");
if (lang == null || lang.isBlank()) {
LOG.log(Level.FINE,
"bad request: 'lang' parameter is empty");
respondBadRequest(exchange);
return;
}
lang = lang.toLowerCase().trim();
if (!localeStorage.getSupportedLocales()
.contains(lang)) {
LOG.log(Level.FINE,
"bad request: unsupported locale: %s"
.formatted(lang));
respondBadRequest(exchange);
return;
}
exchange.getResponseHeaders()
.add("Set-Cookie", "%s=%s; Path=/; Secure; HttpOnly"
.formatted(LANG_KEY, lang));
respondRedirect(exchange, "/index.html");
LOG.log(Level.FINE, "changed lang to: %s"
.formatted(lang));
return;
}
..
Обратите внимание на установку заголовка Set-Cookie — с его помощью сохраняется выбранный пользователем язык, который при следущих запросах передается на сервер.
На стороне сервера в методе обработчика страниц PageHandler.handle() происходит получение выбранного пользователем языка из заголовка Cookie:
lang = getCookieValue(exchange, LANG_KEY);
Если он пуст или не был задан — выбирается английская локаль в качестве значения по-умолчанию:
// put current language and current page url
runtime.put("lang", lang == null || lang.isBlank() ? "en" : lang);
Дальше она устанавливается в рантайм шаблонизатора — т. е. значение локали становится доступно как из самого шаблона так и из логики его обработки, в которой происходит чтение значений из бандла:
...
if (expr.startsWith("msg(")) {
// extract variable name from expression block
String data = expr.substring("msg(".length());
data = data.substring(0, data.indexOf(")"));
LOG.log(Level.FINE, "key: '%s'".formatted(data));
/*
* We support 2 cases:
* 1) direct substitution from provided key-value map
* 2) attempt to get value from i18n bundle
*/
return runtime.containsKey(data) ?
runtime.get(data).toString() :
localeStorage.resolveKey(data, (String) runtime.get("lang"));
}
Как видите вызов метода resolveKey(), который отвечает за получение текстовых сообщений из бандлов происходит с указанием выбранной локали.
Парсер булевых выражений
Наконец последняя, но крайне интересная тема данного проекта — свой собственный парсер булевых выражений.
Нужен он для того чтобы превратить сложные выражения записанные в виде строки вроде:
String s = "true && ( false || ( false && true ) )";
В одно булевое значение true или false. Это и есть очень простой аналог Expression Language, вернее одной из его ключевых частей. Идея реализации была взята отсюда, затем переработана.
Вот так выглядит лексическое выражение:
expression = factor { "||" factor }
factor = term { "&&" term }
term = [ "!" ] element
element = "T" | "F" | "(" expression ")"
Вот так парсер запускается:
ConditionalParser c =new ConditionalParser(s);
boolean result = c.evaluate();
Как видите на каждое выражение порождается свой экземпляр парсера — это нужно из-за использования рекурсии в реализации:
private static class ConditionalParser {
private final String s;
int index = 0;
ConditionalParser(String src) {
this.s = src;
}
private boolean match(String expect) {
while (index < s.length()
&& Character.isWhitespace(s.charAt(index)))
++index;
if (index >= s.length())
return false;
if (s.startsWith(expect, index)) {
index += expect.length();
return true;
}
return false;
}
private boolean element() {
if (match(Boolean.TRUE.toString()))
return true;
if (match(Boolean.FALSE.toString()))
return false;
if (match("(")) {
boolean result = expression();
if (!match(")"))
throw new RuntimeException("')' expected");
return result;
} else
throw new RuntimeException("unknown token found: %s"
.formatted(s));
}
private boolean term() {
return match("!") != element();
}
private boolean factor() {
boolean result = term();
while (match("&&"))
result &= term();
return result;
}
private boolean expression() {
boolean result = factor();
while (match("||"))
result |= factor();
return result;
}
public boolean evaluate() {
final boolean result = expression();
if (index < s.length())
throw new RuntimeException(
"extra string '%s'"
.formatted(s.substring(index)));
else
return result;
}
}
}
Кстати таких проектов достаточно много на Github, поскольку задача реализации подобного парсера является одним из домашних заданий в ВУЗах, где серьезно учат компьютерным наукам.
Эпилог
Как видите есть веские причины по которым на свете существуют готовые библиотеки и сложные фреймворки — если вы не готовы угореть по хардкору к сложностям полностью кастомной разработки, то лучше все же использовать что-то готовое.
Сложность и объем разработки «полностью с нуля» думаю теперь стал для многих читателей вполне очевиден — это ни разу не накидывание готовых компонентов в уютном фреймворке.
Помните об этом прежде чем садиться за разработку чего-то «с нуля» и с желанием всех переиграть.
P.S.
К сожалению редактор статьей Хабра не выдерживает таких объемов текста и подвисает даже на урезанной версии, поэтому полную версию статьи (в два раза больше) с описанием всего реализованного функционала вы можете найти в нашем блоге.
0x08 Software
Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.
Оживляем давно умершее, чиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.
Комментарии (92)
hssergey
21.09.2024 11:28+3Ну фактически вы написали свой собственный фреймворк: менеджер зависимостей, шаблонизатор, поддержка REST, самописная система сборки (шелл-скрипт) и так далее. И в качестве примера реализовали на нем форму логина.
Насколько это нужно и востребовано - сложно сказать. С одной стороны, в образовательных целях штука очень ценная - самостоятельно разработать все то мрачное колдунство, что под капотом у больших фреймворков и понять, что не боги горшки обжигают. А с другой стороны - вы же пилите какие-то коммерческие решения. И задействовав свой собственный фреймворк вы привязываете к нему заказчика - любые доработки своими силами или смена команды обойдутся гораздо дороже, чем когда используется что-то распространенное...
alex0x08 Автор
21.09.2024 11:28+6а нем форму логина
вы невнимательно читали - в 700 строк уместился конечный проект гостевой, где авторизация лишь одна из фич.
alex0x08 Автор
21.09.2024 11:28Да ктстати про:
свой собственный фреймворк
«фреймворк» — от фразы «frame of work», что в дословном переводе означает «направление работы».
Смысл существования любого фреймворка — дать возможность реализации конечного функционала со своей помощью в строго заданном направлении.
Поэтому выход за рамки направления работ у используемых фреймворков является самым проблемным местом в современной разработке — просто потому что
Баба Ягасам фреймворк против таких действий.То что я реализовал в рамках статьи является не фреймворком а конечным приложением, без каких либо внешних фреймворков и библиотек вообще — в этом и был смысл: показать чего стоит реализация бизнес‑логики «голыми руками».
siarheiblr
21.09.2024 11:28+16Framework переводится как «каркас» или «остов». Структура, на которую полезная нагрузка навешивается
vasyakolobok77
21.09.2024 11:28+7Это интересный проект для джуна, чтобы проверить свои силы и знания.
Но раз мы делимся кодом с окружающими, то давайте делиться качественным кодом.
Позвольте отметить несколько моментов, дабы новички не приняли на веру.Общие замечание.
Многие вещи стоило унести в утилиты. Например, если даже мы используем jul, то можно было бы написать адаптер для удобного использования.
Форматирование кода просто нечитаемое. Но это скорее вкусовщина.
debugMessages = Boolean.parseBoolean(...);
тут можно и нужно унести в static final, раз сделали ее shared переменнойTinyDI notDI = new TinyDI();
notDI.setup(...)
если мы делаем свой IoC контейнер, может сделать более удобный API использования?
есть тысяча способов сделать это, у Жени Борисова есть Live-coding доклад, где он за пару часов созадет IoC контейнер.int[] topoSort(ArrayList> adj)
не нужно привязываться к реализации коллекцийMap sessions = new HashMap<>();
public Session getSession
public synchronized String registerSessionFor
в многопоточном коде все операции должны быть под одним монитором.
но проще конечно же здесь и в других местах использовать конкурентные коллекции.static void toJson(StringBuilder out, BookRecord r)
написать общий сериализатор в JSON дело часа-двух.
забыли про экранирование спецсимволов в toJson.if (match(Boolean.TRUE.toString()))
вместо сравнения со строковой константой каждый раз дергаем метод.OutputStream os = t.getResponseBody();
os.close();
try with resources был бы правильным решением.new String(getResource("/static/html/template/main.html"))
а какая кодировка будет использоваться при разборе байт в строку?alex0x08 Автор
21.09.2024 11:28Слушайте, это все конечно круто и замечательно, но вы разбираете код, основной задачей которого является минимально возможная реализация и отсутствие внешних зависимостей, поэтому идеи из серии:
не нужно привязываться к реализации коллекций
или:
то можно было бы написать адаптер для удобного использования.
мягко говоря - неактуальны.
Вы бы еще исходники какого-нибудь демо под Амигу взяли и начали описывать как можно код улучшить с точки зрения обычной корпоративной разработки, ей-богу.
vasyakolobok77
21.09.2024 11:28+7Вы смешали теплое с мягким. Идея написать приложение без внешних зависимостей никак не зависит от правил хорошего тона при разработке. Сама по себе себе интересная, реализация немного хромает.
К слову даже PoC можно за полчаса наговнякать так, что сам не разберешься через месяц, а можно постараться написать так, что код будет хорошо читаем. Это все приходит с опытом.
alex0x08 Автор
21.09.2024 11:28Это все приходит с опытом
Вы серьезно считаете что у автора есть проблемы с опытом?
Fen1kz
21.09.2024 11:28+1Слушайте, это все конечно круто и замечательно, но вы разбираете код, основной задачей которого является минимально возможная реализация и отсутствие внешних зависимостей, поэтому идеи из серии
Ой, началось.
Тогда и тезис меняйте на:
"данная статья — лишь демонстрация, что минимальная возможная реализация функционала без библиотек ещё возможна."А то я смотрю на основе минимальной реализации мы здесь развеселились, "если родились позже 2000х идите к психологу, ко-ко-ко"
r3d
21.09.2024 11:28К сожалению многие сениоры не поймут, что написано в этом проекте. Вы очень сильно переоцениваете тех, кто пришел в разработку за последние 5 лет.
alex0x08 Автор
21.09.2024 11:28+2Ну нет, сеньоры с 5-7 лет опыта как были "рабочими проектными лошадками" 10 лет назад так и остались. И врядли это изменится в будущем.
Не все разумеется, но именно из таких формируются команды разработки.
Вот там где 10+ стажа уже начинаются сложности: необучаемость, бесконечные закидоны, требования и придирки.
Ни разу не видел успешных команд (кроме своей) собранных только из ветеранов, чтобы там все не передрались и пересрались до взаимной ненависти за первые полгода работы, вот честно.
ionicman
21.09.2024 11:28+1Не совсем по теме статьи - судя по прическе и удивленным галазм на первой картинке Эльза в кожанке, можно ссылку на wallpaper? )))
MountainGoat
21.09.2024 11:28+3Раз и туда вкинуть два и получайте сколько хотите, хоть в кожанке, хоть без кожанки. Работает даже без видеокарты, наверное по полчаса на картинку, но работает.
alex0x08 Автор
21.09.2024 11:28Безумно плюсую, поскольку устал от
малолетнихспециалистов, в ультимативной форме требующих от меня ссылку на обои под каждой технической статьей.anaxita
21.09.2024 11:28+5можно ссылку на wallpaper? )))
Ого, а как вы поняли что это спросил малолетний специалист и что указало и на ультимативное требование? Скобки?
alex0x08 Автор
21.09.2024 11:28Потому что взрослые специалисты интересуются живыми женщинами а не нарисованными на картинке и тем более не требуют «дать линк» под технической статьей.
Nvart
21.09.2024 11:28+4Восхищен тем, как вы по-взрослому, твёрдо и четко, записали в малолетки всех мужчин старше 18, интересующихся аниме, мультипликацией и артом.
GospodinKolhoznik
21.09.2024 11:28+2import com.sun.net.httpserver.*;
Но ведь com.sun.net.httpserver это собственно и есть http сервер (который в свою очередь просто обёртка над либой, для работы с сокетами java.net.InetSocketAddress). Ну да, оно встроено в джаву по умолчанию, т.к. джаву делали с явным прицелом на веб разработку. И насколько я понимаю, сейчас это уже негласный стандарт, встраивать в любой новый ЯП веб сервер, превращая тем самым язык в том числе и в веб фреймворк.
Ну а имея на руках http сервер, уже не сложно отправлять респонсы на реквесты. Ведь всю основную работу выполняет InetSocketAddress - в нём сила, брат!
alex0x08 Автор
21.09.2024 11:28, уже не сложно отправлять респонсы на реквесты.
Получается все было зря? О боже мой.
Ладно, в следующей раз будет статья про реализацию HTTP-сервера на чистом ассемблере.
GospodinKolhoznik
21.09.2024 11:28+4Вовсе нет. Просто подача немного сбила с толку, после слов голая джава ожидаешь реализацию только на стандартных языковых операциях, а не на стандартном веб фреймворке ))
А так, конечно не зря. В принципе чуть доработать и можете в виде книги оформить и продавать. Так в принципе и делают - есть ряд книг, которые учат делать веб приложения на произвольном ЯП с парой-тройкой страниц и формочкой силами либо встроенного в язык http сервера либо сторонней либой. И ничего - людям нравится, читают, потом пишут хорошие отзывы. У вас как раз формат приложения подходящий - 700 строк кода целиком влезут в книгу с подробными комментариями каждого действия.
alex0x08 Автор
21.09.2024 11:28До формата книги я пока не дорос, несмотря на глобальность опыта все же считаю что нужно нечто большее чем просто компетенции для такого.
Но вообще у меня достаточно специфическое отношение к технической литературе - большинство таких книг считаю
унылым говномслишком уж скучными и пафосными для излагаемой темы.
avost
21.09.2024 11:28Вовсе нет. Просто подача немного сбила с толку, после слов голая джава ожидаешь реализацию только на стандартных языковых операциях, а не на стандартном веб фреймворке
Вы имеете в виду вообще без использования явского рантайма? Хотел бы я посмотреть на этот цирк с конями... Хотя, нет, не хотел бы - бессмысленность такой затеи поразительна сама по себе. ;)
alex0x08 Автор
21.09.2024 11:28+2Если вам принципиально, то достаточно давно существует утилита jpackage, поставляемая в составе JDK, с помощью которой можно генерировать готовые запускабельные бинарники с упакованным внутрь Java-приложением.
Проект из статьи запросто превращается в запускаемый бинарник размером ~ 3Мб без зависимости от JDK/JRE в системе.
К сожалению эта интересная технология немного опоздала и в нынешних реалиях не особо кому нужна, поэтому акцентировать на такой возможности в статье не стал.
avost
21.09.2024 11:28Если вам принципиально
Мне - нет. Это мой собеседник чего-то такого ожидал. ;)
давно существует утилита jpackage,
Она всего лишь упаковывает рантайм в бинарник, выкидывая лишнее.
К сожалению эта интересная технология немного опоздала и в нынешних реалиях не особо кому нужна
Да, она и раньше не особо была нужна. Такие бинари давно можно было делать сторонними утилитами.
alex0x08 Автор
21.09.2024 11:28Такие бинари давно можно было делать сторонними утилитами.
И получались либо обертки, судорожно ищущие установленный на машине JRE либо распаковщики этого самого JRE при запуске. Либо JRE рядом .exe клали.
Нормального варианта создать полноценное нативное приложение на Java до jpackage не было.
avost
21.09.2024 11:28Либо JRE рядом .exe клали.
Смешно. Вы сами-то хоть раз использовали этот jpackage?
Нормального варианта создать полноценное нативное приложение на Java до jpackage не было.
А что, как вы полагаете, делает jpackage? :)
Думаете, он прямо компилирует байткод в нативный? :)А я вам скажу что он делает НА САМОМ ДЕЛЕ ))
Он создаёт ИНСТАЛЛЯТОР приложения. Да, инсталлятор действительно нативный. И этот инсталлятор устанавливает в вашу систему нативную jre, jar-ник вашего приложения, jar-ники используемых либ и небольшой нативный бинарник-запускалку всего этого добра-бобра :). Вот, только что собрал стандартный шаблонный хелловордовый fx проектик который идея предлагает шаблоном. Инсталлятор получился размером в 55 мегабайт (ни о каких 3 мегабайтах не может быть и речи). Я его установил, посмотрел что он там установил - почти нормальная такая jre - благодаря модульности он её, конечно, почикал. Тыкнул в программулину и... она не заработала :). Ну, это fx, там надо было либы fx-овские подкладывать - лень разбираться. Но работает это вот так, а не так, как вы вообразили. Я же говорю - такого добра и раньше было в достатке и оно никогда не было особо востребованным.alex0x08 Автор
21.09.2024 11:28Вообще-то помимо инсталлятора создается еще и сам запускаемый рантайм (сюрприз) путем нарезания JRE, вот пример под мак (обратите внимание на каталог runtime).
За создание этого рантайма отвечает другая утилита jlink, которую jpackage вызывает при работе.
~3мб достигается если выкинуть практически все модули, особенно модуль
java.desktop (он самый большой).
Но скажу сразу, что для более-менее крупных приложений такое урезание невозможно, поскольку в модуль java.desktop запихана часть javax.el (Expression Language) и потому никакие Tomcat/Jetty на таком обрезанном рантайме не заработают.
Поэтому если вам действительно надо генерировать бинарник из настоящего Java-приложения, то стоит использовать GraalVM, который действительно его создает и честно падает с "page fault" при ошибках.
Mcublog
21.09.2024 11:28+2Читал вас ещё на другом ресурсе посвященным линуксу. Рад, что вы продолжаете свое дело. Всегда читаю с интересом ваши статьи, спасибо.
alex0x08 Автор
21.09.2024 11:28+2Спасибо за теплые слова, к сожалению владелец «другого ресурса» оказался на другой стороне, поэтому посчитал невозможным дальнейшую поддержку проекта.
PastorGL
21.09.2024 11:28+3Нормально.
Считаю, что собрать себе минимальный app container из стандартных запчастей с капелькой рефлекшена — это вполне валидная задача для случаев, когда тянуть спринг с его монстрообразными зависимостями нецелесообразно. Я и сам два раза писал такой маленький DI с нуля на голой жабе для рестоподобных сервисов, и ещё один раз допиливал чужой, — которые потом вполне успешно использовались в проде по нескольку лет.
Писать собственный шаблонизатор — чуть более спорная затея. Для этого на свете есть достаточно удобные и при этом тонкие либы. А для HTTP сервиса всё-таки стоит брать jetty, встроенный совсем уж убогий.
Но в целом вполне зачёт. Полезное упражнение.
alex0x08 Автор
21.09.2024 11:28+1Писать собственный шаблонизатор — чуть более спорная затея.
Ага а собственная реализация недопарсера JSON — прям божья благодать ) Весь этот проект целиком — не более чем упражение, применять такие подходы в реальном проекте стоит только если вы очень хорошо разбираетесь в теме.
PastorGL
21.09.2024 11:28+1Написание потокового парсера для какого-нибудь простого регулярного формата вполне валидная бизнесовая задача. Мне в своё время пришлось писать парсер для .po, например. Потому что нормальной легковесной либы не нашлось :) Или вот быстрый glob pattern парсер в текущем проекте оказался нужен, а сторонние все или слишком перегружены, или не умеют в полный синтаксис. Взял и написал.
Потренироваться на json, чтобы не пугали потом такие задачи, весьма неплохо.
alex0x08 Автор
21.09.2024 11:28+1Разумеется я не ставлю под сомнение, что в некоторых случаях даже «самостийная и краснознаменная реализация» JSON — оправдана (если ваша компания называется «Яндекс» например).
Но это должна быть полноценная и согласованная сверху разработка — с тестами и релизами, не подпольная партизанщина в корпоративном проекте ради строчки в резюме.
PastorGL
21.09.2024 11:28В яндексе процветает NIH, поэтому там несомненно есть собственные либы для парсинга всего, что только им нужно парсить. С перламутровыми пуговицами.
Однако, и в более мелких конторах иногда приходится велосипедить с такими вещами, причём, по совершенно неочевидным причинам, типа неподходящей лицензии у либы. С таким я тоже сталкивался. Не все кто «сверху» к этому бывают морально готовы...
alex0x08 Автор
21.09.2024 11:28+1В яндексе процветает NIH
Яндекс хотя-бы идет на такой шаг осознанно и может себе такое позволить.
и в более мелких конторах иногда приходится велосипедить с такими вещами
Видите ли путь библиотеки (например парсера JSON) на реализации только начинается. Самое важное — что будет потом: огромное количество тестов, кейсы использования, примеры реализации, замеры производительности и выпуск релизов.
Как только библиотекой начинают пользоваться — 90% работы уходит на тесты и сопровождение. Как бы вы не старались, такой огромный масштаб работы спрятать не получится, поэтому ситуация когда:
Не все кто «сверху» к этому бывают морально готовы...
имеет все шансы закончиться для вас крайне печальным образом.
PastorGL
21.09.2024 11:28Вы сейчас рассуждаете так, как будто каждой внутренней либой будет пользоваться мильён человек за пределами конторы. Нет, не будут. На то либа и внутренняя, часто специфичная для проекта, её почти никто не увидит, и вряд ли когда-либо переиспользует. Это нормально.
Отдельными тестами на такую мелочь обычно не заморачивается, потому что интеграционные покрывают все кейсы. И это тоже нормально.
Более того. Частенько бывает, что для разбора какого-нибудь микроформата используется код, выдернутый из подходящего опенсорсного проекта, и обрезанный до нужного feature scope. Чтобы не тянуть лишнего, просто берём и копируем нужные классы к себе, без обвеса, который за пределами необходимого.
alex0x08 Автор
21.09.2024 11:28как будто каждой внутренней либой будет пользоваться мильён человек за пределами конторы
Смысл сложных тестов — в максимальном покрытии функционала, если такое не делать для библиотеки, реализующей кодек или парсер есть неиллюзорный шанс словить проблему в неожиданном месте уже клиентского кода и не вовремя.
В очередной раз приведу пример с поддержкой юникода в JSON на уровне стандарта (а не только UTF-8, как все почему-то думают).
Если вы реализовываете свой парсер JSON и не делаете поддержку юникода (или делаете неправильно), то рискуете словить ошибки обработки данных уже в клиентском коде, причем в работающей системе — на проде вам в API прилетит такой JSON и привет.
используется код, выдернутый из подходящего опенсорсного проекта, и обрезанный до нужного feature scope.
Опасное это дело, поскольку тем самым вы берете на себя ответственность за всю дальнейшую поддержку такого кода — его уже не получится обновить просто обновив версию библиотеки.
Также на ваши плечи ложится ответственность за все уязвимости, найденные в оригинальной версии — патчи для их исправления вам придется собирать и адаптировать вручную.
«Что дозволено Юпитеру — не дозволено Быку», не надо слепо повторять за Яндексом и Гуглом, не имея их бюджетов.
PastorGL
21.09.2024 11:28Если бы вы знали, как на заводе делается колбаса, вы бы не стали её есть (с)
Ваша наивность и, прямо скажем, некоторый максимализм несколько удивляют. Зачем вообще вы написали эту статью тогда? Из принципа «Дети, никогда так не делайте?», что ли? Ну так дети и не будут.
alex0x08 Автор
21.09.2024 11:28Если бы вы знали, как на заводе делается колбаса, вы бы не стали её есть (с)
Именно по этой причине я не пишу софт для медицинских устройств )
Ваша наивность и, прямо скажем, некоторый максимализм несколько удивляют.
Вас смутило описанное про ответственность? Ну так это реальность, а не максимализм.
Если берете в свой проект чужой код — берете и ответственность за его дальнейшую поддержку своими силами.
Или вы как-то по другому на это смотрите?
PastorGL
21.09.2024 11:28Я знаю, что делаю, так также почему именно так, и уж тем более зачем. И могу это всё доступно объяснить бизнесу. Но у меня и опыта 25 лет, и роль на проекте «техлид»... Собсно, моя задача и состоит в определении набора либ, стека технологий, и проч. Если я решу, что в каком-то месте лучше воткнуть самописный велосипед, там будет воткнут такой велосипед. Если увижу, что взять готовое решение оптимальнее (что и происходит в 90% случаев), то так оно и будет.
samizdam
21.09.2024 11:28+1Тоже пришёл к тому, что тащить современные фреймворки в небольшой новый проект, или легаси с самописом, с которого надо слезть, избыточно.
Да, я из другого стека, но сути это не особо меняет. Свой di через рефлексию покрывающий 90% кейсов реализовал в 2017 для php, в 2019 для python. ORM свою писать в общем случае для проектов такого масштаба не стоит, и для остальных задач в любом мэйнстримовом языке есть куча пакетов реализующих всё необходимое (роутинг, шаблонизация, i18n), и фреймворк только ради di тащить будто лишнее ограничение архитектуры. Тем более если пакеты брать реализующие jsr / psr, чтобы их можно было легко заменить позже.
alex0x08 Автор
21.09.2024 11:28Тоже пришёл к тому, что тащить современные фреймворки в небольшой новый проект, или легаси с самописом, с которого надо слезть, избыточно.
Прежде чем приходить к такому интересному выводу, рекомендую ознакомиться с проектом OWASP и посмотреть сколько новых уязвимостей сейчас выкладывают в паблик за один день.
Дальше думаю стоит оценить риски, собственные таланты и объем свободного времени — действительно ли вы готовы вкладываться в поиск уязвимостей и защиту.
И не стоит думать, что раз ваш сайт или проект не особо известны и популярны то никому не нужны — нужны в первую очередь доступные ресурсы на сервере (например для майнинга), а взлом ныне автоматический, с помощью роботов.
Проект в статье если что тестовый, в тексте специально написано несколько раз:
«не стоит так делать в реальном проекте».
Написано как раз потому что простота обманчива — существенный объем кода уходит на решения проблем с безопасностью и разумеется в 700 строк я бы просто не уложился.
samizdam
21.09.2024 11:28сколько новых уязвимостей сейчас выкладывают в паблик за один день
Чем больше зависимостей и компонентов в системе - тем шире вектор атаки.
Простота может быть обманчива, а сложность убивает и требует ресурсов на поддержку, снижает возможность контроля. Плюс фреймворки создают вендор-лок и порой заставляют писать бизнес-логику специфичным для них образом, мешая тестированию (не уверен как в java с этим, но в моих тех.стеках частая картина).
Я не говорю что фреймворки и библиотеки зло и их нельзя использовать. Как по мне статья хорошо иллюстрирует что npm left-pad incident от лени и невежества. Простое локальное решение банальной задачи, может быть эффективнее импортированного и оправдано. Для разработчика владеющего языком и мозгом, а не только фреймворком.
Я считаю большой проблемой, что приведённый пример "тестового приложения" для большинства современных разработчиков прям хардкор-хардкор. Ощущение, что если дать им аналоговую отвёртку вместо шуруповёрта, то окажется что они не помнят в какую сторону крутить надо)
idd451289
21.09.2024 11:28+2Как по мне, если у вас такое приложение что тянуть спринг не имеет смысла, то можно и ручками все разрешить, и не городить ад с рефлексией и прочим
PastorGL
21.09.2024 11:28Спринг вообще в 80% случаев оверкилл, который берут чисто по привычке.
БТВ, мне почему-то частенько попадаются кейсы, когда необходима динамическая подгрузка плагинов из класспаса, которую без минимального DI реализовывать попросту неудобно. Приложение при этом не обязательно сильно развесистое.
alex0x08 Автор
21.09.2024 11:28Спринг вообще в 80% случаев оверкилл, который берут чисто по привычке.
Понимаю что речь про современные вебсервисы, отдающие JSON из пары десятков однотипных методов, но если смотреть шире — Spring Framework еще очень небольшой, по сравнению с тем что живет в Java-мире.
А живет там например OSGi, который сложнее Spring наверное раза в два или RCP вроде Eclipse, первая же разработка под который будет незабываемой .
Не стоит забывать про Jakarta EE, в девичестве JEE, где каждый сервер приложений фактически является огромным фреймворком, доходящим до своего апофеоза в виде Websphere или Weblogic.
И это я еще не трогал более глобальные вещи вроде ESB или BPM, которые вообщем‑то тоже — фреймворки.
А есть еще Portlet API и Java Portals с монстрами вроде Liferay (не к ночи будет помянут), который тоже является огромным и сложным фреймворком, под который ведется разработка конечного приложения.
Вообщем переусложнение на ровном месте и бесконечное количество архитектурных слоев — конек Java.
PastorGL
21.09.2024 11:28Что-то вы батенька sending совсем уж mixed messages in your comments.
Не надо путать тёплое с мягким, то есть enterprise application containers и AOP/DI, это решения совершенно разной мощности и для разных нужд. Сравнивать их могут только люди, не разбирающиеся в теме. EE — это вообще-то набор стандартов.
Чем больше отвечаете в разных ветках комментов, тем больше складывается впечатление, что в голове у вас какая-то каша.
olku
21.09.2024 11:28Отличный проект для понимания эволюции разработки и основных концепций. Базы данных не хватает для полноты картины, пусть хоть однопользовательская и файловая, чтоб ACID показать.
pyur
21.09.2024 11:28ну, у меня веб-сервер без библиотек, на чистом jdk. и не только json парсер-сериализатор, но и http-сервер тоже. причём с вебсокетами. и много чего другого. свой парсер-сериализатор der контейнеров для сертификатов и т.д.
APXEOLOG
21.09.2024 11:28+1В нынешние интересные времена, когда один только boilerplate (шаблон проекта) может занимать на диске гигабайт, а количество библиотек в самом обычном проекте приближается к паре сотен — данная статья может нанести психическую травму неподготовленному читателю и заставить задуматься о правильности выбора профессии.
Мне сложно представить себе, что это за такие "обычные проекты", где сотни библиотек. Я сталкивался с достаточно большим количеством проектов - и больших, с кодовой базой на миллионы строк кода. И поменьше. На разных языках. Но не помню ни одного, где хотя бы сотня библиотек набралась. Можно конечно с натяжкой сказать это про javascript, если считать непрямые завимимости. Но это звучит не слишком честно.
Эта объемная работа предназначена в первую очередь для профессионалов разработки на Java, которые уже имеют практический опыт с большими фреймворками, так популярными в этом
болотесреде и смогут в полной мере оценить всю сложность работы «без всего».Мне кажется целевая аудитория выбрана неверно. Данная статья действительно может быть полезна для новичков. Для профессионалов тут просто нет ничего интересного.
alex0x08 Автор
21.09.2024 11:28Мне сложно представить себе, что это за такие "обычные проекты", где сотни библиотек.
А вы не очень умный: это надо было умудриться процитировать текст со ссылкой на JHipster и не попробовать ее даже открыть )
Мне кажется целевая аудитория выбрана неверно.
Если «кажется» — креститься надо, говорят помогает.
APXEOLOG
21.09.2024 11:28+2А вы не очень умный: это надо было умудриться процитировать текст со ссылкой на JHipster и не попробовать ее даже открыть )
Исходя из текста, ссылка относится к "шаблону проекта". Не спорю с этим утверждением. Шаблон проекта может занимать и 10 Кб, и 10 Гб - личное дело каждого.
Если же Вы считаете, что результат работы какого-то конкретного генератора проектов = "самый обычный проект", то хотелось бы увидеть какую-то статистику на этот счет.
И даже при таком подходе, sample app по Вашей ссылке не имеет упомянутых "сотен библиотек".
Вывод: Автор пытается словить хайпа на громких утверждениях, которые не может подтвердить.
alex0x08 Автор
21.09.2024 11:28И даже при таком подходе, sample app по Вашей ссылке не имеет упомянутых "сотен библиотек".
А вы собрать-то попробуйте ) Скачается из интернета примерно ~1.5Гб библиотек Java и где-то 55-60 тысяч библиотек для Node.js.
Вывод:
"Сказочный специалист" (ц)
APXEOLOG
21.09.2024 11:28Считать непрямые зависимости не имеет никакого смысла. С таким подходом можно и JDK представить как сотни библиотек (ну а чего, так и есть, просто запакованы в одну большую)
alex0x08 Автор
21.09.2024 11:28Считать непрямые зависимости не имеет никакого смысла.
И память наверное у вас тоже бесконечная?
Или вы думаете что все эти библиотеки скачиваются просто так, для коллекции?
С таким подходом можно и JDK представить как сотни библиотек (ну а чего, так и есть, просто запакованы в одну большую)
Не поверите, но именно так и произошло: в какой-то момент умные люди представили объем библиотек, поставляемых вместе с JDK и ужаснулись.
После чего появился проект Jigsaw (пила), созданный специально для задачи выпиливания всех лишних библиотек из JDK.
А вы говорите.
APXEOLOG
21.09.2024 11:28+1И память наверное у вас тоже бесконечная?
Или вы думаете что все эти библиотеки скачиваются просто так, для коллекции?
Во-первых, не все библиотеки используются в райнтайме и являются частью финального дистрибутива.
Во-вторых, выбор библиотек основывается на требованиях проекта. Есть много случаев, когда память не является ограничивающим фактором.
После чего появился проект Jigsaw (пила), созданный специально для задачи выпиливания всех лишних библиотек из JDK.
А для javascript, Вы не поверите, появились бандлеры с tree shake, которые выпилывают весь неиспользуемый код из финального бандла. Это не мешает Вам считать "60 тысяч библиотек".
Я думал тут статья про когнитивную сложность, к чему эти пассажи про память?
alex0x08 Автор
21.09.2024 11:28Есть много случаев, когда память не является ограничивающим фактором.
Это практически девиз современной разработки. К сожалению.
Слушайте я не знаю что вам ответить, вот честно.
Есть на свете такая вещь как «инженерная культура», остатки которой еще позволяют ИТ‑индустрии хоть как‑то развиваться и не разваливаться под зловонными массами «современных решений».
Передать вам эту культуру одной перепиской не получится, если вы не понимаете в чем проблема использовать «50к библиотек для CRUD‑проекта» — ну у вас этой культуры просто нет, что печально.
APXEOLOG
21.09.2024 11:28+1Передать вам эту культуру одной перепиской не получится, если вы не понимаете в чем проблема использовать «50к библиотек для CRUD‑проекта» — ну у вас этой культуры просто нет, что печально.
Я прекрасно понимаю. И именно поэтому я не пользуюсь всякими "генераторми проектов", где какие-то случайные люди решили, что они лучше меня знают, что именно нужно для моего проекта.
По результатам нашей с Вами дискуссии, я еще меньше стал понимать смысл данной статьи. Изначально я предположил, что смысл статьи - помочь начинающим программистами понять, что в больших и сложных фреймворках, которые повсеместно используются, нет никакой магии. Концептуальная идея довольно проста и минимальная реализация показана на примере.
Но Вы переводите разговор в русло производительности, заявляя про ограничения памяти и т.д. В данном контексте видимо идея статьи в том, чтобы переписать все нужные библиотеки самому, выкинув лично Вам ненужый функционал? Очередное устранение "фатального недостатка".
alex0x08 Автор
21.09.2024 11:28И именно поэтому я не пользуюсь всякими "генераторми проектов", где какие-то случайные люди решили, что они лучше меня знают, что именно нужно для моего проекта.
Для вашего проекта — безусловно никаких ограничений нет, но есть еще такие проекты за которые платят деньги и в большинстве случаев они вам (как разработчику) не пренадлежат.
Называется это «коммерческая разработка», которой на сегодняшний день большинство.
В тех редких случаях, когда проект создается с нуля, главную роль играет скорость создания функционала а не идейные соображения, поэтому и используют готовые шаблоны проектов.
Но это повторюсь — большая редкость и чаще всего дело приходится иметь с уже существущей и работающей системой, которую надо «просто доработать» или что‑то в ней исправить.
В этом случае думаю вполне очевидно — ничего глобального менять вам просто не дадут.
я еще меньше стал понимать смысл данной статьи
Скажем так: статья написана для моих коллег по заказной разработке, годами не вылезающих из всевозможных «кривоколенных проектов», спроектированных пьяными матросами и далеко не в самый их лучший день.
Отдых для уставшей души — временами надо увидеть, что хоть где‑то может быть по другому.
siarheiblr
21.09.2024 11:28В первый раз слышу, чтобы инженерная культура требовала писать свои велосипеды вместо использования библиотек. Вы ж не имплементите алгоритмы шифрования с нуля, я надеюсь?
Подкованные блохи это из разряда диковин, все же.
vvbob
21.09.2024 11:28Интересный эксперимент :)
Что делать если оказался на необитаемом информационном острове, нужно написать свой сайт с блек-джеком и всем прилагающимся, а из инструментария у тебя один только голый JDK.
kmatveev
21.09.2024 11:28+5Ох уж этот сам себя нахваливающий автор-графоман, со своими статьями "для профессионалов разработки на Java", объясняющий, что такое DI-контейнер и "откуда сервлет достаёт параметры HTTP-запроса".
У вас в первом же куске кода (пример использования сервера) лажа: вы задаёте заголовок "Content-Length" как длину строки в символах, а потом в тело ответа эту строку с кириллическими буквами кодируете в байты в UTF-8, у вас получится не 8, а 15 байт.
Контейнер у вас забавный: вам надо бины и пометить интерфейсом, и передать в setup(). Теоретически можно было бы считать, что в setup() передавать надо только те бины, которые потом через getInstance() получают, но нифига не сработает, ваш массив классов cl ограничен тем размером, который передаётся в setup(). Вообще не очень понятно, зачем marker-интерфейс Dependency, всё равно у вас все параметры конструктора должны быть внедряемыми зависимостями, иначе вы экземпляр не создадите. Метод addClassNum(), который проходится по массиву, включая незаполненную часть, и проверяющий классы через equals() - не очень производительно.
В хранилище сессий у вас утечка памяти: если пользователь не делает явно "logout", а просто закрывает страницу и уходит, то сессия останется в хранилище навечно. Поэтому не непонятно, нафига вы делали протухание сессий? Этот механизм не от хорошей жизни придуман, он может доставлять неудобство пользователю, но в настоящих http-серверах сессия вычистилась по таймеру и потом её уже взять неоткуда, а вы её удаляете и тут же создаёте новую. С вашим ограничением на количество сессий - беда, новые пользователи зайти не смогут.
Вообще с сессиями не очень наглядно вышло, лучше было бы хранить язык пользователя в ней на сервере, а не через cookie передавать.
alex0x08 Автор
21.09.2024 11:28вы задаёте заголовок "Content-Length" как длину строки в символах, а потом в тело ответа эту строку с кириллическими буквами кодируете в байты в UTF-8, у вас получится не 8, а 15 байт.
Как безусловный профессионал, вы ведь знаете что все строки в Java в юникоде? Ведь так, о великий гуру?
kmatveev
21.09.2024 11:28+2Знаю конечно.
Выражение "это тест".length() вернёт 8. Выражение "это тест".getBytes("UTF-8").length вернёт 15. Вы в заголовок "Content-Length" передали первое, причём сразу через sendHeaders(), чтобы улетело, а в response body передали второе.
alex0x08 Автор
21.09.2024 11:28Да, подтверждаю косяк. Могу сказать что этот код тестовый, взят из javadoc пакета куда был прилеплен текст на русском. В основном проекте этого нет.
barker
21.09.2024 11:28все строки в Java в юникоде
Вы довольно смутно понимаете причём тут юникод, надо сказать... Туда же и прочие по коду response.getBytes() без кодировок. Джуновские такие косячки. Ну и ладно вроде как, проект джаст фор фан, как говорится. И на жаве всё равно так не пишут (почти в каждом методе). Просто поразительно сколько при этом гонора, скромнее надо быть)
alex0x08 Автор
21.09.2024 11:28+1Контейнер у вас забавный: вам надо бины и пометить интерфейсом, и передать в setup().
Это остатки более сложной логики инстанциации, которая учитывала наличие примитивных типов, чтобы можно было передавать числа и строки и сделать некий аналог @Value.
Вот тут была ветка с учетом примитивов:
for (Class<?> p : c.getParameterTypes()) if (Dependency.class.isAssignableFrom(p) && services.containsKey(p)) params.add(services.get(p));
К сожалению компактно сделать не удалось и пришлось порезать.
если пользователь не делает явно "logout", а просто закрывает страницу и уходит, то сессия останется в хранилище навечно.
Как видите есть две проверки:
// 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;
Первая как раз не позволяет бесконечный рост хранилища сессий - те той самой утечки никогда не произойдет.
Вторая не дает повторную регистрацию, если сессия для логина уже есть, поэтому получается связка 1 к 1 всегда: один пользователь можен открыть только одну сессию.
Наконец вот эта логика удаляет существующую сессию при устаревании:
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; }
Код вызывается при любом обращении к странице где есть сессионность.
Поэтому описанный вами кейс:
если пользователь не делает явно "logout", а просто закрывает страницу и уходит, то сессия останется в хранилище навечно.
возможен только если он не просто уйдет а еще и удалит cookie с id сессии.
Во всех остальных случаях произойдет обращение с id из cookie и сессия мирно удалится при устаревании.
Для демо проекта этого достаточно, поскольку логика с фоновой обработкой хранилища сессий и автоудалением - больше и сложнее.
Maksclub
21.09.2024 11:28Простой код у джавистов вызывает раздражение и несварение.
Но ведь писать просто можно, да, не на уровне написания своего json serializer, но свою то логику можно и попроще. Просто сколько залезал в джава код — всегда видел сильную размазанность и обилие папок с 2-3 файликами, где названия вроде вот они — мои, зашел... а там интерфейсы голые, ат ы уже по самые помидоры на глубине папок. И бегаешь ищешь чего и где :) другое дело в го библиотеках: в среднем примере тупо все файлы в одной папке :)
9lLLLepuLLa
21.09.2024 11:28на самом деле это просто низкая инженерная культура. На java можно писать нормально, если не делать всё со всем связанным и всё подряд со скоупом public.
Все становится гораздо прозрачнее, если строить взаимодействие между пакетами только через интерфейсы и забыть про публичные классы
alex0x08 Автор
21.09.2024 11:28Все становится гораздо прозрачнее, если строить взаимодействие между пакетами только через интерфейсы и забыть про публичные классы
К сожалению это редко когда возможно в современной разработке.
Дело в том что в большинстве случаев «ваши классы вам не принадлежат» — разработчики вынуждены соблюдать бесконечное количество правил оформления классов, вводимых тем или иным фреймворком: сеттеры/геттеры, обязательное наличие публичного конструктора, требования к модификаторам полей и самих классов.
Есть требования к JPA Entity классам, требования к POJO сериализуемым в JSON через какой-нибудь Jackson, требования к CDI бинам, требования к Repository в Spring и так далее и тому подобное.
В результате вся прикладная разработка так или иначе выстраивается вокруг запросов используемых фреймворков а не ваших архитектурных идей.
Maksclub
21.09.2024 11:28требования к JPA Entity классам
Но ведь дата-маппер придуман, чтобы требований к ентити не было, "пишите свою сущность", а маппер заммапит. Никто не просил обвязывать их ломбуком.
требования к POJO
Требования к Plain Object ... звучит как оксюморон
В результате вся прикладная разработка так или иначе выстраивается вокруг запросов используемых фреймворков
Но ведь никто не требует написать простой сервис без абстрактных фабрик и стратегий, который просто сделает работу (например как в го пишут и не испытывают проблем с этим), да, немного копипасты, но вся копипаст — проблема, особенно пока "вдруг нам понадобится" еще не наступило
alex0x08 Автор
21.09.2024 11:28Но ведь дата-маппер придуман, чтобы требований к ентити не было, "пишите свою сущность", а маппер заммапит. Никто не просил обвязывать их ломбуком.
А как насчет требований оформления к самому дата мапперу? А ничего, что этим вы вводите в проект целый отдельный слой для перекладывания данных? Причем аж на уровне сборки (если вы про MapStruct), не смущает?
А что если "надо такой же но с перламутровыми пуговицами": в Entity например старый java.util.Date а в DTO уже LocalDateTime (что сплошь и рядом)? Конвертер будете писать? Для маппера?
Требования к Plain Object .
Я вас удивлю, сказав что существуют требования оформления к POJO?
(например как в го пишут и не испытывают проблем с этим
Разработка на Go сильно отличается ввиду другой культуры разработки и куда меньших проектов - не забывайте что для Java проект в 100 млн строк кода это обыденность, это даже не большой проект. В Go с учетом особенностей сборки думаю будет ад и погибель уже на одном миллионе.
9lLLLepuLLa
21.09.2024 11:28// создание 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));
По-моему вы просто реализовали свой контейнер IoC, а не DI. Не проще было использовать синглтоны с фабричными методами и не городить огород с контейнерами ?
alex0x08 Автор
21.09.2024 11:28проще было использовать синглтоны с фабричными методами
Проще конечно, но не так показательно.
Еще я не очень люблю синлтоны и фабрики, поскольку это плохо стыкуется с концепцией управляемого окружения аля CDI/Spring в реальных проектах.
youngmyn
Я немного не понял технический стек проекта, можно подробнее? Какие библиотеки/фреймворки использовали?
alex0x08 Автор
Никаких вообще.
qiper
Вообще всё голое
foal
Это сарказм?
nikitakim
Для автора com.sun.net.httpserver.*, java.net.* и java.io.* не внешние зависимости
alex0x08 Автор
Если вы покажете как можно работать с сокетами в java без импортов вообще - ну думаю сможете автора удивить.
d00m911
Можно работать с сокетами без Java)