Простой Java JDBC с использованием Javascript

Моделирование бэкэнда JDBC с помощью JavaScript

В этой эпопее мы рассмотрим компонент доступа к базе данных приложения — подключение к базе данных с использованием простого JDBC API Java и Nashorn с JDK 10, который является реализацией Java ES5 JavaScript. Существует множество вариантов технологий баз данных, и все они выполняют свою работу уникальными способами. Мы начнем с реляционной базы данных и исследуем другие технологии в других эпосах. Давайте приступим!

Давайте смоделируем серверную часть для простого приложения Todo. Для этой эпопеи мы будем использовать простой JDBC для обработки постоянства. Единственный объект домена, который мы будем здесь использовать, будет:

model Todo {
    task: String! unique,
    completed: boolean
}

Объект домена здесь представлен с использованием простого синтаксиса, похожего на json, для его вездесущности и ясности

Теперь давайте создадим функции, необходимые для доступа к базе данных. Мы будем использовать следующее для этого эпоса:

  • База данных H2. Это крошечная и быстрая реляционная база данных, написанная на Java.
  • BasicDataSource от apache commons, который используется для подключения к базе данных H2.

Начните с создания папки проекта и назовите ее my-app. В этой папке создайте папки src и lib. Загрузите необходимые зависимости и сохраните их в папке lib. Эти зависимости:

  1. h2–1.4.197.jar
  2. commons-dbcp2–2.5.0.jar
  3. commons-pool2–2.6.0.jar
  4. commons-log-1.2.jar

Создайте каталог в корневой папке и назовите его src. Здесь будут находиться наши исходные файлы.

Создайте файл appdb.js в папке src. Давайте определим скелетную структуру в этом файле, чтобы помочь нам изолировать нашу функциональность в модуле.

let dao = {};
(function(dao, load){
    //...code goes here    
})(dao, true);

В идеале в среде Nodejs мы должны использоватьmodule.exportsдля инкапсуляции наших функций, но поскольку мы используем Nashorn, у нас пока нет такой роскоши.

Давайте создадим конфигурацию для источника данных и приступим к работе.

let DataSource = Java.type('org.apache.commons.dbcp2.BasicDataSource');    
var DB = function(params){
    this.config = {
        "jdbc.driverClass": params && params['driverClass'] || "org.h2.Driver",
        "jdbc.url":         params && params['url'] || "jdbc:h2:./data/todos.js_db;DB_CLOSE_DELAY=-1",
        "jdbc.username":    params && params['username'] || "sa",
        "jdbc.password":    params && params['password'] || "sa"
    };
    this.ds = undefined;
};

Функция DB — это прототипный способ создания класса в JavaScript. Это то, что мы будем использовать в Nashorn, чтобы избежать конфликта с ключевым словом class, используемым в Java. Обратите внимание, что последние версии ECMAScript также представили ключевое слово class в JavaScript, которое является более узнаваемым способом определения классов.

Конфигурация класса БД имеет четыре параметра, которые вы можете передать через аргумент params. Свойства

  • driverClass — это класс драйвера JDBC, который подключается к базовой базе данных.
  • url — это строка, ориентированная на JDBC, которая идентифицирует базу данных, протокол подключения, расположение базы данных, имя базы данных и другие параметры, необходимые для успешного подключения к базе данных. Различные базы данных имеют уникально разные URL-адреса подключения.
  • имя пользователя — для большинства баз данных это имя активного пользователя, подключающегося к базе данных.
  • пароль — если активный пользователь настроил пароль, это простой пароль, необходимый для аутентификации пользователя в базе данных.

Убрав этот шаблонный код, теперь давайте усилим этот класс. Давайте добавим метод для инициализации источника данных

DB.prototype.initDS = function(){
    var dataSource = new DataSource();
    dataSource.setDriverClassName(this.config["jdbc.driverClass"]);
    dataSource.setUrl(this.config["jdbc.url"]);
    dataSource.setUsername(this.config["jdbc.username"]);
    dataSource.setPassword(this.config["jdbc.password"]);
    this.ds = dataSource;
};
DB.prototype.closeDS = function(){
    this.ds.close();
};

