Project Detroit: JavaScript V8 в Java Development Kit
Всім привіт, мене звати Денис Макогон. Я є cloud-архітектором і членом команди Java Developer Relations в Oracle.
З останнього моменту як я писав статті по Java тут вже пройшло досить багато часу. Проте, нещодавно, мені нагадали й трохи пожурили за відсутність контенту. Виправляюся!
Ця стаття НЕ ПРО...
... те, що JavaScript «кращий за Java», і не про заміну Java backend-ів на JS. Головна ідея показати вам, що Project Detroit може дати Java-застосунку контрольований доступ до сильних сторін інших екосистем — перш за все динамічних правил, JSON-трансформацій і скриптованих розширень, без окремого Node.js або Python процесу.
Java застосунки роками інтегруються з JS і Python через різні канали звʼязку (HTTP, черги, shared memory, JNI, Foreign Function & Memory API і т.д.). Для певного класу задач на кшталт динамічних правил, обробки JSON, скриптованих плагінів або UDF (user-defined function) потрібна «ближча» взаємодія та інтеграція. Саме тут і цим цікавий Project Detroit — можливість запускати V8 або CPython поруч із JVM, у тому самому процесі, і використати для цього доволі вже знайомий javax.script.
Одразу важлива ремарка: Project Detroit не є частиною стандартного релізу JDK, а має окремі репозиторії й поки не має офіційних збірок (за аналогом із JDK EA).
Тому ця стаття більш технічний огляд і практичний експеримент, а не порада масово переносити Java-сервіси на нову технологію або впроваджувати концептуально щось нове, бо мова йде навіть не про «preview feature». Отже, досить демотивації, вйо до суті.
Project Detroit: передісторія та вибір JS/Python
Від javax.script до Nashorn і назад
JDK вже досить давно постачається із scripting API. Пакет javax.script зʼявився ще у Java 6 і пропонував загальний контракт для виконання скриптів, передавання Java-обʼєктів у скрипт, виклику функцій та пошуку engine через ScriptEngineManager. Він задумувався саме для випадків, коли скрипт написаний користувачем застосунку або є частиною його конфігурації. Java Scripting API
У JDK 8 зʼявився Nashorn, такий собі JavaScript engine, реалізований для JVM. Він давав Java-розробникам можливість запускати JS без зовнішнього рушія, але підтримувати сучасну сумісність із мовою та її екосистемою стало надто дорого. І, surprise-surprise, Nashorn перейшов у статус «deprecated» із релізом JDK 11 і повністю видалений з JDK 15. JEP 335, JEP 372
На відміну від Nashorn, Project Detroit пропонує інший підхід. Замість повторної реалізації JavaScript або CPython поверх JVM він інтегрує рушії, навколо яких вже існують відповідні екосистеми:
- V8 для JavaScript;
- CPython для Python.
Це принципова різниця. Мета полягала не у створенні ще одного діалекту JavaScript для Java, а долучити зовнішній рушій, яким уже користуються Chrome і Node.js, та інтерпретатор, з яким сумісна велика частина СPython-екосистеми. Перше бачення і реалізація Project Detroit зʼявилися у 2018 році й проіснували у стані «keep the lights on» (неактивна робота), у 2026 році OpenJDK повернувся до ініціативи: на JavaOne’26 її представили як проєкт для інтеграції Java з JavaScript і Python через V8 та CPython, а розробка отримала окремі репозиторії: OpenJDK announcement, контекст JavaOne 2026
Чому саме JavaScript і Python
JavaScript добре підходить для динамічних правил і JSON-перетворень. У нього є природна робота з обʼєктами, масивами, optional-полями та стандартні JSON.parse() і JSON.stringify(). Крім того, JS знає велика частина frontend- і Node.js-розробників.
Python має іншу сильну сторону: (сюрприз-сюрприз) AI/ML, data science і наукові бібліотеки. Важлива саме сумісність із CPython, адже значна частина екосистеми Python містить native extensions. Ну і про комʼюніті Python-а й казати не треба, сам колись був в такому (ох, гарні часи живого OpenStack...).
Лірику в сторону, довгий час майже будь-яку технологію можна було привнести у JVM за допомогою JNI, та Foreign Function & Memory API. Будь-який інструмент, або фреймворк, який посилить JVM це вже виграш для всіх розробників Java-застосунків.
Примітка від автора: Detroit Python існує як окремий OpenJDK-репозиторій, тому не слід робити висновок, що JS і Python integration уже мають однаковий рівень готовності. Detroit Python
V8: що він дає і чого не дає
V8 це open-source рушій JavaScript і WebAssembly, написаний на C++. Він виконує ECMAScript, компілює JavaScript, керує памʼяттю JS-обʼєктів і має власний garbage collector (GC). V8 використовується у Chrome та Node.js, але сам по собі не є Node.js. V8 надає специфікацію мови JavaScript, типізацію, функції, Promise, JSON, Map, Set тощо, проте, V8 не постачає (самостійно) вже досить звичний функціонал типу:
fs, HTTP server абоrequire();- npm package resolver;
- DOM;
- Node.js API.
Цікавий факт, у Project Detroit, за надання DOM в JS скрипт відповідає сам хост скрипту, тобто, Java-застосунок. Отже, саме Java вирішує, які обʼєкти й функції стануть доступними JS-коду. Це означає, що маючи розрізнену памʼять і все що неї стосується (heap в V8, heap в JVM), саме JVM вирішує що буде відображено у стані (globals) V8 — обʼєкти Java та обʼєкти JS не стають одним суцільним сегментом у спільній памʼяті. Задля досягнення інтер-комунікації, Project Detroit надає інструментарій для відображення обʼєктів між JVM та V8 один в один. Але не треба забувати, що кожен перехід між середовищами Java та JavaScript, конвертація даних та життєвий цикл обʼєктів мають обчислювальну вартість, і відповідний «memory footprint». Як завжди, персонально рекомендую бути обережним знаючи, що під капотом знаходиться JNI.
Варто зазначити також, що V8 має власні JIT-оптимізації, але це не означає, що JavaScript у Project Detroit автоматично швидший за Java. У реальних продакшен-умовах, JIT оптимізація (з обох сторін) допоможе зробити застосунки продуктивними (наскільки це можливо). Проте, у Java-застосунків є один козир — AOT (ahead of time) компіляція, вона допоможе оптимізувати більшу частину Java коду і зробити це можна до початку штатного життєвого циклу застосунку (якщо цікаво, дайте знати, залюбки про це розповім в іншій статті).
Отже, схематично, інтеграція двох середовищ (Java та JavaScript) виглядає приблизно ось так:
Java code JavaScript code │ │ │ ScriptEngine / Invocable │ ECMAScript + JSON + V8 JIT ▼ ▼ ┌─────────────────── Project Detroit ────────────────────┐ │ Java wrappers, object conversion, native bridge, JNI │ └───────────────────────┬────────────────────────────────┘ ▼ V8 isolate + V8 heap
isolate — це незалежне середовище виконання V8 зі своїм heap і global state. Воно допомагає відділяти один JS рушій від іншого, але саме по собі не є повноцінним «периметром безпеки» для виконання довільного (arbitrary) не-довіреного коду. Отже, ми проходимо до класичного архітектурного рішення: або один V8 на всі задачі, або один V8 на підзадачу.
Як Detroit інтегрує V8 у Java через javax.script
У Project Detroit доступ і функціонування V8 постачається як реалізація давно наявного SPI javax.script. Реалізація JS постачається у двох варіантах:
| Engine | Застосування |
|---|---|
v8 | Довірений JS, якому Java свідомо передає вузький API через bindings. |
v8-no-java | Користувацькі UDF та інші сценарії, де JS не повинен бачити Java bridge. |
v8 по дизайну підходить для всіх задач, проте, його застосування потрібне для передачі контролю над обʼєктами JVM у безпосередній скрипт. Водночас v8-no-java, із назви вже видно, є більш лімітованою версією і має більш суворі обмеження: ніякого доступу до JVM, але не є повноцінно-ізольованою sandbox для будь-якого коду.
Найбільшим нюансом, навіть не проблемою, є те, що обидва типи рушіїв не можуть бути обмежені, ані по памʼяті, ані по CPU. Все ж, як треба мати жорсткий контроль, то краще, аніж cgroups, або ulimits ще не придумали (прим., для окремого процесу).
Що саме дає стандартний scripting API для V8
Розглянемо наступні API:
| API | Роль у Detroit |
|---|---|
ScriptEngine | Виконує eval() і працює з ScriptContext. |
Bindings | Робить значення іменованих Java обʼєктів доступними у global scope V8. |
Invocable | Викликає JS-функції після завантаження скрипту. |
Compilable | Дозволяє компілювати скрипт для повторного запуску, якщо рушій це підтримує. |
ScriptEngineFactory | Фабрика рушіїв. |
Наприклад, наступний рядок:
scriptEngine.put("javaObject", new JavaObject());
робить Java-обʼєкт javaObject доступним у JavaScript. Це дуже зручно для довіреного середовища, коли стоїть задача мати вплив на обʼєкти з JVM з середини V8. Project Detroit також додає V8-специфічний V8ScriptEngine. Він розширює ScriptEngine, Compilable і Invocable, та має додаткові можливості:
AutoCloseableдля якісного контролю за життєвим циклом рушія (try-with блок);V8ExecutionControlдля переривань (interrupt) і скасування (termination) довгих обчислень;JSObjectдля зчитування та доступу до атрибутів JS обʼєкту.
Як бачите, кардинально нового нічого нема і нічого не змінилося, все саме цікаве заховано вглибині та не дуже є цікавим для технічного розбору, окрім одного факту. Реалізація підтримки V8 в JDK використовує JNI замість Project Panama. Як думаєте, чому?
Досить слів, до справи!
Отже, вже зрозуміло яку мету переслідують розробники Project Detroit, і як вони це реалізують, проте подивімось як воно працює в реальності.
Збірка Project Detroit для JavaScript aka detroit-js
На момент написання, Project Detroit JS треба збирати з вихідного коду. Для цього потрібні:
- JDK 25 або новіше,
- Python 3 для V8 tooling,
make,- C/C++ toolchain,
- Chromium
depot_tools, - На macOS знадобляться Xcode Command Line Tools.
Процедура збірки виглядає наступним чином:
git clone https://github.com/openjdk/detroit-js.git cd detroit-js # depot_tools слід клонувати поза репозиторієм Detroit git clone https://chromium.googlesource.com/chromium/tools/depot_tools ../depot_tools export PATH="../depot_tools:$PATH" export JAVA_HOME=/path/to/jdk-25 make get-v8 make clean all make dist bundles
Збірка завантажує та компілює V8, тому це не легка Maven-залежність. Після неї Java-застосунок має бачити модуль org.openjdk.engine.javascript. Для macOS на Apple Silicon у моїх прикладах використовуються такі параметри:
--module-path <build-directory>/macos-aarch64-release/dist/org.openjdk.engine.javascript/lib --add-modules org.openjdk.engine.javascript --enable-native-access=org.openjdk.engine.javascript
Назва build directory залежить від платформи. Якщо код запускається не з кореня detroit-js проєкту, то для --module-path краще використати абсолютний шлях.
Демо 1: JSON → JS rules → Java record → JSON
Всім відомо, що у JDK нема інструментів для роботи із JSON-обʼєктами. Тому розглянемо наступну практичну вправу: моделюємо розрахунок знижки. Java лишає у себе доменний тип та обмеження, а JavaScript описує правило зміни стану обʼєкту. Java завантажує .js-файл один раз у статичному блоці:
private static final ScriptEngine ENGINE;
private static final Invocable SCRIPTS;
static {
try {
ENGINE = new ScriptEngineManager().getEngineByName("v8");
ENGINE.put("policy", new DiscountPolicy());
ENGINE.eval(Files.readString(Path.of("discount-rules.js")));
SCRIPTS = (Invocable) ENGINE;
} catch (Exception exception) {
throw new ExceptionInInitializerError(exception);
}
}
Функція описана в discount-rules.js отримує JSON тільки як строку. V8 виконує нативний JSON.parse(), викликає Java API для обмеження знижки й повертає звичайний JS object:
function calculateDiscount(orderJson) {
const order = JSON.parse(orderJson);
const requested = order.customer.tier === "GOLD" && order.subtotal >= 1000
? 0.15
: 0.0;
// JavaScript ---> Java: Java лишається джерелом істини для лімітів.
const approved = policy.capDiscount(order.customer.tier, requested);
policy.audit(`order=${order.id}, approved=${approved}`);
return {
orderId: order.id,
customerTier: order.customer.tier,
discount: approved,
total: order.subtotal * (1 - approved),
ruleVersion: "2026-08-10"
};
}
Java викликає функцію через Invocable, отримує JSObject і повертається до типізованого Java API:
var jsDecision = (JSObject) SCRIPTS.invokeFunction(
"calculateDiscount",
orderJson
);
var decision = new DiscountDecision(
(String) jsDecision.getMember("orderId"),
(String) jsDecision.getMember("customerTier"),
((Number) jsDecision.getMember("discount")).doubleValue(),
((Number) jsDecision.getMember("total")).doubleValue(),
(String) jsDecision.getMember("ruleVersion")
);
У зворотному напрямку JVM передає DiscountDecision у JS-функцію serializeDecision(). Вона явно відтворює plain JS object з accessor-методів Java record і викликає JSON.stringify():
function serializeDecision(decision) {
return JSON.stringify({
orderId: decision.orderId(),
customerTier: decision.customerTier(),
discount: decision.discount(),
total: decision.total(),
ruleVersion: decision.ruleVersion()
});
}
Повний шлях даних такий:
Java String → V8 JSON.parse() → JS object → Java DiscountPolicy → JS object → Java DiscountDecision → V8 JSON.stringify() → Java String
Звичайно ж, можна було використати Jackson, але у цьому демо цієї бібліотеки навмисно нема. JSON парсинг та серіалізація є відповідальністю JS/V8, а Java відповідає за доменний контракт DiscountDecision і критичний API DiscountPolicy.
Концептуально, цей приклад досить тривіальний. Логіка використання JVM <---> V8 полягала у простій підміні відповідальностей або спроба перекрити недоліки однієї технології перевагами іншої. Проте, в академічних цілях, досить цікаво подивитися на сам процес організації переходу між середовищами: передача обʼєктів JVM, виклик методів Java-класу, та передача даних від V8 назад у JVM.
Піднімемо рівень складності, треба спробувати розглянути приклад більш близький до реальності, щось що вже є десь у продакшені.
Лістинг
import java.nio.file.Files;
import java.nio.file.Path;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import org.openjdk.engine.javascript.JSObject;
public class DetroitJsonInteropDemo {
private static final ScriptEngine ENGINE;
private static final Invocable SCRIPTS;
static {
try {
ENGINE = new ScriptEngineManager().getEngineByName("v8");
ENGINE.put("policy", new DiscountPolicy());
String rules = Files.readString(Path.of("discount-rules.js"));
ENGINE.eval(rules);
SCRIPTS = (Invocable) ENGINE;
} catch (Exception exception) {
throw new ExceptionInInitializerError(exception);
}
}
public static void main(String[] args) throws Exception {
String orderJson = """
{
"id": "ord-42",
"subtotal": 1250.00,
"customer": {
"id": "cust-7",
"tier": "GOLD"
},
"items": [
{ "sku": "book-1", "quantity": 1, "price": 500.00 },
{ "sku": "book-2", "quantity": 1, "price": 750.00 }
]
}
""";
// Java -> JavaScript: передаємо JSON лише як String.
JSObject jsDecision = (JSObject) SCRIPTS.invokeFunction(
"calculateDiscount",
orderJson
);
// JavaScript object -> Java domain type.
DiscountDecision decision = DiscountDecision.from(jsDecision);
// Java -> JavaScript: передаємо Java record на серіалізацію.
String responseJson = (String) SCRIPTS.invokeFunction(
"serializeDecision",
decision
);
IO.println("Java domain object: " + decision);
IO.println("JSON created by V8: " + responseJson);
}
public record DiscountDecision(
String orderId,
String customerTier,
double discount,
double total,
String ruleVersion
) {
static DiscountDecision from(JSObject value) {
return new DiscountDecision(
(String) value.getMember("orderId"),
(String) value.getMember("customerTier"),
((Number) value.getMember("discount")).doubleValue(),
((Number) value.getMember("total")).doubleValue(),
(String) value.getMember("ruleVersion")
);
}
}
public static final class DiscountPolicy {
public double capDiscount(String tier, double requested) {
double limit = switch (tier) {
case "GOLD" -> 0.12;
case "SILVER" -> 0.08;
default -> 0.05;
};
return Math.min(requested, limit);
}
public void audit(String message) {
IO.println("[AUDIT] " + message);
}
}
}
Демо 2: мільйон записів і користувацька user-defined function (UDF)
Уявімо сервіс, який щодня обробляє великий потік замовлень, наприклад, результати імпорту з ERP, події з Kafka або записи, отримані курсором із бази даних. Кожен елемент має однакову базову структуру: дані клієнта, товар, суму, статус, час створення та кілька службових прапорців. За один запуск таких записів може бути мільйон або більше.
У цьому прикладі, Java-сервіс відповідає за читання даних, збереження результату, транзакції, доступ до інфраструктури та спостережуваність. Але бізнес-команда хоче самостійно змінювати частину логіки обробки: наприклад, визначати ставку VAT за країною клієнта, розраховувати підсумкову суму та позначати замовлення, які потрібно відправити на додаткову перевірку.
Саме тут виникає задача, схожа на JavaScript UDF у СУБД: користувач надає чисту функцію для одного запису, а host-застосунок безпечно застосовує її до великої колекції даних. На вході функція отримує один JS object row; на виході має повернути новий JS object із нормалізованими або обчисленими полями. Скрипт не читає БД, не пише у Kafka і не має доступу до Java-класів — усі ці дії лишаються відповідальністю Java.
Для прикладу візьмемо таку функцію:
row => {
const vatRate = row.customer.country === "UA" ? 0.20 : 0.21;
const amountWithVat = Number((row.amount * (1 + vatRate)).toFixed(2));
const risk = row.amount >= 1_500 || row.flags.expedited
? "REVIEW"
: "AUTO_APPROVED";
return {
id: row.id,
customerId: row.customer.id,
productSku: row.product.sku,
amountWithVat,
processingDecision: risk,
sourceStatus: row.status
};
}
У цьому прикладі JS не знає нічого про джерело даних. Він бачить лише один запис, умовно такого вигляду:
{
id: 42,
customer: { id: "cust-000042", tier: "GOLD", country: "UA" },
product: { sku: "sku-00042", category: "BOOKS" },
quantity: 2,
amount: 1500,
currency: "USD",
status: "PAID",
createdAt: "2026-08-10T12:00:00Z",
flags: { gift: true, expedited: false }
}
Натомість результатом буде обʼєкт, придатний для наступного етапу pipeline-у: його можна записати в іншу таблицю, відправити в Kafka або передати до сервісу risk review. Наприклад, для цього запису функція поверне суму з VAT та рішення REVIEW, оскільки сума перевищує встановлений поріг.
Є чотири важливі вимоги до такого сценарію:
- Функція має бути ізольована від host-застосунку. Користувацький код не повинен отримати connection до БД,
ClassLoader, файлову систему або Java service objects. - Мільйон рядків не можна передавати одним обʼєктом. Це збільшить пікове споживання памʼяті й зробить cancellation та контроль часу виконання грубими. Дані потрібно подавати невеликими batch-ами.
- Обробка має бути паралельною, але без спільного V8 state. Один V8 engine не може одночасно викликати з кількох потоків.
- Host повинен мати право зупинити UDF. Навіть випадковий
while (true)або надмірно складна функція не мають блокувати pipeline назавжди.
Тому в даній реалізації JVM не передає елементи колекції у V8 як Java POJO або Record. Вона формує JSON batch, а wrapper усередині V8 робить JSON.parse(batchJson), викликає rows.map(userTransform) і повертає результат через JSON.stringify(). Таким чином Java контролює I/O та batch lifecycle, а JS відповідає тільки за перетворення одного рядка.
Схематично це виглядає наступним чином:
source: DB cursor / Kafka / file │ ▼ Java reads a small batch │ JSON ▼ V8: rows.map(userTransform) │ JSON ▼ Java writes the transformed batch to a sink
У цьому демо, для такого типу UDF, будемо використовувати v8-no-java, а не звичайний v8: Java не додає в bindings жодного обʼєкта. У демо також є ліміт розміру source code, watchdog на виконання batch і окремий V8 engine для кожного обробника даних.
Зауважу, ніхто не казав, що тепер усюди треба використовувати V8, сучасні JDK та JVM надають інструментарій і оптимізують навіть погано написаний код. Проте, існує клас задач, в яких є необхідність ізолювати середовище JVM від стороннього коду. Ви ж не забули, що в нас вже нема SecuritManager, правда? А нова бібліотека JDK Classfile API все ще не дуже розповсюджена для аналізу байт-коду. Тому, використання V8 може стати дуже в пригоді, коли ви хочете дати змогу власним користувачам власноруч визначати як саме вони хочуть обробляти дані in-place.
Лістинг
import java.time.Duration;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
import java.util.stream.IntStream;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import org.openjdk.engine.javascript.JSObject;
import org.openjdk.engine.javascript.V8ExecutionControl;
import org.openjdk.engine.javascript.V8Undefined;
/**
* Demonstrates a constrained JavaScript UDF for a large tabular data set.
*
* <p>This example deliberately uses the {@code v8-no-java} engine. User code
* cannot call Java classes or receive Java service objects. It sees only data
* parsed from JSON and ECMAScript built-ins.</p>
*/
public class SafeV8TabularProcessor {
private static final int RECORD_COUNT = 1_000_000;
private static final int BATCH_SIZE = 1_000;
private static final Duration MAX_BATCH_RUNTIME = Duration.ofMillis(100);
private static final AtomicBoolean OUTPUT_PREVIEW_LOGGED = new AtomicBoolean();
public static void main(String[] args) throws Exception {
var startedAt = System.nanoTime();
IO.println("[START] %,d records; batch size %,d; timeout %d ms"
.formatted(RECORD_COUNT, BATCH_SIZE, MAX_BATCH_RUNTIME.toMillis()));
// In a real application these JSON rows would be streamed from a file,
// database cursor, or message queue instead of being retained in memory.
var records = IntStream.range(0, RECORD_COUNT)
.parallel()
.mapToObj(SafeV8TabularProcessor::jsonRow)
.toList();
IO.println("[INPUT] Generated %,d JSON records".formatted(records.size()));
IO.println("[INPUT] First record: " + records.getFirst());
var userFunction = """
row => {
const vatRate = row.customer.country === "UA" ? 0.20 : 0.21;
const amountWithVat = Number((row.amount * (1 + vatRate)).toFixed(2));
const risk = row.amount >= 1_500 || row.flags.expedited
? "REVIEW"
: "AUTO_APPROVED";
return {
id: row.id,
customerId: row.customer.id,
customerTier: row.customer.tier,
productSku: row.product.sku,
category: row.product.category,
quantity: row.quantity,
currency: row.currency,
amount: row.amount,
amountWithVat,
processingDecision: risk,
sourceStatus: row.status,
createdAt: row.createdAt
};
}
""";
var sandboxes = new ConcurrentLinkedQueue<SandboxedUdf>();
var sandboxPerWorker = ThreadLocal.withInitial(() -> {
try {
var sandbox = new SandboxedUdf(userFunction);
sandboxes.add(sandbox);
return sandbox;
} catch (Exception exception) {
throw new IllegalStateException("Cannot initialize V8 worker", exception);
}
});
var processed = new LongAdder();
var batchCount = (records.size() + BATCH_SIZE - 1) / BATCH_SIZE;
var completedBatches = new AtomicInteger();
try {
IntStream.range(0, batchCount)
.parallel()
.forEach(batchNumber -> processBatch(
records,
batchNumber,
sandboxPerWorker.get(),
processed,
completedBatches,
batchCount
));
} finally {
sandboxes.forEach(SandboxedUdf::close);
}
var elapsed = Duration.ofNanos(System.nanoTime() - startedAt);
IO.println(
"[DONE] Processed %,d records in %.3f s using %d V8 workers".formatted(
processed.sum(),
elapsed.toNanos() / 1_000_000_000.0,
sandboxes.size()
));
}
private static void processBatch(
List<String> records,
int batchNumber,
SandboxedUdf sandbox,
LongAdder processed,
AtomicInteger completedBatches,
int batchCount
) {
var from = batchNumber * BATCH_SIZE;
var to = Math.min(from + BATCH_SIZE, records.size());
var batchJson = "[" + String.join(",", records.subList(from, to)) + "]";
try {
// The result is already JSON, ready for a thread-safe file, Kafka, or HTTP sink.
var transformedBatch = sandbox.applyBatch(batchJson);
sendToOutputSink(transformedBatch);
processed.add(to - from);
var completed = completedBatches.incrementAndGet();
var progressStep = Math.max(1, batchCount / 10);
if (completed % progressStep == 0 || completed == batchCount) {
IO.println(
"[PROGRESS] %3d%% - %,d / %,d records".formatted(
completed * 100 / batchCount,
processed.sum(),
records.size()
));
}
} catch (Exception exception) {
throw new IllegalStateException("Failed to process batch " + batchNumber, exception);
}
}
private static String jsonRow(int index) {
var tier = switch (index % 3) {
case 0 -> "GOLD";
case 1 -> "SILVER";
default -> "STANDARD";
};
var country = switch (index % 4) {
case 0 -> "UA";
case 1 -> "PL";
case 2 -> "DE";
default -> "US";
};
var category = switch (index % 4) {
case 0 -> "BOOKS";
case 1 -> "ELECTRONICS";
case 2 -> "HOME";
default -> "SPORT";
};
var status = index % 10 == 0 ? "PENDING" : "PAID";
return """
{"id":%d,"customer":{"id":"cust-%06d","tier":"%s","country":"%s"},
"product":{"sku":"sku-%05d","category":"%s"},"quantity":%d,
"amount":%d,"currency":"USD","status":"%s",
"createdAt":"2026-08-10T12:00:00Z","flags":{"gift":%b,"expedited":%b}}
""".formatted(
index,
index % 100_000,
tier,
country,
index % 50_000,
category,
index % 5 + 1,
index % 2_000,
status,
index % 7 == 0,
index % 13 == 0
).replaceAll("\\R", "");
}
private static void sendToOutputSink(String transformedBatch) {
if (OUTPUT_PREVIEW_LOGGED.compareAndSet(false, true)) {
var previewLength = Math.min(transformedBatch.length(), 500);
IO.println("[OUTPUT] First transformed batch: "
+ transformedBatch.substring(0, previewLength)
+ (previewLength < transformedBatch.length() ? "…" : ""));
}
// Production code can write this JSON batch to a thread-safe sink.
}
static final class SandboxedUdf implements AutoCloseable {
private final ScriptEngine engine;
private final V8ExecutionControl executionControl;
private final JSObject applyBatch;
private final ScheduledExecutorService watchdog;
SandboxedUdf(String userFunction) throws Exception {
if (userFunction.length() > 16_384) {
throw new IllegalArgumentException("User function exceeds 16 KiB");
}
engine = new ScriptEngineManager().getEngineByName("v8-no-java");
if (engine == null) {
throw new IllegalStateException("Project Detroit v8-no-java engine is unavailable");
}
executionControl = (V8ExecutionControl) engine;
watchdog = Executors.newSingleThreadScheduledExecutor();
IO.println("[WORKER] V8 sandbox initialized on %s".formatted(Thread.currentThread().getName()));
// The supplied code must be a function expression, for example:
// row => ({ id: row.id, amount: row.amount * 1.2 })
// It is evaluated only in the no-Java V8 engine.
applyBatch = (JSObject) engine.eval("""
(() => {
const userTransform = (%s);
if (typeof userTransform !== "function") {
throw new TypeError("Expected a JavaScript function");
}
return function applyBatch(batchJson) {
const rows = JSON.parse(batchJson);
if (!Array.isArray(rows)) {
throw new TypeError("Expected a JSON array");
}
return JSON.stringify(rows.map(userTransform));
};
})()
""".formatted(userFunction));
}
String applyBatch(String batchJson) throws Exception {
var completed = new AtomicBoolean(false);
var timeout = watchdog.schedule(
() -> executionControl.requestInterrupt(() -> {
if (!completed.get()) {
executionControl.terminateExecution();
}
}),
MAX_BATCH_RUNTIME.toMillis(),
TimeUnit.MILLISECONDS
);
try {
return (String) applyBatch.call(V8Undefined.INSTANCE, batchJson);
} finally {
completed.set(true);
timeout.cancel(false);
}
}
@Override
public void close() {
watchdog.shutdownNow();
}
}
}
ThreadLocal
У цьому демо, я використовую ThreadLocal для того, щоб досягти необхідного рівня ізоляції при обробці даних. Тобто, як зазначалося раніше, або один V8 на все, або по одному V8 на задачу.
Проте, ThreadLocal — не магія, а спосіб не ділити один isolate між потоками. Ціна цього підходу — більше engine-ів, стартового часу та памʼяті (тому й ініціалізація йде заздалегідь, на старті застосунку). Для великих систем може бути кращим обмежений engine pool (за аналогом ForkJoinPool і визначеного фактора паралелізму) або окремі worker-процеси. А враховуючи той факт, що ми маємо справу із JNI, то можна сміливо очікувати блокування та pinning потоків при переході від JVM до V8.
Демо 3: policy-функція для фільтрації HTTP запитів
Тепер розглянемо інший клас задач, дуже близький мені, бо я маю із цим справу із різними видами API Gateway майже щодня — перевірка та фільтрація HTTP трафіку.
Частина перевірок є технічною та стабільною: TLS, аутентифікація та авторизація, rate limiting, максимальний розмір даних у запиті на рівні web серверу. Інші атрибути запиту можуть часто змінюватися: список небажаних User-Agent, правила для параметрів запиту (query), заборонені HTTP методи для конкретного виду бізнес-логіки або реакція на підозрілий трафік під час інциденту.
Якщо всі такі правила реалізувати у Java-коді, навіть невелика зміна потребує повної перезборки. А якщо ви є постачальником такого рішення, то у вас повинен бути досить гнучкий спосіб описувати правила прийняття HTTP запиту. З іншого боку, передавати користувацькому (UDF) JS повноцінний обʼєкт, що містить стан та дані HTTP-запиту (незалежно від фреймворку реалізації HTTP серверу) або весь Spring Context небезпечно: скрипт отримає значно більше свобод, можливостей і даних, ніж потрібно для прийняття рішення.
Нехай Java-застосунок готує мінімальний snapshot HTTP-запиту. Він повинен містити лише метод, шлях (path), оголошений Content-Length, фактичний розмір уже прочитаного body, User-Agent і параметри запиту (можна ще додати усі заголовки, але й цього достатньо):
{
method: "POST",
path: "/checkout",
contentLength: 124,
bodySize: 124,
agent: "Mozilla/5.0",
query: {
coupon: ["summer"]
}
}
JS-функція, що приймає рішення щодо HTTP запиту, має один дуже простий контракт: повернути вердикт в Java-застосунок, що використається в послідовності перевірок HTTP запиту (щось, на кшталт middleware, але без можливості модифікації самого HTTP запиту).
{ accepted: true, reason: "OK" }
// або
{ accepted: false, reason: "Content-Length does not match body size" }
На основі поля accepted Java-застосунок вирішує, чи передавати запит далі по ланцюжку виконання. Водночас поле reason потрібне для аудиту, метрик і зрозумілої відповіді клієнту.
У Java HTTP запит описаний за допомогою java Record класу:
public record HttpRequest(
String method,
String path,
long contentLength,
int bodySize,
String agent,
Map<String, List<String>> queryParameters
) {}
Але JS policy-функція не отримує Java Record напряму. Java серіалізує HTTP запит у JSON структуру, а V8 один раз робить JSON.parse() і створює нативний JS обʼєкт. Це гарантує, що contentLength, bodySize, agent і query мають звичайні JS типи, а не Java обʼєкти.
var jsRequest = (JSObject) REQUEST_FROM_JSON.call(
V8Undefined.INSTANCE,
toJson(request)
);
var jsDecision = (JSObject) ENGINE.invokeFunction("filterRequest", jsRequest);
var decision = new FilterDecision(
(Boolean) jsDecision.getMember("accepted"),
(String) jsDecision.getMember("reason")
);
Такий формат перевірок сам по-собі не формує HTTP відповідь, не завершує зʼєднання і не має сторонніх ефектів впливу на сам запит (як це робить middleware). У даному прикладі JS-функція перевірки HTTP запиту відхиляє заборонені TRACE і CONNECT, тіло запиту у понад 1 MiB, розбіжність між Content-Length і фактичним розміром тіла, та й відомі «scanner user agent», надлишковість параметрів HTTP запиту (кількість, їхні назви та розмір значень) і кілька показових підозрілих значень:
function filterRequest(request) {
if (request.method === "TRACE" || request.method === "CONNECT") {
return reject(`HTTP method ${request.method} is not allowed`);
}
if (request.bodySize > MAX_BODY_BYTES) {
return reject("Request body exceeds 1 MiB limit");
}
if (request.contentLength >= 0 && request.contentLength !== request.bodySize) {
return reject("Content-Length does not match body size");
}
if (typeof request.agent !== "string" || BLOCKED_AGENTS.test(request.agent)) {
return reject("Blocked user agent");
}
for (const [name, values] of Object.entries(request.query ?? {})) {
for (const value of values) {
if (SUSPICIOUS_QUERY.test(value)) {
return reject(`Suspicious value for query parameter '${name}'`);
}
}
}
return { accepted: true, reason: "OK" };
}
[FILTER] ACCEPTED POST /checkout OK Mozilla/5.0
[FILTER] REJECTED POST /checkout Content-Length does not match body size Mozilla/5.0
[FILTER] REJECTED POST /upload Request body exceeds 1 MiB limit Mozilla/5.0
[FILTER] REJECTED GET /catalog Blocked user agent sqlmap/1.8
[FILTER] REJECTED GET /search Suspicious value for query parameter 'q' Mozilla/5.0
[FILTER] REJECTED TRACE /health HTTP method TRACE is not allowed Mozilla/5.0
Тут також використано v8-no-java: JS не бачить Java обʼєкти, стан сервера, файлову систему або мережу. Ця функція контролю запиту отримує лише переформатований HTTP запит й повертає мінімальний контракт.
Здавалося б, ось і повноцінний API Gateway, бери й пиши нові правила до нього, хоча б я й віддав перевагу повноцінному WAF (web application firewall). У цьому прикладі Content-Length є лише декларацією клієнта, а ліміт тіла запиту слід застосовувати на рівні HTTP серверу ще до повного читання body. Реальний захист потребує більш складних перевірок, нормалізації URL, rate limiting, перевірених security controls, CORS, і журналювання з аудитом. У цьому прикладі, така JS policy-функція, як критерій прийняття, може бути додатковим шаром бізнес- або application-level фільтрації, але не заміною безпеки по всій моделі OSI.
Цей приклад показує формат застосування трохи інакшим, відмінним від двох попередніх прикладів, де основною метою була трансформація об’єкта за допомогою JS user-defined functions. Основна мета була показати яким чином можна реалізувати досить гнучку систему перевірки запитів, яка ну дуже схожа на звичний для нас із вами WAF.
Лістінг
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import javax.script.ScriptEngineManager;
import org.openjdk.engine.javascript.JSObject;
import org.openjdk.engine.javascript.V8ScriptEngine;
import org.openjdk.engine.javascript.V8Undefined;
/**
* Demonstrates an HTTP request filter implemented as a JavaScript policy.
*
* <p>The policy receives a native V8 object with request data and returns
* {@code { accepted: boolean, reason: string }}. The no-Java engine ensures
* the script cannot call Java classes or receive server-side objects.</p>
*/
public class HttpRequestFilterDemo {
private static final V8ScriptEngine ENGINE;
private static final JSObject REQUEST_FROM_JSON;
static {
try {
ENGINE = (V8ScriptEngine) new ScriptEngineManager()
.getEngineByName("v8-no-java");
if (ENGINE == null) {
throw new IllegalStateException("Project Detroit v8-no-java engine is unavailable");
}
var rules = Files.readString(Path.of("http-request-filter.js"));
ENGINE.eval(rules);
REQUEST_FROM_JSON = (JSObject) ENGINE.eval("json => JSON.parse(json)");
} catch (Exception exception) {
throw new ExceptionInInitializerError(exception);
}
}
public static void main(String[] args) throws Exception {
var requests = List.of(
new HttpRequest(
"POST",
"/checkout",
124,
124,
"Mozilla/5.0",
Map.of("coupon", List.of("summer"))
),
new HttpRequest(
"POST",
"/checkout",
128,
124,
"Mozilla/5.0",
Map.of()
),
new HttpRequest(
"POST",
"/upload",
1_500_000,
1_500_000,
"Mozilla/5.0",
Map.of()
),
new HttpRequest(
"GET",
"/catalog",
0,
0,
"sqlmap/1.8",
Map.of("q", List.of("java"))
),
new HttpRequest(
"GET",
"/search",
0,
0,
"Mozilla/5.0",
Map.of("q", List.of("<script>alert(1)</script>"))
),
new HttpRequest(
"TRACE",
"/health",
0,
0,
"Mozilla/5.0",
Map.of()
)
);
IO.println("[START] Evaluating %d HTTP requests%n".formatted(requests.size()));
for (var request : requests) {
var decision = filter(request);
var outcome = decision.accepted() ? "ACCEPTED" : "REJECTED";
IO.println(
"[FILTER] %-8s %-18s %-42s %s%n".formatted(
outcome,
request.method() + " " + request.path(),
decision.reason(),
request.agent()
));
}
}
private static FilterDecision filter(HttpRequest request) throws Exception {
var jsRequest = (JSObject) REQUEST_FROM_JSON.call(
V8Undefined.INSTANCE,
toJson(request)
);
var jsDecision = (JSObject) ENGINE.invokeFunction("filterRequest", jsRequest);
return new FilterDecision(
(Boolean) jsDecision.getMember("accepted"),
(String) jsDecision.getMember("reason")
);
}
private static String toJson(HttpRequest request) {
var json = new StringBuilder("{\"method\":");
appendJsonString(json, request.method());
appendStringField(json, "path", request.path());
json.append(",\"contentLength\":").append(request.contentLength());
json.append(",\"bodySize\":").append(request.bodySize());
appendStringField(json, "agent", request.agent());
json.append(",\"query\":{");
var firstParameter = true;
for (var entry : request.queryParameters().entrySet()) {
if (!firstParameter) {
json.append(',');
}
appendJsonString(json, entry.getKey());
json.append(':').append('[');
for (var index = 0; index < entry.getValue().size(); index++) {
if (index > 0) {
json.append(',');
}
appendJsonString(json, entry.getValue().get(index));
}
json.append(']');
firstParameter = false;
}
return json.append("}}").toString();
}
private static void appendStringField(StringBuilder json, String name, String value) {
json.append(',');
appendJsonString(json, name);
json.append(':');
appendJsonString(json, value);
}
private static void appendJsonString(StringBuilder json, String value) {
json.append('"');
for (var index = 0; index < value.length(); index++) {
var character = value.charAt(index);
switch (character) {
case '"' -> json.append("\\\"");
case '\\' -> json.append("\\\\");
case '\b' -> json.append("\\b");
case '\f' -> json.append("\\f");
case '\n' -> json.append("\\n");
case '\r' -> json.append("\\r");
case '\t' -> json.append("\\t");
default -> {
if (character < 0x20) {
json.append("\\u%04x".formatted((int) character));
} else {
json.append(character);
}
}
}
}
json.append('"');
}
public record HttpRequest(
String method,
String path,
long contentLength,
int bodySize,
String agent,
Map<String, List<String>> queryParameters
) {}
public record FilterDecision(boolean accepted, String reason) {}
}
Що ж Project Detroit може дати Java-розробнику?
| Сценарій | Практична користь |
|---|---|
| Rules engine | Java зберігає транзакції, domain model та інваріанти; JS-файл описує поведінку, яка часто змінюється. |
| JSON / ETL-трансформації | Нативні JSON API, обʼєкти й масиви, лаконічні map, filter, reduce. |
| Плагіни та UDF | Можна надати користувачу невеликий, контрольований простір для трансформацій. |
| Локальна інтеграція | Для локальної скриптової логіки немає HTTP hop і окремого Node.js deployment. |
| Знайомий Java API | ScriptEngine, Bindings, Invocable і discovery через ServiceLoader. |
Project Detroit особливо доречний, коли Java-застосунок лишається головним: він володіє даними, доступом до БД, транзакціями та авторизацією. JavaScript у такій моделі не замінює Java, а дає компактну мову для частини поведінки, яка часто змінюється.
Обмеження, про які не слід забувати
Переваги не скасовують складності інтеграції двох рушіїв.
Ранній статус і build cost
Detroit JS поки збирається з вихідного коду, потребує native toolchain і V8 tooling. Сам набір API, та процес збірки можуть змінюватися, допоки нема готових офіційних білдів, то ми з вами маємо справу безпосередньо з зовсім новою технологією. Це не залежність, яку сьогодні варто додати у production-сервіс без технічної оцінки та відповідної експертизи. Хоча, реліз початкової підтримки саме для JS вже каже про досить багато, і ця стаття-огляд тому натяк і підтвердження.
Два heap і межа даних
Java heap і V8 heap живуть поруч, але відокремлено. Часті переходи Java <---> JS, конвертація великих обʼєктів, JSON і GC можуть коштувати дорожче за нативний Java-код. Потрібні benchmark-и саме на власному payload і профілі навантаження.
Безпека користувацького коду
v8-no-java прибирає місток назад в JVM, але не гарантує повної безпеки користувацького коду. Watchdog також не замінює ізоляцію процесу. V8 прямо рекомендує запускати недовірений JS в окремому процесі поруч лише з даними, які цей код має бачити. V8 guidance for untrusted code
Для використання V8 із UDF в реальних застосунках потрібні щонайменше:
- окремий worker-процес або контейнер;
- вимкнена мережа й мінімальний доступ до файлової системи;
- OS/container/process limits для CPU та RAM (ulimits);
- знищення worker після timeout, а не нескінченне повторне використання того самого процесу;
- ліміти на фактичний розмір коду, та розмір вхідних та вихідних даних;
- валідація JSON schema до й після трансформації;
- аудит версії UDF, автора, execution time та помилок без логування чутливих payload-ів.
Project Detroit не замінює Node.js, Python або мікросервісну архітектуру
Якщо JS потребує модулів Node.js і повного Node API, а Python — специфічного environment з бібліотеками та GPU, окремий рушій усе ще є здоровішим рішенням.
Висновки
Project Detroit повертає Java до поліглотного scripting, але робить це інакше, ніж Nashorn. Його головна ідея це не повторно реалізувати іншу мову на JVM, а інтегрувати runtime, навколо якого вже існує велика екосистема.
Для Java-розробника це цікаво не як «перехід на JavaScript», а як спосіб додати V8 до стабільного Java-ядра: для динамічних правил, JSON-трансформацій, контрольованих UDF і вузько-направлених policy-функцій. Перший приклад показує trusted JS rules, де Java лишає за собою інваріанти. Другий — показує, що UDF вимагає не лише eval(), а й окремого engine на worker, timeout, меж даних і process isolation. Третій — демонструє, як передавати у JS складний HTTP запит, але не розмивати межу між policy та справжньою HTTP безпекою.
Project Detroit варто пробувати, читати його код і давати feedback OpenJDK через maillist. Але на цьому етапі технологію потрібно розглядати тверезо: вимірювати продуктивності, не передавати зайвий Java API у скрипт, не ділити один V8 isolate між потоками та не вважати v8-no-java заміною повноцінного sandbox.
Джерела
- Project Detroit
- OpenJDK Detroit JS
- OpenJDK Detroit Python
- Java Scripting API (
javax.script) - V8 documentation
- V8 threading / Locker
- V8 guidance for untrusted code
- JEP 335: Deprecate the Nashorn JavaScript Engine
- JEP 372: Remove the Nashorn JavaScript Engine
Стежте за новинами та оновленнями за посиланнями:
- dev.java (спеціальний портал Oracle для поглиблення ваших знань з Java та участі у комʼюніті).
- inside.java (суто технічний блог, який ведуть архітектори та інженери, що розроблюють Java).
- Java on YouTube (усе про Java та екосистему).
- OpenJDK mailing lists (місце, де можна дізнатися поточний стан речей у OpenJDK-комʼюніті).
- Підписуйтесь на OpenJDK і Java on Twitter.
11 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівЯк лодку назвали так і попливе,- «Detroit» — місто рока:)
бекенд на JS — гріх.
а ніхто не пише, про те, що треба бек на JS писати, власне просто V8 для цього й не достатньо.
на JS можна написати бекенд для адмінки, або бекенд для РОС.
Nashorn 2.0?
випиляний ще аж з11-ї Джави
Nashorn випиляний, я пишу, що це типу Nashorn 2.0
Автор стверджує, що ні, бо це враппер над існуючими рушіями — а не переімплементація, яку доведеться викинути як Nashorn — бо дорого мейнтейнити
Але побачим. Бо в мене питання ще лишається для чого воно все, хто користувався Nashorn в свій час? Неочевидно, які бізнес проблеми він вирішував
з додаванням JS в JDK було, нмп, як з ШІ, блокчейном, клаудом, татухами — мода.
upd: за десятиріччя в джава-ентепрайзі, я жодного разу не зустрічав чогось корисного, зробленого на Nashorn або адекватного JS бекенду (його вкручували коли джавістів впадлу було найняти, а потім воно здихало)
не виключаю, що javax.script у оракула просто ще руки не дійшли випиляти.
усьому свій час
Nashorn був проблемою, бо був «майже» 100% пропрієтарним і підтримувати мовну специфікацію і рантайм який немає відношення до Java було надто дорогим. Тому, ми прийняли рішення позбутися його, і зробити щось концептуально нове. Добре, що обидва V8 та CPython можна використовувати з C ABI (за одним зауваженням, що V8 постачається із C++ API, тому єдиним виходом було робити підтримку зараз за допомогою JNI, а не FFM API).
Відносно мети застосування, то в Oracle Database і Autonomous Database (OCI) вже давно була підтримка кастомних користувацьких функцій відносно рядків чи view. Як воно було реалізовано я не буду коментувати. Скажу лише те, що публічні анонси кажуть про те, хто технологія вже знайшла підтримку. Тому, власне, і існує ця стаття.
З власного досвіду я скажу так, Демо № 3 як раз показує як подібну технологію можна застосувати в імплементації WAF та API Gateway. У мене була задача зробити так, щоб фільтрація запитів відбувалася саме по складних правилах, а не лише по CORS та CSRF-превенції. Треба було мати змогу зазирнути в сам запит і не навантажувати сервіс запитами, які не мають бути взагалі оброблені, навіть із негативної відповіддю типу HTTP 400, 404 чи щось ще.