No mundo moderno, não há como sem armazenamento de dados. E a história do trabalho com bancos de dados começou há muito tempo, com o advento do JDBC. Proponho lembrar algo que nenhuma estrutura moderna construída sobre JDBC pode prescindir. Além disso, mesmo trabalhando com eles, às vezes você pode precisar da oportunidade de “voltar às suas raízes”. Espero que esta revisão ajude como uma introdução ou ajude a refrescar sua memória.
Este padrão é descrito pela especificação " JSR 221 JDBC 4.1 API ". Esta especificação nos diz que a API JDBC fornece acesso programático a bancos de dados relacionais a partir de programas escritos em Java. Também informa que a API JDBC faz parte da plataforma Java e, portanto, está incluída no Java SE e no Java EE. A API JDBC é fornecida em dois pacotes: java.sql e javax.sql. Vamos conhecê-los então.
Como podemos ver, e isso é lógico, o banco de dados é um componente externo que não é nativo do Java SE. Isso é explicado de forma simples - há um grande número de bancos de dados e você pode trabalhar com qualquer um. Por exemplo, existe PostgreSQL, Oracle, MySQL, H2. Cada um desses bancos de dados é fornecido por uma empresa separada chamada de fornecedores de bancos de dados. Cada banco de dados é escrito em sua própria linguagem de programação (não necessariamente Java). Para poder trabalhar com o banco de dados a partir de uma aplicação Java, o provedor do banco de dados escreve um driver especial, que é seu próprio adaptador de imagem. Esses compatíveis com JDBC (ou seja, aqueles que possuem um driver JDBC) também são chamados de “banco de dados compatível com JDBC”. Aqui podemos fazer uma analogia com dispositivos de computador. Por exemplo, em um bloco de notas existe um botão "Imprimir". Cada vez que você pressiona, o programa informa ao sistema operacional que o aplicativo bloco de notas deseja imprimir. E você tem uma impressora. Para ensinar seu sistema operacional a se comunicar uniformemente com uma impressora Canon ou HP, você precisará de drivers diferentes. Mas para você, como usuário, nada mudará. Você ainda pressionará o mesmo botão. O mesmo acontece com JDBC. Você está executando o mesmo código, mas diferentes bancos de dados podem estar sendo executados nos bastidores. Acho que esta é uma abordagem muito clara. Cada driver JDBC é algum tipo de artefato, biblioteca ou arquivo jar. Esta é a dependência do nosso projeto. Por exemplo, podemos selecionar o banco de dados “ H2 Database ” e então precisamos adicionar uma dependência como esta:
Introdução
Um dos principais objetivos de uma linguagem de programação é armazenar e processar informações. Para entender melhor como funciona o armazenamento de dados, vale a pena dedicar um pouco de tempo à teoria e arquitetura das aplicações. Por exemplo, você pode ler a literatura, nomeadamente o livro " Manual do Arquiteto de Software: Torne-se um arquiteto de software de sucesso implementando um arco eficaz... " de Joseph Ingeno. Como dito, existe uma certa camada de dados ou “camada de dados”. Inclui um local para armazenar dados (por exemplo, um banco de dados SQL) e ferramentas para trabalhar com um armazenamento de dados (por exemplo, JDBC, que será discutido). Há também um artigo no site da Microsoft: “ Projetando uma camada de persistência de infraestrutura ”, que descreve a solução arquitetônica de separar uma camada adicional da camada de dados – a camada de persistência. Neste caso, o Data Tier é o nível de armazenamento dos dados em si, enquanto a Persistence Layer é algum nível de abstração para trabalhar com dados do armazenamento do nível Data Tier. A camada de persistência pode incluir o modelo “DAO” ou vários ORMs. Mas ORM é um tema para outra discussão. Como você já deve ter entendido, o Data Tier apareceu primeiro. Desde a época do JDK 1.1, o JDBC (Java DataBase Connectivity - conexão com bancos de dados em Java) apareceu no mundo Java. Este é um padrão para interação de aplicações Java com diversos SGBDs, implementado na forma de pacotes java.sql e javax.sql incluídos no Java SE:Início do trabalho
Para entender o que é a API JDBC em geral, precisamos de um aplicativo Java. É mais conveniente usar um dos sistemas de montagem do projeto. Por exemplo, vamos usar Gradle . Você pode ler mais sobre o Gradle em uma breve revisão: " Uma Breve Introdução ao Gradle ". Primeiro, vamos inicializar um novo projeto Gradle. Como a funcionalidade do Gradle é implementada por meio de plug-ins, precisamos usar “ Gradle Build Init Plugin ” para inicialização:gradle init --type java-application
Depois disso, vamos abrir o script de construção - o arquivo build.gradle , que descreve nosso projeto e como trabalhar com ele. Estamos interessados no bloco " dependências ", onde são descritas as dependências - ou seja, aquelas bibliotecas/frameworks/api, sem as quais não podemos trabalhar e das quais dependemos. Por padrão, veremos algo como:
dependencies {
// This dependency is found on compile classpath of this component and consumers.
implementation 'com.google.guava:guava:26.0-jre'
// Use JUnit test framework
testImplementation 'junit:junit:4.12'
}
Por que estamos vendo isso aqui? Estas são as dependências do nosso projeto que o Gradle gerou automaticamente para nós ao criar o projeto. E também porque goiaba é uma biblioteca separada que não está incluída no Java SE. JUnit também não está incluído no Java SE. Mas temos o JDBC pronto para uso, ou seja, faz parte do Java SE. Acontece que temos JDBC. Ótimo. O que mais nós precisamos? Existe um diagrama tão maravilhoso:
dependencies {
implementation 'com.h2database:h2:1.4.197'
Como encontrar uma dependência e como descrevê-la está indicado nos sites oficiais do provedor do banco de dados ou no " Maven Central ". O driver JDBC não é um banco de dados, como você entende. Mas ele é apenas um guia para isso. Mas existe algo como " Bancos de dados em memória ". Esses são bancos de dados que existem na memória durante a vida útil do seu aplicativo. Normalmente, isso é frequentemente usado para fins de teste ou treinamento. Isso permite evitar a instalação de um servidor de banco de dados separado na máquina. O que é muito adequado para conhecermos o JDBC. Então nossa sandbox está pronta e começamos.
Conexão
Então, temos um driver JDBC, temos uma API JDBC. Como lembramos, JDBC significa Java DataBase Connectivity. Portanto, tudo começa com a Conectividade – a capacidade de estabelecer uma conexão. E conexão é conexão. Vamos voltar ao texto da especificação JDBC e dar uma olhada no índice. No capítulo " CAPÍTULO 4 Visão geral " (visão geral) passamos para a seção " 4.1 Estabelecendo uma conexão " (estabelecendo uma conexão) é dito que existem duas maneiras de se conectar ao banco de dados:- Através do DriverManager
- Através da fonte de dados
Connection con = DriverManager.getConnection(url, user, passwd);
Os parâmetros podem ser retirados do site do banco de dados que escolhemos. No nosso caso, é H2 - " Folha de dicas H2 ". Vamos passar para a classe AppTest preparada pelo Gradle. Ele contém testes JUnit. Um teste JUnit é um método marcado com uma anotação @Test
. Os testes unitários não são o tema desta revisão, portanto nos limitaremos simplesmente a entender que se trata de métodos descritos de uma determinada forma, cujo objetivo é testar algo. De acordo com a especificação JDBC e o site H2, verificaremos se recebemos uma conexão com o banco de dados. Vamos escrever um método para obter uma conexão:
private Connection getNewConnection() throws SQLException {
String url = "jdbc:h2:mem:test";
String user = "sa";
String passwd = "sa";
return DriverManager.getConnection(url, user, passwd);
}
Agora vamos escrever um teste para este método que irá verificar se a conexão está realmente estabelecida:
@Test
public void shouldGetJdbcConnection() throws SQLException {
try(Connection connection = getNewConnection()) {
assertTrue(connection.isValid(1));
assertFalse(connection.isClosed());
}
}
Este teste, quando executado, irá verificar se a conexão resultante é válida (criada corretamente) e se não está fechada. Ao usar try-with-resources, liberaremos recursos quando não precisarmos mais deles. Isso nos protegerá de conexões flácidas e vazamentos de memória. Como qualquer ação com o banco de dados requer uma conexão, vamos fornecer os métodos de teste restantes marcados como @Test com uma Conexão no início do teste, que lançaremos após o teste. Para isso, precisamos de duas anotações: @Before e @After Vamos adicionar um novo campo à classe AppTest que armazenará a conexão JDBC para testes:
private static Connection connection;
E vamos adicionar novos métodos:
@Before
public void init() throws SQLException {
connection = getNewConnection();
}
@After
public void close() throws SQLException {
connection.close();
}
Agora, qualquer método de teste tem garantia de ter uma conexão JDBC e não precisa criá-la todas as vezes.
Declarações
A seguir estamos interessados em declarações ou expressões. Eles estão descritos na documentação do capítulo " CAPÍTULO 13 Declarações ". Em primeiro lugar, diz que existem vários tipos ou tipos de declarações:- Instrução: expressão SQL que não contém parâmetros
- PreparedStatement: instrução SQL preparada contendo parâmetros de entrada
- CallableStatement: Expressão SQL com capacidade de obter um valor de retorno de SQL Stored Procedures.
private int executeUpdate(String query) throws SQLException {
Statement statement = connection.createStatement();
// Для Insert, Update, Delete
int result = statement.executeUpdate(query);
return result;
}
Vamos adicionar um método para criar uma tabela de teste usando o método anterior:
private void createCustomerTable() throws SQLException {
String customerTableQuery = "CREATE TABLE customers " +
"(id INTEGER PRIMARY KEY, name TEXT, age INTEGER)";
String customerEntryQuery = "INSERT INTO customers " +
"VALUES (73, 'Brian', 33)";
executeUpdate(customerTableQuery);
executeUpdate(customerEntryQuery);
}
Agora vamos testar isso:
@Test
public void shouldCreateCustomerTable() throws SQLException {
createCustomerTable();
connection.createStatement().execute("SELECT * FROM customers");
}
Agora vamos executar a requisição, e ainda com um parâmetro:
@Test
public void shouldSelectData() throws SQLException {
createCustomerTable();
String query = "SELECT * FROM customers WHERE name = ?";
PreparedStatement statement = connection.prepareStatement(query);
statement.setString(1, "Brian");
boolean hasResult = statement.execute();
assertTrue(hasResult);
}
O JDBC não oferece suporte a parâmetros nomeados para PreparedStatement, portanto, os próprios parâmetros são especificados por perguntas e, ao especificar o valor, indicamos o índice da pergunta (começando em 1, não em zero). No último teste recebemos true como uma indicação se há resultado. Mas como o resultado da consulta é representado na API JDBC? E é apresentado como um ResultSet.
Conjunto de resultados
O conceito de ResultSet é descrito na especificação da API JDBC no capítulo "CAPÍTULO 15 Conjuntos de Resultados". Em primeiro lugar, diz que ResultSet fornece métodos para recuperar e manipular os resultados de consultas executadas. Ou seja, se o método execute retornou verdadeiro para nós, então podemos obter um ResultSet. Vamos mover a chamada do método createCustomerTable() para o método init, que está marcado como @Before. Agora vamos finalizar nosso teste shouldSelectData:@Test
public void shouldSelectData() throws SQLException {
String query = "SELECT * FROM customers WHERE name = ?";
PreparedStatement statement = connection.prepareStatement(query);
statement.setString(1, "Brian");
boolean hasResult = statement.execute();
assertTrue(hasResult);
// Обработаем результат
ResultSet resultSet = statement.getResultSet();
resultSet.next();
int age = resultSet.getInt("age");
assertEquals(33, age);
}
É importante notar aqui que a seguir está um método que move o chamado “cursor”. O cursor no ResultSet aponta para alguma linha. Assim, para ler uma linha, você precisa colocar o cursor sobre ela. Quando o cursor é movido, o método de movimentação do cursor retorna verdadeiro se o cursor for válido (correto, correto), ou seja, aponta para dados. Se retornar falso, então não há dados, ou seja, o cursor não está apontando para os dados. Se tentarmos obter dados com um cursor inválido, obteremos o erro: Nenhum dado está disponível. Também é interessante que através do ResultSet você pode atualizar ou até mesmo inserir linhas:
@Test
public void shouldInsertInResultSet() throws SQLException {
Statement statement = connection.createStatement(ResultSet.TYPE_SCROLL_SENSITIVE, ResultSet.CONCUR_UPDATABLE);
ResultSet resultSet = statement.executeQuery("SELECT * FROM customers");
resultSet.moveToInsertRow();
resultSet.updateLong("id", 3L);
resultSet.updateString("name", "John");
resultSet.updateInt("age", 18);
resultSet.insertRow();
resultSet.moveToCurrentRow();
}
Conjunto de linhas
Além do ResultSet, o JDBC introduz o conceito de RowSet. Você pode ler mais aqui: " Noções básicas de JDBC: usando objetos RowSet ". Existem diversas variações de uso. Por exemplo, o caso mais simples pode ser assim:@Test
public void shouldUseRowSet() throws SQLException {
JdbcRowSet jdbcRs = new JdbcRowSetImpl(connection);
jdbcRs.setCommand("SELECT * FROM customers");
jdbcRs.execute();
jdbcRs.next();
String name = jdbcRs.getString("name");
assertEquals("Brian", name);
}
Como você pode ver, RowSet é semelhante a uma simbiose de instrução (especificamos o comando por meio dela) e o comando executado. Através dele controlamos o cursor (chamando o próximo método) e obtemos dados dele. Não só esta abordagem é interessante, mas também possíveis implementações. Por exemplo, CachedRowSet. Está "desconectado" (ou seja, não utiliza uma conexão persistente com o banco de dados) e requer sincronização explícita com o banco de dados:
CachedRowSet jdbcRsCached = new CachedRowSetImpl();
jdbcRsCached.acceptChanges(connection);
Você pode ler mais no tutorial no site da Oracle: " Usando CachedRowSetObjects ".
Metadados
Além das consultas, uma conexão com o banco de dados (ou seja, uma instância da classe Connection) fornece acesso aos metadados – dados sobre como nosso banco de dados está configurado e organizado. Mas primeiro, vamos mencionar alguns pontos-chave: A URL para conexão com nosso banco de dados: “jdbc:h2:mem:test”. test é o nome do nosso banco de dados. Para a API JDBC, este é um diretório. E o nome estará em maiúscula, ou seja, TEST. O esquema padrão para H2 é PUBLIC. Agora, vamos escrever um teste que mostre todas as tabelas do usuário. Por que personalizado? Porque os bancos de dados contêm não apenas tabelas de usuários (aquelas que nós mesmos criamos usando expressões de criação de tabela), mas também tabelas de sistema. Eles são necessários para armazenar informações do sistema sobre a estrutura do banco de dados. Cada banco de dados pode armazenar essas tabelas de sistema de maneira diferente. Por exemplo, em H2 eles são armazenados no esquema " INFORMATION_SCHEMA ". Curiosamente, o INFORMATION SCHEMA é uma abordagem comum, mas a Oracle seguiu um caminho diferente. Você pode ler mais aqui: " INFORMATION_SCHEMA e Oracle ". Vamos escrever um teste que receba metadados nas tabelas do usuário:@Test
public void shoudGetMetadata() throws SQLException {
// У нас URL = "jdbc:h2:mem:test", где test - название БД
// Название БД = catalog
DatabaseMetaData metaData = connection.getMetaData();
ResultSet result = metaData.getTables("TEST", "PUBLIC", "%", null);
List<String> tables = new ArrayList<>();
while(result.next()) {
tables.add(result.getString(2) + "." + result.getString(3));
}
assertTrue(tables.contains("PUBLIC.CUSTOMERS"));
}
Conjunto de conexões
O pool de conexões na especificação JDBC possui uma seção chamada "Capítulo 11 Pool de conexões". Também fornece a principal justificativa para a necessidade de um pool de conexões. Cada Coonection é uma conexão física com o banco de dados. Sua criação e fechamento é um trabalho bastante “caro”. JDBC fornece apenas uma API de pool de conexões. Portanto, a escolha da implementação continua sendo nossa. Por exemplo, tais implementações incluem HikariCP . Conseqüentemente, precisaremos adicionar um pool à dependência do nosso projeto:dependencies {
implementation 'com.h2database:h2:1.4.197'
implementation 'com.zaxxer:HikariCP:3.3.1'
testImplementation 'junit:junit:4.12'
}
Agora precisamos usar esse pool de alguma forma. Para fazer isso, você precisa inicializar a fonte de dados, também conhecida como Datasource:
private DataSource getDatasource() {
HikariConfig config = new HikariConfig();
config.setUsername("sa");
config.setPassword("sa");
config.setJdbcUrl("jdbc:h2:mem:test");
DataSource ds = new HikariDataSource(config);
return ds;
}
E vamos escrever um teste para receber uma conexão do pool:
@Test
public void shouldGetConnectionFromDataSource() throws SQLException {
DataSource datasource = getDatasource();
try (Connection con = datasource.getConnection()) {
assertTrue(con.isValid(1));
}
}
Transações
Uma das coisas mais interessantes sobre o JDBC são as transações. Na especificação JDBC, eles recebem o capítulo "CAPÍTULO 10 Transações". Antes de mais nada, vale entender o que é uma transação. Uma transação é um grupo de operações sequenciais combinadas logicamente sobre dados, processadas ou canceladas como um todo. Quando uma transação começa ao usar JDBC? Conforme afirma a especificação, isso é tratado diretamente pelo driver JDBC. Mas normalmente, uma nova transação começa quando a instrução SQL atual exige uma transação e a transação ainda não foi criada. Quando termina a transação? Isso é controlado pelo atributo auto-commit. Se a confirmação automática estiver habilitada, a transação será concluída após a instrução SQL ser "concluída". O que significa "concluído" depende do tipo de expressão SQL:- Linguagem de manipulação de dados, também conhecida como DML (Insert, Update, Delete)
A transação é concluída assim que a ação é concluída Selecione Instruções
- CallableStatement e expressões que retornam vários resultados
Quando todos os ResultSets associados foram fechados e toda a saída foi recebida (incluindo o número de atualizações)
A transação é concluída quando o ResultSet é fechado ( ResultSet#close )
@Test
public void shouldCommitTransaction() throws SQLException {
connection.setAutoCommit(false);
String query = "INSERT INTO customers VALUES (1, 'Max', 20)";
connection.createStatement().executeUpdate(query);
connection.commit();
Statement statement = connection.createStatement();
statement.execute("SELECT * FROM customers");
ResultSet resultSet = statement.getResultSet();
int count = 0;
while(resultSet.next()) {
count++;
}
assertEquals(2, count);
}
É simples. Mas isso é verdade desde que tenhamos apenas uma transação. O que fazer quando existem vários deles? Eles precisam estar isolados um do outro. Portanto, vamos falar sobre os níveis de isolamento de transações e como o JDBC lida com eles.
Níveis de isolamento
Vamos abrir a subseção "10.2 Níveis de isolamento de transação" da especificação JDBC. Aqui, antes de prosseguir, gostaria de lembrar algo como ACID. ACID descreve os requisitos para um sistema transacional.- Atomicidade:
Nenhuma transação será parcialmente comprometida com o sistema. Todas as suas suboperações serão executadas ou nenhuma será executada. - Consistência:
Cada transação bem-sucedida, por definição, registra apenas resultados válidos. - Isolamento:
enquanto uma transação está em execução, as transações simultâneas não devem afetar seu resultado. - Durabilidade:
Se uma transação for concluída com sucesso, as alterações feitas nela não serão desfeitas devido a qualquer falha.
ext {
h2Version = '1.3.176' // 1.4.177
hikariVersion = '3.3.1'
junitVersion = '4.12'
}
A seguir, usamos isso nas versões:
dependencies {
implementation "com.h2database:h2:${h2Version}"
implementation "com.zaxxer:HikariCP:${hikariVersion}"
testImplementation "junit:junit:${junitVersion}"
}
Você deve ter notado que a versão h2 ficou mais baixa. Veremos o porquê mais tarde. Então, como você aplica níveis de isolamento? Vejamos imediatamente um pequeno exemplo prático:
@Test
public void shouldGetReadUncommited() throws SQLException {
Connection first = getNewConnection();
assertTrue(first.getMetaData().supportsTransactionIsolationLevel(Connection.TRANSACTION_READ_UNCOMMITTED));
first.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED);
first.setAutoCommit(false);
// Транзакиця на подключение. Поэтому первая транзакция с ReadUncommited вносит изменения
String insertQuery = "INSERT INTO customers VALUES (5, 'Max', 15)";
first.createStatement().executeUpdate(insertQuery);
// Вторая транзакция пытается их увидеть
int rowCount = 0;
JdbcRowSet jdbcRs = new JdbcRowSetImpl(getNewConnection());
jdbcRs.setCommand("SELECT * FROM customers");
jdbcRs.execute();
while (jdbcRs.next()) {
rowCount++;
}
assertEquals(2, rowCount);
}
Curiosamente, este teste pode falhar em um fornecedor que não suporta TRANSACTION_READ_UNCOMMITTED (por exemplo, sqlite ou HSQL). E o nível de transação pode simplesmente não funcionar. Lembra que indicamos a versão do driver do banco de dados H2? Se aumentarmos para h2Version = '1.4.177' e superior, então READ UNCOMMITTED irá parar de funcionar, embora não tenhamos alterado o código. Isso prova mais uma vez que a escolha do fornecedor e da versão do driver não é apenas letras, na verdade determinará como suas solicitações serão executadas. Você pode ler sobre como corrigir esse comportamento na versão 1.4.177 e como ele não funciona em versões superiores aqui: “ Suporte ao nível de isolamento READ UNCOMMITTED no modo MVStore ”.
Resultado final
Como podemos ver, JDBC é uma ferramenta poderosa nas mãos do Java para trabalhar com bancos de dados. Espero que esta breve revisão ajude a fornecer um ponto de partida ou a refrescar sua memória. Bem, para um lanche, alguns materiais adicionais:- Relatório de incêndio: “ Transações: mitos, surpresas e oportunidades ” de Martin Kleppmann
- Yuri Tkach: " JPA. Transações "
- Yurik Tkach: " JDBC - Java para testadores "
- Curso gratuito na Udemy: " JDBC e MySQL "
- " Tratando objetos CallableStatement "
- Desenvolvedor IBM: " Conectividade de banco de dados Java "
- IBM Knowledge Center: " Introdução ao JDBC "
GO TO FULL VERSION