МетодinitDSсоздаст объект источника данных и прочитает необходимые параметры из объектаconfig. С другой стороны, методcloseDSуничтожит объект источника данных и освободит все открытые ресурсы.

Давайте создадим функцию, которая создаст таблицу базы данных, необходимую для бэкенда.

DB.prototype.createTable = function(query, onSuccess, onError){
    var con, stmt;
    try{
        con = this.ds.getConnection(); 
        stmt = con.createStatement();
        var result = stmt.execute(query);
        if (result) {
            onSuccess("createTable was successful");
        }
    }
    catch(error){
        onError(error);
    }
    finally{
        if(stmt) stmt.close();
        if(con) con.close();
    }
};

Это довольно просто. Функция принимает DDL-запрос и два обратных вызова; успех и неудача. Это поддается асинхронному стилю программирования, который будет обсуждаться позже в других эпопеях. Действие, которое необходимо выполнить после того, как операция с базой данных будет отложена для инициатора этой операции

Далее давайте создадим функцию для пакетной вставки задач, если в этом возникнет необходимость. Мы будем использовать эту функцию позже в этой эпопее, чтобы продемонстрировать ее огромное преимущество при выполнении пакетной операции.

DB.prototype.insertBatch = function(tasks, onSuccess, onError) {
    var con, stmt;
    try {
        con = this.ds.getConnection(); 
        stmt = con.createStatement();
        for(var i= 0; i < tasks.length; i++){
            stmt.addBatch(tasks[i]);
        }        
        var result = stmt.executeBatch();
        onSuccess(result, "batch insert was successful");
    } catch (error) {
        onError(error);
    }finally{
        if(stmt) stmt.close();
        if(con) con.close();
    }
};

Опять же, это довольно просто. Функция принимает список задач и два обратных вызова; успех и неудача. Объект подключения использует преимущества методовaddBatchиexecuteBatchв JDBC для выполнения операции пакетной вставки

И на всякий случай давайте создадим функцию для очистки записей базы данных. Это полезно только для целей тестирования. В идеале такая функция должна быть защищена и использоваться с максимальной осторожностью.

DB.prototype.truncateTable = function (onSuccess, onError) {
    var query = "TRUNCATE table tbl_todos";
    var con, stmt;
    try {
        con = this.ds.getConnection();
        stmt = con.createStatement();
        var result = stmt.executeUpdate(query);
        onSuccess(result, "Table data truncated");
    } catch (error) {
        onError(error);
    } finally {
        if (stmt) stmt.close();
        if (con) con.close();
    }
};

Функция TRUNCATE удаляет все строки из таблицы. В отличие от DELETE FROM без предложения where, эта команда не может быть отменена. Эта команда быстрее, чем DELETE без предложения where. Только обычные таблицы данных без ограничений внешнего ключа могут быть усечены. Для тестовых целей нормально. В практических целях записи следует удалять, используя другую стратегию.

Теперь переходим к уровню более мелких деталей. Давайте создадим функцию для создания задачи Todo

DB.prototype.createTask = function(task, onSuccess, onError) {
    var query = "INSERT INTO tbl_todos (task) values (?)";
    var con, pst;
    try {
        con = this.ds.getConnection(); 
        pst = con.prepareStatement(query);
        pst.setString(1, task);
        var result = pst.executeUpdate();
        onSuccess(result, "createTask was successful");
    } catch (error) {
        onError(error);
    }finally{
        if(pst) pst.close();
        if(con) con.close();
    }
};

Операция вставки принимает один параметр — название задачи и сохраняет его в таблице базы данных. Завершенное значение естественно устанавливается равным false

Далее давайте создадим метод для обновления статуса одной задачи Todo.

