Как подружить JavaScript (node.js) и Java

Месяца два назад появилась на горизонте задача «подружить» существующий jar-ник и с nodejs. А точнее, сделать так, чтобы методы java-классов можно было вызывать из javascript-кода. Ну и конечно, необходимо все обернуть в объекты предметной области вместе с привидениями типов, читабельными исключениями, асинхронными вызовами методов (при необходимости) и т.д.

Задача, изначально казалась довольно специфической и было опасение, что очень многое придется писать с нуля, но нет. Уже три года как существует node-java (API-мост для подключения с существующими Java API). Ставится через обычный npm install java. Подключение модуля и его конфигурация хорошо расписаны в соответствующем разделе Readme.

Что же дается «из коробки» (тут и далее var java = require("java")):

  • Подключение jar-файлов и целых папок с ними (через java.classpath)
  • Настройка JVM через массив опций options (пример: java.options.push('-Xmx1024m'))
  • Хуки на инициализацию JVM (before, after)
  • Синхронные и асинхронные вызовы методов (см. суффикс Async)
  • Работа с Java-типами (проверка типа, создание примитивов)

Другими словами, «из коробки» дается все, что надо для решения поставленной задачи (выполнение java-кода из js).

Что же необходимо сделать помимо данного? Начнем с того, что Java — это язык со строгой статической типизацией, а JS — язык с нестрогой динамической типизацией. Соответственно, если в JS внутри функции можно сделать проверку, число тебе пришло первым параметром или строка, то в Java будет перегрузка метода, где в одном случае первым параметром будет число, а во втором — строка. А если вариантов типов данных будет больше, то больше будет и перегрузок. Ну и конечно не стоит забывать, что в Java типов данных больше, чем в JS (пример: Number в JS и Byte, Double, Float, Long и т.д. в Java). В условиях данного задания это играет на руку JS. Вообще, с примитивами проблем практически нет.

Куда интереснее обстоят дела с объектами и массивами. Сопоставление JS → Java сделали таким: Objectjava.util.HashMap, []java.util.ArrayList, Datejava.sql.Timestamp (можно и java.util.Date). Для HashMap и ArrayList для каждого свойства/элемента так же необходимо сделать приведение типов JS → Java. То есть, функция, которая делает все эти преобразования, должна быть рекурсивной:

jsToJava: function (value) {
    var self = this;
    var toString = {}.toString();
    if ('[object Array]' === toString.call(value)) {
      var arrayList = java.newInstanceSync('java.util.ArrayList');
      value.forEach(function (v) {
        arrayList.addSync(self.jsToJava(v));
      });
      return arrayList;
    }
    if('[object Date]' === toString.call(value)) {
      return java.newInstanceSync('java.sql.Timestamp', java.newLong(value.getTime()));
    }
    if ('[object Object]' === toString.call(value)) {
      var hashMap = java.newInstanceSync('java.util.HashMap');
      Object.keys(value).forEach(k => {
        hashMap.putSync(self.jsToJava(k), self.jsToJava(value[k]));
      });
      return hashMap;
    }
    return value;
};

Для определения типов можно использовать не просто {}.toString.call, а сделать какой-нибудь фасад вида isObject/isArray/isDateи т.д. А если делать этого не хочется, то по запросу type checking в npm есть очень много готовых пакетов. Из интересных особенностей в данной функции стОит обратить внимание на java.newInstanceSync (доки). Суффикс Sync говорит о том, что метод вызывается синхронно. Тоже самое касается addSync для ArrayList и putSync для HashMap.

