Рассказываю что можно сделать на одном только голом 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
Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.
Оживляем давно умершее, чиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.
ссылка на оригинал статьи https://habr.com/ru/articles/841574/
Добавить комментарий