DB.prototype.updateDone = function(task, done, onSuccess, onError) {
    var query = "UPDATE tbl_todos set completed=? where task = ?";
    var con, pst;
    try {
        con = this.ds.getConnection(); 
        pst = con.prepareStatement(query);
        pst.setBoolean(1, done);
        pst.setString(2, task);
        var result = pst.executeUpdate();
        onSuccess(result, "updated complete status");
    } catch (error) {
        onError(error);
    }finally{
        if(pst) pst.close();
        if(con) con.close();
    }
};

Операция обновления принимает два параметра помимо обратных вызовов — название задачи и статус завершения — и обновляет статусcompletedв таблице базы данных.

Далее давайте создадим метод для обновления заголовка отдельной задачи Todo.

DB.prototype.updateName = function(task, newName, onSuccess, onError) {
    var query = "UPDATE tbl_todos set task=? where task = ?";
    var con, pst;
    try {
        con = this.ds.getConnection(); 
        pst = con.prepareStatement(query);
        pst.setString(1, newName);
        pst.setString(2, task);
        var result = pst.executeUpdate();
        onSuccess(result, "updateName was successful");
    } catch (error) {
        onError(error);
    }finally{
        if(pst) pst.close();
        if(con) con.close();
    }
};

Операция обновления принимает два параметра помимо обратных вызовов — заголовок задачи и новый заголовок — и обновляет задачуtitleв таблице базы данных.

Далее давайте создадим метод для удаления одной задачи Todo из таблицы.

DB.prototype.deleteTask = function(task, onSuccess, onError) {
    var query = "DELETE from tbl_todos where task = ?";
    var con, pst;
    try {
        con = this.ds.getConnection(); 
        pst = con.prepareStatement(query);
        pst.setString(1, task);
        var result = pst.executeUpdate();
        onSuccess(result, "deleteTask was successful");
    } catch (error) {
        onError(error);
    }finally{
        if(pst) pst.close();
        if(con) con.close();
    }
};

Операция удаления принимает единственный параметр помимо обратных вызовов — название задачи — и удаляет задачу из таблицы базы данных.

Далее давайте создадим метод для извлечения одной задачи Todo из таблицы.

DB.prototype.retrieveTask = function(name, onSuccess, onError) {
    var query = "SELECT * from tbl_todos where task = ?";
    var con, pst;
    try {
        con = this.ds.getConnection(); 
        pst = con.prepareStatement(query);
        pst.setString(1, name);
        var rs = pst.executeQuery();
        if (rs.next()) {
            var task = {};
            task.completed = rs.getBoolean("completed");
            task.name = rs.getString("task");
            task.created = rs.getDate("date_created");
            onSuccess(task, "retrieveTask was successful");
        } else {
            onSuccess({}, "no task found");
        }
    } catch (error) {
        onError(error);
    }finally{
        if(pst) pst.close();
        if(con) con.close();
    }
};

Операция получения принимает единственный параметр помимо обратных вызовов — название задачи — и извлекает задачу из таблицы базы данных.

Далее давайте создадим метод для извлечения группы задач Todo из таблицы, используя диапазон — начальную позицию и количество элементов, которые нужно извлечь из этой точки. Этот метод полезен, когда вам нужно выполнить нумерацию страниц над записями таблицы.

DB.prototype.retrieveByRange = function(start, size, onSuccess, onError) {
    var query = "SELECT * from tbl_todos limit ? offset ?";
    var con, pst;
    try {
        con = this.ds.getConnection(); 
        pst = con.prepareStatement(query);
        pst.setInt(1, size);
        pst.setInt(2, start);
        var rs = pst.executeQuery();
        var result = [];
        while (rs.next()) {
            var task = {};
            task.completed = rs.getBoolean("completed");
            task.name = rs.getString("task");
            task.created = rs.getDate("date_created");
            result.push(task);
        }
        onSuccess(result, "retrieveByRange was successful");
    } catch (error) {
        onError(error);
    }finally{
        if(pst) pst.close();
        if(con) con.close();
    }
};