Обратное сопоставление типов немного отличается. В первую очередь, {}.toString для java.newInstanceSync('java.util.List') вернет [object nodeJava_java_util_ArrayList]. Префикс [object nodeJava_ есть у любого JS-объекта, соответствующему Java-классу. Это стоит иметь ввиду при сопоставлении типов. Потому что, если объект не является экземпляром Java-класса, то с ним ничего делать не надо. Для мап (java.util.Map) и списков (java.util.List) нужно сделать сопоставление типов для ключей и значений:

javaToJs: function (value) {
    // not a Java-object
    if ({}.toString.call(value).indexOf('[object nodeJava_') !== 0) {
      return value;
    }
    // java.lang.Number -> Number
    if (java.instanceOf(value, 'java.lang.Number')) {
      return value.doubleValueSync();
    }
    // java.util.Date -> Date
    if (java.instanceOf(value, 'java.util.Date')) {
      return new Date( value.getYearSync() + 1900, value.getMonthSync(), value.getDateSync(), value.getHoursSync(), value.getMinutesSync(), value.getSecondsSync());
    }
    // java.util.List -> Array
    if (java.instanceOf(value, 'java.util.List')) {
      let ret = [];
      let i = value.iteratorSync();
      while (i.hasNextSync()) {
        ret.push(this.javaToJs(i.nextSync()));
      }
      return ret;
    }
    // java.util.Map -> Object
    if (java.instanceOf(value, 'java.util.Map')) {
      let ret = {};
      let i = value.keySetSync().iteratorSync();
      while (i.hasNextSync()) {
        let k = this.javaToJs(i.nextSync());
        ret[k] = this.javaToJs(value.getSync(k));
      }
      return ret;
    }
    return value;
};

В двух вышеописанных функциях затронуты только те типы данных, которые использовались в Java-приложении. Возможно, что под другие цели потребуются дополнительные преобразования.

В коде выше использовался метод newInstance(Sync), а как быть со статическими свойствами Java-классов? Проблем нет никаких. Из «коробки» даются методы getStaticFieldValue и setStaticFieldValue. По названиям понятно, кто и что делает. Если нет — то см. доки1 и доки2.

Идем дальше. Помимо типов данных есть еще исключения. JS-разработчик не хочет видеть в stacktrace NullPointerException или же еще нечто, что никак не вписывается в JS. «Could not find method … Possible matches: …» — всего лишь значит, что метод вызывается с данными не тех типов. Лучше это все обернуть в более читабельные исключения. Например, сделать whiteList/blackList Exceptions. Например:

function toComaList(arr) {
  return arr && arr.length ? arr.map(item => `"${item}"`).join(', ') : '';
};
 
function digitToSequenceNumber(index) {
  const m = { '1': '1st', '2': '2nd', '3': '3rd' };
  return m[index] ? m[index] : (index + 'th');
};
 
function TypeWhiteListError(allowedTypes, valueType, methodName, index) {
  allowedTypes = Array.isArray(allowedTypes) ? allowedTypes : [allowedTypes];
  this.name = 'TypeWhiteListError';
  this.stack = (new Error()).stack;
  this.message = `"${valueType}" is not allowed for "${methodName}" ${digitToSequenceNumber(index)} argument. Only ${toComaList(allowedTypes)} ${allowedTypes.length === 1 ? ' is' : ' are'} allowed!`;
};
TypeWhiteListError.prototype = Object.create(Error.prototype);
TypeWhiteListError.prototype.constructor = TypeWhiteListError;
 
 
function TypeBlackListError(disallowedTypes, methodName, index) {
  disallowedTypes = Array.isArray(disallowedTypes) ? disallowedTypes : [disallowedTypes];
  this.name = 'TypeBlackListError';
  this.stack = (new Error()).stack;
  this.message = `${toComaList(disallowedTypes)} ${(disallowedTypes.length === 1 ? ' is' : ' are')} not allowed for "${methodName}" ${digitToSequenceNumber(index)} argument.`;
};
TypeBlackListError.prototype = Object.create(Error.prototype);
TypeBlackListError.prototype.constructor = TypeBlackListError;

Опытный nodejs-разработчик скажет, что есть же util.inherits и будет прав. Так что лучше не самим лезть в prototype, а написать:

var util = require('util');
util.inherits(TypeWhiteListError, Error);
util.inherits(TypeBlackListError, Error);

Теперь, если в функции myFunc вторым параметром ожидается строка или число, а передано массив (ну так получилось :)), то , при выбросе TypeWhiteListError, сообщение об ошибке будет таким: '"array" is not allowed for "myFunc" 2nd argument. Only "string", "number" are allowed!'. И ниже будет stacktrace.

Если же в функцию myFunc2 первым параметром можно передавать все, кроме массива и объекта, но передано было что-то из этих двух типов, то, при выбросе TypeBlackListError, сообщение об ошибке будет таким: '"array", "object" are not allowed for "myFunc2" 1st argument.'.

Скорее всего, помимо «простых» проверок типов, понадобится еще проверять и массивы на принадлежность элементов к одному типу (или нескольким):

checkEachType (list, neededTypes) {
  if (!Array.isArray(list)) {
    return false;
  }
  var l = list.length;
  if (!l) {
    return false;
  }
  neededTypes = Array.isArray(neededTypes) ? neededTypes : [neededTypes];
  for (var i = 0; i < l; i++) {
    if (neededTypes.indexOf(getType(list[i])) === -1) {
      return false;
    }
  }
  return true;
}

Функция getType возвращает имя типа переменной. Можно написать свою на основе {}.toString.call, а можно и взять нечто готовое. Выше я писал, что в npm полно модулей с данным функционалом. Некоторые даже умеют проверять constructor.name для объектов.

Последний пункт (из тех, которые не привязаны к предметной области), о котором пришлось задуматься — это слова типа caller, arguments и т.д. То есть, зарезервированные в JS слова. Почему о них надо задуматься? Потому что может возникнуть ситуация, что в Java-классе есть метод caller. node-java не сможет его правильно конвертировать в js. Об этом, кстати, написано в доках. Решение, предложенное авторами node-java — это суффикс ifReadOnlySuffix (задается в java.asyncOptions), значение которого подставляется в конец «конфликтных» имен. Просто и понятно.

После усваивания всех «общих» тонкостей по работе с node-java, остаются только «проектные» детали, которые попадают под NDA.

Как небольшое заключение: задача интеграции модулей одного языка в другой — не такая уж редкая, как мне казалось вначале. И реализация гораздо интереснее, чем выглядит ее условие 🙂

,

Оставить комментарий

Top ↑ | Main page | Back