Генерируем простой web интерфейс для просмотра таблиц PostgreSQL
- пятница, 1 сентября 2023 г. в 00:00:18
В публикациях на хабре, чаще всего самое интересное в коментариях. Вот и в моем прошлом посте "Сгенерировать web интерфейс из БД или объектной модели не стало проще даже 10 лет спустя" я собрал джекпот из SharePoint, Vaadin и нескольких библиотек либо устаревших, либо поддерживаемых "одним автором".
Что я пытался объяснить читателям, что на этапе проверки идей не нужна дополнительная сложность в виде фреймворков, серверной инфраструктуры только ради этой самой инфраструктуры и нового языка программирования/платформы. Именно поэтому мне не нужны сейчас Yii, Symfony и Laravel, не подойдет ADF. На любое обучение тратится драгоценное время и поэтому надо понимать зачем тратить время на что-то, что потом возможно навсегда придется забыть через неделю-другую игры с прототипом.
В своих эксперементах с помощью лапшекода на JavaScript я быстро разработал вполне функциональный (для проверки идеи) но при этом не дружелюбный к пользователю интерфейс. Как пример, один из моих экранов:
При этом все это время я не программировал backend совсем. Все что есть в приложении - это набор статических файлов на http сервере jetty, и PostgREST для превращения базы данных PostgreSQL в сервис Open API и несколько новых хранимых функций в базе данных для интерфейса.
Если бы PostgREST позволял отдавать статические HTML и картинки. Ведь когда я пытался запустить страничку из локальной папки, взаимодействие с PostgREST не работат из-за ограничений безопасности в современных браузерах.
Что из моего опыта разработки "на коленке" может быть полезно бэкэндерам, кто хочет бытро показать табличные данные из PostgreSQL?
Пересмотрев несколько библиотек на GitHub для отображения таблиц я остановился для себя на Tabulator которая из коробки умеет отображать таблицы с сортировкой, фильтрацией и разбивкой на страницы, автоматически распознает типы колонок, с отличной документацией, доступной для понимания не фронтэндером. А также позволяет быстро делать прототип, разрешая настраивать типы отображения поля даже в режиме автоопределения.
За несколько минут( не учитывая время чтения документации) я набросал прототип который позволяет просмотреть все таблицы в базе данных. И сделал это я на Java еще не выпущенной версии 21 и потом еще минут десять пытался его запустить, разыскивая где в новой для меня версии IntelliJ Idea CE и в старой версии maven место куда же добавить эксперементальный параметр JDK enablePreview.
Честное слово, это привычка на автоматизме разрабатывать с заделом на будущее, чтобы когда-нибудь разбирать метаданные PostgreSQL и комментарии к объектам БД, все то что не экспортирует PostgREST. Пошел заварить чай и подумал доберусь ли я до этих метаданных постгреса и когда, что все это тоже лишнее, не нужен тут overengineering и надо переписать на HTML+JavaScript.
Ощущения что с Servlet API 5.0 разработка стала проще и без web.xml, а String Templates из JDK 21 сделают код более читаемым и в простых случаях избавляют от надобности подключения внешнего движка шаблонизации Apache Velocity:
package com.github.com.github.isuhorukov.postgrest.crud;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import static java.lang.StringTemplate.STR;
@WebServlet("/table")
public class TableServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
String endpoint = request.getParameter("endpoint");
if(endpoint==null || endpoint.isBlank()){
List<String> postgRestAvailablePaths = getPostgRestAvailablePaths();
String options = postgRestAvailablePaths.stream().map(path ->
STR. """
<option value="\{path}">\{path}</option>
""").collect(Collectors.joining("\n"));
response.getWriter().append(STR."""
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
</head>
<body>
<form method="GET" action="/table">
<select name="endpoint">\{options}</select>
<input type="submit" value="Show">
</form>
</body>
</html>
""");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
response.getWriter().append(STR."""
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
<link href="https://unpkg.com/tabulator-tables@5.5.2/dist/css/tabulator.min.css" rel="stylesheet">
<script type="text/javascript" src="https://unpkg.com/tabulator-tables@5.5.2/dist/js/tabulator.min.js"></script>
</head>
<body>
<div id="table-result"></div>
<script>
var table = new Tabulator("#table-result", {
ajaxURL: '\{endpoint}',
pagination:true,
paginationMode:"remote",
dataSendParams:{
"size" : "limit" //rename page request parameter to "limit"
} ,
ajaxURLGenerator:function(url, config, params){
let sortParam = '';
if (params.sort && params.sort.length > 0) {
sortParam = '&order='+params.sort.map(sorter => `${sorter.field}.${sorter.dir}`).join(',');
}
return url + (url.includes("?")?"&":"?") + "limit=" + params.limit+"&offset=" + params.limit*(params.page-1) + sortParam;
},
ajaxResponse: function (url, params, response) {
document.querySelector(".tabulator-footer .tabulator-page[data-page='last']").style.display = "none";
return {
last_page: 10000000000,
data: response
};
},
autoColumns:true,
paginationSize: 35,
sortMode: 'remote',
filterMode:'remote',
ajaxSorting: true,
ajaxFiltering: true
});
</script>
</body>
</html>""");
}
List<String> getPostgRestAvailablePaths() {
String postgRestUrl = System.getenv("postgrest_url");
if(postgRestUrl==null || postgRestUrl.isBlank()){
throw new IllegalArgumentException("Please provide PostgREST / endpoint in environment variable: postgrest_url");
}
List<String> paths = new ArrayList<>();
try (HttpClient httpClient = HttpClient.newHttpClient()){
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(postgRestUrl)).build();
HttpResponse<String> postgRestResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (postgRestResponse.statusCode() == 200) {
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
ObjectMapper objectMapper = new ObjectMapper();
if (response.statusCode() == 200) {
String responseBody = response.body();
JsonNode openApiSpec = objectMapper.readTree(responseBody);
JsonNode pathsNode = openApiSpec.get("paths");
if (pathsNode != null && pathsNode.isObject()) {
for (Iterator<String> it = pathsNode.fieldNames(); it.hasNext(); ) {
String path = it.next();
if (!"/".equals(path) && !path.startsWith("/rpc")) {
paths.add(postgRestUrl + path);
}
}
} else {
throw new RuntimeException("No table paths found.");
}
} else {
throw new RuntimeException("PostgREST failed with status code: " + response.statusCode());
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
} else {
throw new RuntimeException("PostgREST request failed with status code: " + postgRestResponse.statusCode());
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
Collections.sort(paths);
return paths;
}
}
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.github.igor-suhorukov</groupId>
<artifactId>postgrest-crud</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-jakarta-servlet-api</artifactId>
<version>5.0.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<enablePreview>true</enablePreview>
</configuration>
</plugin>
<plugin>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-maven-plugin</artifactId>
<version>11.0.15</version>
<configuration>
<scanIntervalSeconds>10</scanIntervalSeconds>
<connectors>
<connector implementation="org.mortbay.jetty.nio.SelectChannelConnector">
<port>8080</port>
<maxIdleTime>60000</maxIdleTime>
</connector>
</connectors>
</configuration>
</plugin>
</plugins>
</build>
</project>
Итак, я сделал все то что не хотел, добавил лишний компонент между PostgREST и браузером. Смело выбрасываем этот код/подход и движимся дальше!
Tabulator сделает всю магию за нас. Он отлично работает на ноутбуке и в мобильном браузере, и его нужно лишь правильно сконфигурировать для работы с PostgREST API.
Первой попыткой было создание задачи на адаптацию его для PostgREST с помощью комьюнити проекта, но безрезультатно.
Ну что ж, сделаем сами. Его параметр ajaxURL, который подгружает данные с сервера не сработает с PostgREST потому что в ответе он ожидает JSON объект c last_page - номером последней страницы и массивом data, который содержит сами данные.
Наш же API возвращает данные в виде массива сразу и не возвращает сколько страниц. Но нам это и не нужно, в первом приближении, так как эта та "кроличья нора" провалившись куда никогда не вернусь к основной задаче.
К слову, API умеет возвращать сколько строк в результате по нескольким алгоритмам. Нужно лишь установить HTTP Header:
Prefer: count=exact
Prefer: count=planned
Prefer: count=estimated
Результат можно извлечь из Content-Range в Response после "/". Я же поступил гораздо проще - убрал кнопку перехода на последнюю страницу.
Продолжаю разбирать проблемы: Tabulator передает в параметры номер страницы, а мне это нужно перевести их в limit/offset для API. Делаю это в функции ajaxURLGenerator и с помощью конфигурации dataSendParams.
Преобразование в нужный для виджета формат данных с сервера делаю на клиенте в функции для ajaxResponse.
Итого, у моей страницы есть параметры:
postgrest_url указывает на PostgREST API. Если не указать, то по умолчанию будет "стучаться" на localhost:3000
endpoint - таблица или вьюшка в постгресе. Если ее не указать, то код запросит все пути endpoint из PostgREST, отфильтрует функции и метаданные, и выдаст вам форму с выбором endpoint. Укажите в поле select какую таблицу хотите посмотреть, после "Нажми на кнопку и получишь результат".
Для работы нужена ваша база данных, сервер для API PostgREST который "смотрит" на эту базу, любой веб сервер куда вы можете положить эту страничку ( запускаю с помощьюmvn jetty:run
). Если бы браузеры не боролись с cross site scripting и безопасностью ваших локальных данных, как это было раньше, то и веб сервер не был бы нужен для раздачи статики.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
<link href="https://unpkg.com/tabulator-tables@5.5.2/dist/css/tabulator.min.css" rel="stylesheet">
<script type="text/javascript" src="https://unpkg.com/tabulator-tables@5.5.2/dist/js/tabulator.min.js"></script>
</head>
<body>
<form id="endpoint_selector" method="GET" action="tables.html" style="display:none">
<select id="endpoint" name="endpoint"></select>
<input id="postgrest_url" name="postgrest_url" type="hidden" value="">
<input type="submit" value="Show">
</form>
<div id="table-result"></div>
<script>
const params = new URLSearchParams(window.location.search);
const postgRestUrl = params.has('postgrest_url')?params.get('postgrest_url') :'http://localhost:3000';
async function fetchAndExtractPaths(postgRestUrl) {
try {
const response = await fetch(postgRestUrl);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${postgRestUrl}`);
}
const data = await response.json();
const paths = [];
if (data && typeof data.paths==='object') {
paths.push(...Object.keys(data.paths).filter(key => !key.startsWith('/rpc') && key!='/' ));
}
return paths;
} catch (error) {
alert('Error:', error);
return [];
}
}
if (params.has('endpoint')) {
const endpoint = params.get('endpoint');
var table = new Tabulator("#table-result", {
ajaxURL: postgRestUrl+endpoint,
pagination:true,
paginationMode:"remote",
dataSendParams:{
"size" : "limit"
} ,
ajaxURLGenerator:function(url, config, params){
let sortParam = '';
if (params.sort && params.sort.length > 0) {
sortParam = '&order='+params.sort.map(sorter => `${sorter.field}.${sorter.dir}`).join(',');
}
return url + (url.includes("?")?"&":"?") + "limit=" + params.limit+"&offset=" + params.limit*(params.page-1) + sortParam;
},
ajaxResponse: function (url, params, response) {
document.querySelector(".tabulator-footer .tabulator-page[data-page='last']").style.display = "none";
return {
last_page: 10000000000,
data: response
};
},
autoColumns:true,
paginationSize: 35,
sortMode: 'remote',
filterMode:'remote',
ajaxSorting: true,
ajaxFiltering: true
});
} else {
fetchAndExtractPaths(postgRestUrl)
.then(paths => {
const container = document.getElementById('endpoint');
paths.forEach(path => {
const optionElement = document.createElement('option');
optionElement.setAttribute('option',path);
optionElement.textContent = path;
container.appendChild(optionElement);
});
document.getElementById("endpoint_selector").style.display = 'block';
document.getElementById("postgrest_url").value = postgRestUrl;
});
}
</script>
</body>
</html>
PostgREST я запускаю локально в Docker:
docker run --name postgrest --net=host -e PGRST_DB_URI="postgres://USER:PASSWORD@127.0.0.1:5432/DATABASE" -e PGRST_DB_ANON_ROLE="YOUR_ROLE" postgrest/postgrest:v11.2.0
PostgreSQL с данными у меня тоже запущен в Docker образе, как я уже рассказывал на хабре.
Что еще можно сделать, пока не вышло универсально - в зависимости от типов данных менять отображение в колонках.
Получается отображать данные таблиц на сотни гигабайт с помощью постраничной загрузки с возможностью сортировки в базе данных.
И самое главное, что в обсуждении о производительности мы фокусируемся на самих бизнес данных и методах доступа базы к ним, а не на тюнинге GC, оптимизации сериализации в приложении, не о кластеризации серверов и даже не о мониторинге кешей Hazelcast, Ingnite, Coherence, оркестрации микросервисов и много еще чего. То есть мы ближе к решению задачи, чем к зоопарку технологий в MVP. На начальном этапе достаточно грамотной работы с базой данных. И откладываем самые важные архитектурные решения и трудозатраты на разработку на потом, когда проверим что в решении задачи есть ценность.
У меня нет планов на создание или на развитие этого как low code/no code платформы. Свою задачу я решаю быстро. Возможно кому-нибудь еще, кто использует PostgreSQL и не фронтэнд разработчик, этот опыт поможет для проверки идеи или создания "админки" к базе для пользователей внутри компании.