Операция извлечения по диапазону принимает два параметра помимо обратных вызовов — начало и размер — и извлекает диапазон задач из таблицы базы данных.

Наконец, давайте создадим метод для извлечения выполненных задач Todo из таблицы.

DB.prototype.retrieveByDone = function(completed, onSuccess, onError) {
    var query = "SELECT * from tbl_todos where completed = ?";
    var con, pst;
    try {
        con = this.ds.getConnection(); 
        pst = con.prepareStatement(query);
        pst.setBoolean(1, completed);
        var rs = pst.executeQuery();
        var result = [];
        while (rs.next()) {
            var task = {};
            task.completed = rs.getBoolean("completed");
            task.name = rs.getString("task");
            task.created = rs.getDate("date_created");
            result.push(task);
        }
        onSuccess(result, "retrieveByDone was successful");
    } catch (error) {
        onError(error);
    }finally{
        if(pst) pst.close();
        if(con) con.close();
    }
};

Получение задач по статусу завершения принимает единственный параметр помимо обратных вызовов — флаг завершения — и возвращает список совпадающих элементов.

Теперь у нас есть множество методов, которые мы можем использовать для работы с базой данных. Давайте теперь заставим это работать.
Давайте экспортируем объект БД, чтобы сделать его доступным вне этого скрипта.

//export DB through 'dao' 
dao.DB = DB;

В созданном замыкании мы передаем два аргумента — dao и true. Второй аргумент используется для определения необходимости загрузки исходных данных. В этом случае мы собираемся вставить исходные данные. Для этого добавьте следующий раздел

if(load){
    function onCreate(msg){
        print(msg);
    }
    
    //init database and insert data
    var db = new dao.DB();
    db.initDS();
    var data = [
        "merge into tbl_todos (task, completed) key(task) values ('buy milk', false);",
        "merge into tbl_todos (task, completed) key(task) values ('work out', true);",
        "merge into tbl_todos (task, completed) key(task) values ('watch game', false);",
        "merge into tbl_todos (task, completed) key(task) values ('hit gym', false);",
        "merge into tbl_todos (task, completed) key(task) values ('go to meeting', true);"
    ];
    
    db.createTable([
        "CREATE TABLE IF NOT EXISTS tbl_todos (",
        "  task varchar(25) UNIQUE NOT NULL,",
        "  completed boolean DEFAULT false,",
        "  date_created datetime default current_timestamp,",
        "  PRIMARY KEY (task)",
        ")"
    ].join(""), onCreate, onCreate);
    db.insertBatch(data, (res, msg) => {
        db.closeDS(); 
        print("res=" + res + ", msg=" + msg);
    }, (error) => print(error));
    let Date = Java.type('java.util.Date');
    print("data loaded".concat(" @ ").concat(new Date().toString()));
}

После завершения раздела загрузки данных вы можете добавить в начало appdb.js вызов load(‘jvm-npm.js’). Загрузите этот файл и сохраните его в папке lib.

load('jvm-npm.js');

Теперь приложение готово к запуску. Чтобы вызвать сценарий, обязательно добавьте необходимые зависимости в путь к классам. (Обратите внимание, что символы'\ 'в пути к классам используются для разбиения команды на несколько строк, поэтому просто удалите их и все пробелы в пути к классам, если они создают проблемы)
jjs --language=es6 -ot -scripting -J-Djava.class.path=./lib/h2-1.4.197.jar:\ ./lib/commons-dbcp2-2.5.0.jar:./lib/commons-pool2-2.6.0.jar:./lib/commons-logging-1.2.jar appdb.js

JUnit-тестирование доступа к базе данных JDBC

Для начала мы воспользуемся собственной библиотекой Junit для Java, чтобы протестировать только что созданный сценарий доступа к данным appdb.js на устойчивость. В следующей эпопее мы будем использовать библиотеку тестирования Javascript для проверки той же функциональности.
Загрузите библиотеку junit и зависимость hamcrest и поместите файлы jar в папку ./lib.
Затем создайте папку __tests__ и добавьте новый тестовый файл appdb-test.js. Теперь приступим к работе!

