Бази данни
Проблем
Каталог във файл
Каталогът на магазина е CSV файл, както в модула за файловете. Докато продуктите са сто, става. После стават сто хиляди и се появяват проблеми:
- за да намериш един продукт, четеш целия файл;
- „всички под 5 €, подредени по цена“ е цикъл,
ifи сортиране, писани на ръка; - две каси пишат във файла едновременно и единият запис се губи или файлът се поврежда;
- токът спира по средата на записа и половината файл го няма.
Затова данните на почти всяка истинска програма са в база данни: програма, чиято работа е да пази данни, да ги търси бързо и да не ги губи.
Идеята
Таблици и SQL
Релационната база пази данните в таблици. Таблицата има колони (име, тип) и редове (по един за всеки продукт). Колоната id е първичен ключ: стойността ѝ е различна за всеки ред, затова по нея се намира точно един.
С базата се говори на езика SQL. Петте най-чести команди:
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2));
INSERT INTO products VALUES (1, 'Кафе', 7.50);SELECT name, price FROM products WHERE price < 5 ORDER BY price;UPDATE products SET price = 6.90 WHERE id = 1;DELETE FROM products WHERE id = 3;CREATE TABLEправи таблица,INSERTдобавя ред,UPDATEпроменя,DELETEтрие.SELECTчете: кои колони, от коя таблица, при какво условие (WHERE) и в какъв ред (ORDER BY).
Ти казваш какво искаш, а базата решава как да го намери бързо.
По-точно, за любопитните
- Известни релационни бази са PostgreSQL, MySQL, Oracle и SQL Server. Те работят като отделни сървъри. Тук ползваме H2: малка база, написана на Java, която може да живее изцяло в паметта на програмата. Удобна е за примери и тестове: всяко пускане започва с празна база.
- Текстът в SQL е в единични кавички:
'Кафе'.DECIMAL(8, 2)е точно число с 2 знака след запетаята, подходящо за пари.
Виж го
JDBC: връзка, заявка, резултат
Connection, заявката се праща през Statement, а резултатът е ResultSet. Методът setup прави таблицата и добавя три продукта:static void setup(Connection conn) throws SQLException { try (Statement st = conn.createStatement()) { st.executeUpdate("CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2))"); st.executeUpdate("INSERT INTO products VALUES (1, 'Кафе', 7.50), (2, 'Чай', 3.20), (3, 'Шоколад', 2.40)"); }}record Product(int id, String name, double price):try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); List<Product> products = new ArrayList<>(); try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT id, name, price FROM products ORDER BY price")) { while (rs.next()) { products.add(new Product(rs.getInt("id"), rs.getString("name"), rs.getDouble("price"))); } } for (Product p : products) { System.out.println(p); }}Product[id=3, name=Шоколад, price=2.4]Product[id=2, name=Чай, price=3.2]Product[id=1, name=Кафе, price=7.5]DriverManager.getConnection(адрес)отваря връзката. Адресът казва коя база: тук H2 в паметта с имеshop.executeUpdateе за команди, които променят (CREATE,INSERT,UPDATE,DELETE),executeQueryе заSELECT.rs.next()минава на следващия ред и връщаfalse, когато редовете свършат.getInt,getStringиgetDoubleвзимат колона по име.- Връзката, заявката и резултатът се затварят: затова са в try-with-resources.
- Почти всеки метод на JDBC хвърля проверено
SQLException. Тукmainиsetupго обявяват сthrows.
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); List<Product> products = new ArrayList<>(); try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT id, name, price FROM products ORDER BY price")) { while (rs.next()) { products.add(new Product(rs.getInt("id"), rs.getString("name"), rs.getDouble("price"))); } } for (Product p : products) { System.out.println(p); }}Стъпка по стъпка, с думи
- Връзка с базата в паметта. setup прави таблицата products и добавя 3 реда.
- Празен списък, в който ще влязат обектите.
- SELECT с ORDER BY price: базата връща редовете като ResultSet, от най-евтиния.
- rs.next() минава на ред 1. getInt, getString и getDouble взимат колоните, а new Product прави от тях обект: Шоколад.
- rs.next() минава на ред 2. getInt, getString и getDouble взимат колоните, а new Product прави от тях обект: Чай.
- rs.next() минава на ред 3. getInt, getString и getDouble взимат колоните, а new Product прави от тях обект: Кафе.
- rs.next() връща false: редовете свършиха и цикълът спира.
- Списъкът е от обикновени Java обекти. Печатът показва record-а с полетата му.
По-точно, за любопитните
- С истинска база на сървър адресът е друг и има потребител и парола. Също трябва и драйверът на базата като зависимост в проекта (за MySQL:
mysql-connector-j):
String url = "jdbc:mysql://localhost:3306/shop";try (Connection conn = DriverManager.getConnection(url, "shop_app", "парола")) { System.out.println("Връзката с базата е успешна!");}- Паролата не се пише в кода, а се чете от настройките или от променлива на средата.
- Цените тук са
double, за да е кратко. В истински магазин колонатаDECIMALсе чете сrs.getBigDecimal("price"), за да няма грешки от закръгляне.
Пробвай
Кое ще излезе?
Продуктите са Кафе 7,50 €, Чай 3,20 € и Шоколад 2,40 €. Какво ще отпечата програмата?
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT name FROM products WHERE price < 5 ORDER BY price DESC")) { while (rs.next()) { System.out.println(rs.getString("name")); } }}WHERE price < 5 оставя Чай и Шоколад. ORDER BY price DESC ги подрежда по цена отгоре надолу (DESC е намаляващо, ASC е по подразбиране): първо Чай за 3,20 €, после Шоколад за 2,40 €.
Проблем
Търсачката, която показва всичко
Сайтът търси продукт по име, което потребителят пише. Заявката се сглобява с +:
static void search(Connection conn, String name) throws SQLException { String sql = "SELECT name, price FROM products WHERE name = '" + name + "'"; try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) { while (rs.next()) { System.out.println(" " + rs.getString("name") + ": " + rs.getDouble("price") + " €"); } }}try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); System.out.println("Търся: Чай"); search(conn, "Чай"); System.out.println("Търся: x' OR '1'='1"); search(conn, "x' OR '1'='1");}Търся: Чай Чай: 3.2 €Търся: x' OR '1'='1 Кафе: 7.5 € Чай: 3.2 € Шоколад: 2.4 €С „Чай“ работи. С x' OR '1'='1 заявката става ... WHERE name = 'x' OR '1'='1'. Условието е винаги вярно и излиза цялата таблица. Входът на потребителя е станал част от SQL. Това е SQL injection, една от най-честите атаки срещу сайтове. Със същия трик може да се влезе без парола или да се изтрие таблица.
Идеята
PreparedStatement
PreparedStatement има ? на местата на стойностите, а те се подават със setString, setInt, setDouble (номерата са от 1):static void search(Connection conn, String name) throws SQLException { String sql = "SELECT name, price FROM products WHERE name = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, name); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { System.out.println(" " + rs.getString("name") + ": " + rs.getDouble("price") + " €"); } } }}Търся: Чай Чай: 3.2 €Търся: x' OR '1'='1Сега x' OR '1'='1 е просто текст, който се търси като име. Такъв продукт няма и не излиза нищо. Каквото и да напише потребителят, то не може да стане SQL.
Правилото е просто: стойност от потребителя, от файл или от мрежата никога не се долепя в SQL. Винаги ?.
Пробвай
Кафе с апостроф
Кафе 'Арабика' програмата спира с „Syntax error in SQL statement "UPDATE products SET name = 'Кафе '[*]Арабика'' WHERE id = 1"“. На кой ред е грешката?Избери реда с грешката.
'Арабика' затваря текста по-рано и останалото е безсмислен SQL ([*] в съобщението сочи мястото). Нападател би сложил там своя заявка. Поправката е PreparedStatement: "UPDATE products SET name = ? WHERE id = ?", после ps.setString(1, newName) и ps.setInt(2, id). Тогава името се записва точно както е, с апострофите.Идеята
Транзакция: всичко или нищо
Ана превежда 150 € на Борис. Това са две заявки: добави на Борис, извади от Ана. В таблицата има условие CHECK (balance >= 0), затова втората заявка се проваля: Ана има само 100 €.
static void openAccounts(Connection conn) throws SQLException { try (Statement st = conn.createStatement()) { st.executeUpdate("CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT CONSTRAINT no_debt CHECK (balance >= 0))"); st.executeUpdate("INSERT INTO accounts VALUES (1, 'Ана', 100), (2, 'Борис', 50)"); }}
static void move(Connection conn, int id, int amount) throws SQLException { try (PreparedStatement ps = conn.prepareStatement("UPDATE accounts SET balance = balance + ? WHERE id = ?")) { ps.setInt(1, amount); ps.setInt(2, id); ps.executeUpdate(); }}
static void printBalances(Connection conn) throws SQLException { try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT owner, balance FROM accounts ORDER BY id")) { while (rs.next()) { System.out.println(" " + rs.getString("owner") + ": " + rs.getInt("balance") + " €"); } }}static void transfer(Connection conn, int from, int to, int amount) throws SQLException { move(conn, to, amount); move(conn, from, -amount);}
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:bank")) { openAccounts(conn); try { transfer(conn, 1, 2, 150); } catch (SQLException e) { System.out.println("Преводът на 150 € е отказан."); } printBalances(conn);}Преводът на 150 € е отказан. Ана: 100 € Борис: 200 €static void transfer(Connection conn, int from, int to, int amount) throws SQLException { conn.setAutoCommit(false); try { move(conn, to, amount); move(conn, from, -amount); conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); }}Преводът на 150 € е отказан. Ана: 100 € Борис: 50 €setAutoCommit(false)започва транзакция: промените още не са окончателни.commit()ги записва всички наведнъж.rollback()ги отменя всички, ако нещо се провали. Изключението продължава нагоре, за да разбере и викащият.finallyвръща връзката в обикновения режим.
По-точно, за любопитните
- Съобщението на H2 при провалената заявка е
Check constraint violation: "NO_DEBT: ".NO_DEBTе името на условието: в таблицата то еbalance INT CONSTRAINT no_debt CHECK (balance >= 0).
- Базите обещават четири неща за транзакциите, известни като ACID: всичко или нищо (atomicity), данните остават верни според правилата на таблиците (consistency), едновременните транзакции не си пречат (isolation) и записаното не се губи дори при спрял ток (durability).
Идеята
ORM: обекти вместо редове
@Entity@Table(name = "products")class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false, length = 50) private String name;
private double price;
protected Product() { }
public Product(String name, double price) { this.name = name; this.price = price; }
public String getName() { return name; }
public double getPrice() { return price; }}@Entityказва „този клас е таблица“,@Tableдава името ѝ.@Idе първичният ключ, а@GeneratedValueоставя базата да му дава стойност.@Columnзадава подробности за колоната. Полетата без анотация също стават колони.- Празният конструктор е задължителен: през него Hibernate прави обекта, преди да попълни полетата.
Записът и четенето минават през EntityManager:
static void jpa() { EntityManagerFactory factory = Persistence.createEntityManagerFactory("shop"); EntityManager em = factory.createEntityManager();
em.getTransaction().begin(); em.persist(new Product("Кафе", 7.50)); em.persist(new Product("Чай", 3.20)); em.getTransaction().commit();
List<Product> cheap = em.createQuery( "SELECT p FROM Product p WHERE p.price < :max ORDER BY p.price", Product.class) .setParameter("max", 5.0) .getResultList(); for (Product p : cheap) { System.out.println(p.getName() + ": " + p.getPrice() + " €"); }
em.close(); factory.close();}persist записва обекта, и то в транзакция, както в JDBC. Заявката е на JPQL: прилича на SQL, но говори за класа Product и полето му price, не за таблицата. Резултатът е списък от готови обекти. Кой е драйверът и адресът, пише в META-INF/persistence.xml:
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" version="2.2"> <persistence-unit name="shop"> <class>Product</class> <properties> <property name="javax.persistence.jdbc.url" value="jdbc:h2:mem:shop"/> <property name="hibernate.hbm2ddl.auto" value="create"/> </properties> </persistence-unit></persistence>Тук на сайта този код само се компилира: за да работи, трябват и Hibernate с всички негови библиотеки, и конфигурация.
По-точно, за любопитните
- Със собствения интерфейс на Hibernate същото е през
Session, а заявките са на HQL (езикът на Hibernate, от който е тръгнал JPQL):
static void hibernate() { SessionFactory factory = new Configuration().configure().buildSessionFactory(); try (Session session = factory.openSession()) { Transaction tx = session.beginTransaction(); session.save(new Product("Шоколад", 2.40)); tx.commit();
List<Product> cheap = session.createQuery("from Product where price < :max", Product.class) .setParameter("max", 5.0) .list(); System.out.println("Евтини: " + cheap.size()); } factory.close();}- Заявка може да се построи и с код, без текст: Criteria API. Удобно е, когато условията се сглобяват според избора на потребителя:
static List<Product> named(EntityManager em, String name) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Product> query = cb.createQuery(Product.class); Root<Product> p = query.from(Product.class); query.select(p).where(cb.equal(p.get("name"), name)); return em.createQuery(query).getResultList();}- Транзакцията с
EntityManagerи отмяна при грешка:
EntityTransaction tx = em.getTransaction();try { tx.begin(); // промени по ентитетите tx.commit();} catch (RuntimeException e) { if (tx.isActive()) { tx.rollback(); } throw e;} finally { em.close();}- Hibernate 5 и JPA 2.2 са с пакета
javax.persistence. От JPA 3 (Hibernate 6) пакетът еjakarta.persistence, а останалото е почти същото.
Упражнение
Магазин с база
com.h2database:h2) и адрес jdbc:h2:mem:....1. Каталог. Направи таблица products и методи add, setPrice, remove и list, всички с PreparedStatement освен list. Добави Кафе (7,50 €), Чай (3,20 €) и Шоколад (2,40 €), отпечатай, смени цената на кафето на 6,90 €, изтрий шоколада и отпечатай пак:1. Кафе: 7.5 €2. Чай: 3.2 €3. Шоколад: 2.4 €---1. Кафе: 6.9 €2. Чай: 3.2 €find(conn, text) намира всички, чието име съдържа текста, без значение от малки и главни букви: WHERE LOWER(name) LIKE ? със стойност "%" + text.toLowerCase() + "%". Пробвай „КА“, „чай“ и опит за injection:Търся „КА“: Какао: 4.8 € Кафе: 7.5 €Търся „чай“: Чай: 3.2 €Търся „' OR '1'='1“: няма такива продуктиtransfer(conn, from, to, amount) първо вади от from, после добавя на to. Ако executeUpdate върне 0 (няма такава сметка), хвърли SQLException("Няма сметка " + id). Всичко е в транзакция. Преведи 30 € от 1 към 2, после 40 € от 1 към несъществуващата 99:Преведени 30 € от 1 към 2Отказан: Няма сметка 99 Ана: 70 € Борис: 80 €Вторият превод е извадил 40 € от Ана, преди да се провали, но rollback ги е върнал.
Тест