load('src/appdb.js');
//********************************************//
// Initialize db for testing
//********************************************//
var assert = org.junit.Assert;

В файлappdb-test.jsмы сначала импортируем файлappdb.jsс нашими внутренними функциями. С помощью модульного тестирования мы можем проверить функциональную правильность отдельных функций, которые у нас есть. Для этого мы также импортируем класс Assert из junit, в котором есть методы, которые мы будем использовать

Давайте создадим объект конфигурации доступа к базе данных и используем его для создания объекта доступа к данным. Основное отличие от настроек по умолчанию заключается в том, что имя базы данных изменено. При этом давайте также создадим вспомогательную функцию для генерации случайного числа в заданном диапазоне.

var math = Java.type('java.lang.Math');
function random(min, max){
    return (math.random() * ( max - min )) + min;
}
var config = {
    "driverClass": "org.h2.Driver",
    "url": "jdbc:h2:./data/todos.js_db_test;DB_CLOSE_DELAY=-1",
    "username": "sa",
    "password": "sa"
};
let db = new dao.DB(config);
db.initDS();

Инициализировав объект доступа к данным, мы должны создать базу данных и таблицу, необходимые для тестирования функции доступа к базе данных

На следующем шаге давайте создадим таблицу базы данных и заполним ее тестовыми данными.

db.createTable([
    "CREATE TABLE IF NOT EXISTS tbl_todos (",
    "  task varchar(25) NOT NULL,",
    "  completed boolean DEFAULT false,",
    "  date_created datetime default current_timestamp,",
    "  PRIMARY KEY (task)",
    ")"
].join(""), (res,msg)=>print(res,msg), (err)=>print(err));
var data = [
    "merge into tbl_todos (task, completed) key(task) values ('buy milk', false);",
    "merge into tbl_todos (task, completed) key(task) values ('work out', true);",
    "merge into tbl_todos (task, completed) key(task) values ('watch game', false);",
    "merge into tbl_todos (task, completed) key(task) values ('hit gym', false);",
    "merge into tbl_todos (task, completed) key(task) values ('go to meeting', true);"
];
//clear database records before commencing tests
db.truncateTable((res, msg)=>{
    print(res, msg);
    //insert new batch of test data
    db.insertBatch(data, (res, msg)=>print(res, msg), (error)=>print(error));
}, (error)=>print(error));

В функцииcreateTableмы создаем таблицу, только если она еще не существует. Точно так же мы используем синтаксисmergeдля вставки записей таблицы, если они не существуют, или обновления столбцов, если строка существует.

Имея таблицу базы данных, давайте начнем тестирование функциональности. Для начала протестируем операцию Получить задачу.

//********************************************//
// Test methods in db API
//********************************************//
function testRetrieveTask(test, name, onSuccess, onError){
    if(test) db.retrieveTask(name, onSuccess, onError);
}
testRetrieveTask(true, 'buy milk',
    function(task, msg){
        assert.assertEquals("Expecting 'buy milk'", 'buy milk', task.name);
        print('name='+task.name+', completed='+task.completed);
    },
    function(msg){
        print(msg);
        assert.fail(msg);
    }
);

Для выполнения тестов нам потребуется добавить библиотеки junit и hamcrest в путь к классам и выполнить ту же команду, что и раньше. Однако на этот раз целевым файлом будет файлappdb-junit.jsв папке__tests__.

На компьютере с Windows используйте точку с запятой вместо двоеточия для разделения банок пути к классам.

jjs --language=es6 -ot -scripting\ 
    -J-Djava.class.path=./lib/h2-1.4.197.jar:\ ./lib/commons-dbcp2-2.5.0.jar:./lib/commons-pool2-2.6.0.jar:./lib/commons-logging-1.2.jar:./lib/junit-4.12.jar:./lib/hamcrest-core-1.3.jar\ 
    __tests__/appdb-junit.js

Далее, давайте протестируем функцию создания задачи.

function testCreateTask(test, name, onSuccess, onError){
    if(test) db.createTask(name, onSuccess, onError);
}
let title = "task at " + random(0, 100);
testCreateTask(false, title,
    function(task, msg){
        assert.assertEquals('Expecting \'' + title + '\'', title, task.task);
        print(msg);
    },
    function(msg){
        print(msg);
        assert.fail(msg);
    }
);

Имя задачи объединяется со случайным числом с помощью функции, которую мы создали в начале файла. Это позволяет различать его в целях тестирования.

Далее создадим тест для обновления задачи смотреть игру, которая была создана во время инициализации.

function testUpdateDone(test, name, done, onSuccess, onError){
    if(test) db.updateDone(name, done, onSuccess, onError);
}
testUpdateDone(true, 'watch game', true, 
    function(res, msg){
        assert.assertEquals('Expecting \'1\'', '1', res.toString());
        print(msg);
    },
    function(msg){
        print(msg);
        assert.fail(msg);
    }
);

После успешного завершения функцияобновить статус завершениявозвращает количество обновленных строк. В данном случае мы обновляем только одну строку, поэтому ожидаемый результат равен 1.

Далее создадим тест для обновления названия задачи для записи, созданной во время инициализации.

function testUpdateName(test, name, newname, onSuccess, onError){
    if(test) db.updateName(name, newname, onSuccess, onError);
}
testUpdateName(true, 'watch game', 'watch soccer', 
    function(res, msg){
        assertEquals('Expecting \'1\'', '1', res.toString());
        print(msg);
    },
    function(msg){
        print(msg);
        assert.fail(msg);
    }
);

После успешного завершения функцияобновить название задачивозвращает количество обновленных строк. В этом случае мы снова обновляем только одну строку, поэтому ожидаемый результат равен 1

Далее создадим тест для функции удалить задачу.

function testDeleteTask(test, name, onSuccess, onError){
    if(test) db.deleteTask(name, onSuccess, onError);
}
testDeleteTask(true, 'buy milk', 
    function(res, msg){
        assert.assertEquals('Expecting \'1\'', '1', res.toString());
        print(msg);
    },
    function(msg){
        print(msg);
        assert.fail(msg);
    }
);

После успешного завершения функцияdelete taskвозвращает количество удаленных строк. В этом случае мы снова удаляем только одну строку, поэтому ожидаемый результат равен 1

Далее давайте создадим тест для функции, чтобы получить ряд задач.

function testRetrieveByRange(test, start, end, onSuccess, onError){
    if(test) db.retrieveByRange(start, end, onSuccess, onError);
}
testRetrieveByRange(true, 0, 10, 
    function(tasks, msg){
        assert.assertEquals("Expecting 4", "4", tasks.length.toString());
        print(msg);
        tasks.forEach(task => print('name='+task.name+', completed='+task.completed));
    },
    function(msg){
        print(msg);
        assert.fail(msg);
    }
);

После успешного завершения функцияполучить задачи в диапазоневозвращает количество записей, доступных в диапазоне.

Наконец, что не менее важно, давайте создадим тест для функции, которая будет извлекать выполненные задачи.

function testRetrieveByDone(test, completed, onSuccess, onError){
    if(test) db.retrieveByDone(completed, onSuccess, onError);
}
testRetrieveByDone(true, true, 
    function(tasks, msg){
        print(tasks.length, msg);
        tasks.forEach (task => {
            print('name='+task.name+', completed='+task.completed);
            assert.assertEquals('Expecting \'done\'', true, task.completed);
        });
    },
    function(msg){
        print(msg);
        assert.fail(msg);
    }
);

Чтобы увидеть еще больше занимательных эпопей, посетите https://practicaldime.org.