Banners System

СИСТЕМЫ УПРАВЛЕНИЯ БАЗАМИ ДАННЫХ #03/97
<< ПРЕДЫДУЩАЯ СТАТЬЯ ] [ ОГЛАВЛЕНИЕ ] [ СЛЕДУЮЩАЯ СТАТЬЯ >>

Обзор спецификации JDBC

С. Орлик

Спецификация
Приложения

Я думаю, что читатель со мной согласится, - нельзя говорить о промышленном использовании инструментального средства общего назначения (каковым является Java в сетецентрической модели вычислений), если это средство не поддерживает работу с базами данных. Практически одновременно с выпуском первой реализации JDK (Java Development Kit) была опубликована и спецификация JDBC (часто расшифровывается как Java DataBase Connectivity, однако это не акроним, а зарегистрированная торговая марка). Если мы попытаемся дать наиболее краткую аннотацию JDBC, то скорее всего получим следующее: JDBC - это Java API для выполнения SQL-запросов к базам данных. Так как Java является объектно-ориентированным языком, то под API (Application Programming Interface) подразумевается набор классов и интерфейсов (в понимании языка Java). Эти классы и интерфейсы описываются в специальном пакете (package) java.sql .

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

Спецификация

1. Введение (Introduction)

JDBC, также как и Microsoft ODBC и Borland DataBase Engine (BDE), базируется на X/Open SQL CLI (Call Level Interface). Авторы спецификации обращают особое внимание на то, что их основная задача состоит в описании основных абстракций и концепций, определенных в X/Open CLI, в виде натуральных ("родных") интерфейсов Java.

Для того чтобы лучше понять суть подхода, используемого в JDBC, напомню, что представляют из себя интерфейсы Java. В отличие от классов, заключающих в себе как объявление, так и реализацию методов, интерфейсы обеспечивают более высокий уровень абстракции, описывая только объявления методов. Учитывая возможность наследования, причем наследования множественного (в отличие от классов), такой подход позволяет создавать программы, предназначенные для работы с базами данных, не зависящие от конкретной реализации как самой СУБД, так и методов доступа к ней. Для того чтобы обратиться к конкретной СУБД (подразумеваются серверы баз данных), будь это Oracle, Informix, InterBase или что-то другое, разработчику необходим JDBC-драйвер. В концепции универсиализации доступа к данным через стандартные интерфейсы (мы их рассмотрим ниже в этой статье) JDBC-драйвер есть совокупность классов, реализующих JDBC-интерфейсы. Под реализацией интерфейса в Java понимается создание класса, ссылающегося в своем объявлении на интерфейс и предлагающего конкретную реализацию методов интерфейса уже в виде методов данного класса.

2. Цели и философия (Goals and phylosophy)

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

3. Обзор основных интерфейсов (Overview of the major interfaces)

Данный раздел дает краткое описание базовых интерфейсов (3.1 The JDBC API) и интерфейса драйвера (3.2 The JDBC Driver Interface).

Первая часть этого раздела выделяет как наиболее важные следующие интерфейсы (общая схема их взаимодействия представлена на рис. 1):

Picture_1

Рисунок 1.
Основные интерфейсы JDBC.

Интерфейс выражения java.sql.Statement выступает в качестве предка для других двух важных интерфейсов: java.sql.PreparedStatement и java.sql.CallableStatement, первый из которых предназначен для выполнения прекомпилированных SQL-выражений, второй - для выполнения вызовов хранимых процедур. Соответственно Statement выполняет обычные (статические) SQL-запросы, а указанные два наследника работают с параметризированными SQL-выражениями.

Вторая часть рассматриваемого раздела (3.2) акцентирует внимание разработчиков на разделении JDBC API на несколько уровней - прикладного API (верхний уровень), драйверного API (средний уровень) - функций драйвера для получения базовых интерфейсов - соединения, выражений и результирующих данных, а также описательного (нижний уровень), отвечающего за конкретную реализацию JDBC-драйвера, как системно/СУБД-зависимой части (например, как платформно-зависимый мост JDBC-ODBC или, полностью написанные на Java так называемые драйверы JDBC-Net).

Кроме перечисленных базовых интерфейсов JDBC описывает и другой, не менее важный специфический набор интерфейсов - для работы с метаданными, то есть со структурой БД (мы рассмотрим их несколько позже). Эти две группы интерфейсов являются краеугольными камнями JDBC.

Наконец, приведем простейший пример установки соединения и получения данных из БД:

Connection con = DriverManager.getConnection 
(
        "jdbc:wombat", "myloginname", 
        "mypassword");
        // то же самое с использованием моста 
        // JDBC-ODBC
        // Connection con = 
        // DriverManager.getConnection (
        // "jdbc:odbc:wombat", "myloginname", 
        // "mypassword");
Statement stmt = con.createStatement();
ResultSet res = stmt.executeQuery("SELECT 
StringColumn,IntColumn FROM MyTable");
        while (res.next()) {
                String sCol = res.getString("StringColumn");
                int iCol = res.getInt("IntColumn");
                ... // обработка данных
        }

Picture_2

Рисунок 2.
Уровни реализации JDBC API.

Конечно, в реальной системе нам необходимо обработать возможные исключительные ситуации, которые могут возникнуть при неправильном вводе пароля, указании несуществующей таблицы в SQL-запросе и т.п. Однако, построение полнофункционального приложения не есть цель данной статьи, оставим ее авторам книг по Java и JDBC.

4. Сценарии использования (Scenarios of using)

Раздел описывает основные два сценария доступа к БД - из апплетов (4.1), загружаемых по сети, и самостоятельных приложений (4.2), выполняемых на клиенте.

Наиболее часто встречающаяся область применения Java - это создание апплетов, загружаемых по сети на клиентскую машину как часть web-документа (в большинстве случаев html). Как и в любой другой области применения программ, большая доля этих апплетов должна уметь взаимодействовать с базой данных, причем, с базой данных находящейся на сервере (не на клиентской машине, так как апплет обладает более существенными ограничениями по доступу к ресурсам машины по сравнению с приложением).

В этом случае сценарий взаимодействия такого апплета с БД будет напоминать следующий (см. рис. 3).

  1. Апплет загружается (в виде байт-кода) на клиентскую машину в составе web-документа;
  2. Виртуальная машина на клиенте стартует апплет.
  3. Апплет запрашивает менеджер драйверов JDBC (при этом необходимый JDBC-драйвер физически находится на клиенте);
  4. Апплет получает доступ к серверу баз данных по протоколу Internet (TCP).

В зависимости от конкретной архитектуры реализации драйвера клиентская часть сервера баз данных может присутствовать на клиенте или только на web-сервере (или другом сервере, доступном в сети). В первом случае JDBC-драйвер обычно реализуется как набор Java-классов, описывающих интерфейсы java.sql.* через native-вызовы (платформно-зависимый код). Во втором случае JDBC-драйвер обычно называют JDBC-Net. Он написан целиком на Java и обращается к серверу баз данных по TCP/IP, формируя дейтаграммы в формате, понятном серверу.

Понятно, что типичные апплеты отличаются от традиционных приложений баз данных целым рядом особенностей:

Архитектура Java позволяет также создавать и самостоятельные приложения (4.2). Конечно, они, как и апплеты, выполняются виртуальной машиной, но в отличие от последних существуют вне контекста web-документов. В этом случае сценарий отличает от описанного выше отсутствие пункта 1, а ограничения на доступ к локальным ресурсам снимается до уровня обращения к ним через виртуальную машину (см. рис. 3).


Picture_3

Рисунок 3.
Взаимодействие приложения с БД.

Кроме двух основных сценариев, описанных выше, этот раздел содержит и упоминание "безопасных" (trusted) апплетов и трехзвенной модели (4.3 Other scenarios). Безопасные апплеты включают криптографический ключ и могут, как и приложения, обращаться к локальным ресурсам через виртуальную машину. Случай трех-уровневой архитектуры подразумевает вызов из апплетов или приложений не сервера баз данных напрямую, а сервера приложений/бизнес-логики, который уже, в свою очередь, осуществляет взаимодействие с сервером БД.

На рис. 4 представлена диаграмма взаимодействия Java-кода и баз данных в трехуровневой (точнее, многоуровневой N-tier) модели. Как вы видите, в качестве основных способов взаимодействия между Java-приложениями (апплетами) и серверами среднего звена используются RPC (вызовы удаленных процедур) и CORBA. В случае CORBA, вообще говоря, кроме звена бизнес-логики присутствует и ORB (брокер объектных запросов или, как стало принято называть у нас в литературе, брокер объектных заявок). Что касается самих серверов приложений, то Java (в частности, JDBC) никаких специальных требований к ним не предъявляет, как и в случае с серверами баз данных.

Picture_4

Рисунок 4.
Трехуровневая модель.

5. Соображения безопасности (Security considerations)

Название раздела говорит само за себя. Основной упор в защите доступа к базам данных делается на использование менеджера безопасности Java, реализуемого исполняющей системой (виртуальной Java-машиной).

В отношении апплетов спецификация JDBC (5.1 JDBC and untrusted applets) формулирует следующие основные положения.

В отношении Java-приложений (5.2 JDBC and Java applications) JDBC не накладывает никаких специальных ограничений, загружая драйверы из локального пути имен. Однако, если класс sql.Driver загружается из удаленного источника, этот драйвер может быть использован только тем загружаемым кодом, который имеет один и тот же источник с этим классом.

В качестве шаблона прикладного кода проверки доступ на уровне сетевого взаимодействия (5.3 network security), еще до открытия сессии взаимодействия с базой данных, предлагается следующее:

SecurityManager security =
        System.getSecurityManager();
if (security != null) {
        secutiry.checkConnect(hostName,
                portName)
...
}

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

Часть рассматриваемого раздела "Ответственность драйверов за безопасность" (5.4 Security Responsibilities of Drivers) описывает основные моменты, связанные с разделением (совместным использованием) TCP-соединений (5.4.1 ... sharing TCP connections), необходимых проверок для доступа к локальным файлам (5.4.2) и вызовов native-методов для осуществления взаимодействия с низкоуровневыми библиотеками доступа к базам данных (на примере моста JDBC-ODBC).

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

SecurityManager security =
        System.getSecurityManager();
if (security != null) {
        secutiry.checkXxxx(...)
. . .
}

6. Организация соединения с базой данных (Database connections)

Этот раздел освещает основные вопросы, связанные с подсоединением к базе данных.

Для доступа к базе данных необходимо получить объект java.sql.Connection. Сделать это можно, обратившись к уровню управления JDBC посредством вызова java.sql.DriverManager.getConnection. Основным параметром этого вызова является строка URL (Uniform Resource Locator), описывающая такие характеристики соединения, как субпротокол (например, ODBC) и имя базы данных (может быть комплексным, описывая сервер, путь и файл БД). После получения объекта соединения можно начать работу непосредственно с базой данных, обращаясь к методам java.sql.Connection для создания объектов java.sql.Statement, java.sql.PreparedStatement и java.sql.CallableStatement.

Механизм именования баз данных в JDBC, базируясь на URL, решает такие вопросы, как автоматический выбор драйвера, способного осуществить доступ к данной БД, определение характеристик соединения и т.п.

В общем случае спецификация рекомендует следующую конструкцию URL:

jdbc:<subprotocol>:<subname>

Используя субпротокол, вы можете обращаться к специфическим сетевым службам именования, например DCE. Простейшим примером может служить следующая строка: jdbc:odbc:mysrc , где в качестве субпротокола выступает ODBC, определяя использование моста JDBC-ODBC, а mysrc является источником данных (data source), определенным уже в рамках ODBC. Мост JDBC-ODBC предусматривает следующий синтаксис:

jdbc:odbc:<data-source-name>[; <attribute-name>=<attribute-value>]

В качестве атрибутов могут выступать такие характеристики, как размер кэша (CacheSize), имя пользователя (UID), пароль (PWD) и т.п.

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

Class.forName("acme.db.Driver");, его регистрация через вызов DriverManager.registerDriver и т. п.

В случае с ODBC можно привести следующий пример динамического соединения:

Class.forName("sun.jdbc.odbc.
        JdbcOdbcDriver");
String url = "jdbc:odbc:mysrc";
DriverManager.getConnection(url,
        "myuserID", "mypassword");

7. Передача параметров и получение результатов (Passing parameters and receiving results)

Еще раз приведем пример получения данных из БД:

Statement stmt = con.createStatement();
ResultSet res = stmt.executeQuery("SELECT StringColumn,IntColumn FROM
        MyTable");
while (res.next()) {
        String sCol = res.getString("StringColumn");
        int iCol = res.getInt("IntColumn");
        ... // обработка данных
}

В данном случае использовалось обращение к столбцам таблицы по их именам. Причем регистр имени столбца не играет роли (case insensitive).

JDBC предусматривает и доступ по номеру столбца:

Statement stmt = con.createStatement();
ResultSet res = stmt.executeQuery("SELECT StringColumn,IntColumn FROM
        MyTable");
while (res.next()) {
        String sCol = res.getString(1);
        int iCol = res.getInt(2);
        ...
}

При этом столбцы нумеруются начиная с 1, а не с нуля.

Очевидно, что при описанных подходах в работе с результирующим множеством строк особое значение приобретает исчерпывающее описание методов ResultSet.getXxxx. Действительно, информация в базе данных часто хранится не только в виде столбцов простейших типов (целое, длинное целое и т. п.), но и в специфических двоичных форматах. Таблица 1 описывает возможность получения данных того или иного типа с помощью соответствующих методов getXxxx (особо выделены те ячейки отображения "метод-тип", использование которых рекомендовано стандартом).

T S I B R F D D N B C V L B V L D T T
  Y M N I E L O E U I H A O I A O A I I
  I A T G A O U C M T A R N N R N T M M
  N L E I L A B I E   R C G A B G E E E
  Y L G N   T L M R     H V R I V     S
  I I E T     E A I     A A Y N A     T
  N N R         L C     R R   A R     A
  T T                     C   R B     M
                          H   Y I     P
                          A     N      
                                Y      

getByte X x x x x x x x x x x x x            
getShort x X x x x x x x x x x x x            
getInt x x X x x x x x x x x x x            
getLong x x x X x x x x x x x x x            
getFloat x x x x X x x x x x x x x            
getDouble x x x x x X X x x x x x            
getNumcric x x x x x x x X X x x x x            
getBoolean x x x x x x x x x X x x x            
getString x x x x x x x x x x X X x x x x x x x
getBytes                         X X x      
getDate                     x x x       X   x
getTime                     x x x         X x
getTimestamp                     x x x       x   X
getAsciiSteam                     x x X x x x      
getUnicodeStream                     x x X x x x      
getBinaryStream                     x x X x x x      
getObject x x x x x x x x x x x x x x x x x x x

Таблица 1.
Использование методов ResultSet.getXxxx для основных типов SQL.

В рассматриваемом разделе спецификации JDBC определяется и обработка значения SQL "NULL". Для его идентификации можно использовать метод ResultSet.wasNull. В случае чтения "пустых" данных с использованием методов ResultSet.getXxxx вы получите:

Как вы обратили внимание (см. табл.1), JDBC предусматривает работу с "большими" величинами - LONGVARBINARY и LONGVARCHAR. Для их обработки используются методы getBytes и getString соответственно. Однако существует величина Statement.getMaxFieldSize, ограничивающая объем данных, прочитываемых за одну операцию getXxxx. В случае данных, размеры которых больше описанной величины, предлагается использовать потоки Java, например:

java.sql.Statement stmt =
        con.createStatement();
ResultSet r = stmt.executeQuery("SELECT
        FirstCol FROM MyTable");
byte buff = new byte[4096];
while (r.next()) {
        Java.io.InputStream fin =
                r.getAsciiStream(1);
        for (;;) {
                int size = fin.read(buff);
                if (size == -1) { // конец потока
                        break;
                }
. . .

Говоря о передаче параметров, мы подразумеваем параметры IN (передаваемые) и OUT (возвращаемые).

В обоих случаях требуется установить взаимное соответствие типов Java и SQL. Отображению типов посвящен раздел 8 спецификации. В данном же контексте для нас важнее методы работы с параметрами объектов, описывающих интерфейсы PreparedStatement и CallableStatement.

В случае с параметрами IN, необходимо использовать методы PreparedStatement.setXxxx, принимающие в качестве первого значения номер параметра. Сам же параметр должен описываться в SQL-выражении символом ? , как показано в следующем примере:

PreparedStatement pstmt =
        con.prepareStatement(
                "UPDATE MyTable SET IntCol1 = ? WHERE
                        IntCol2 = ?");
pstmt.setLong(1, 12345);
pstmt.setLong(2, 67890);
pstmt.executeUpdate();

Параметры OUT несут смысловую нагрузку в случае работы с хранимыми процедурами. Для этого используются методы CallableStatement. Как и в случае с параметрами IN, в SQL-выражении они представлены символами ?. Вызов Connection.prepareCall принимает такое SQL-выражение. Далее, по порядковому номеру параметров, регистрируются их типы посредством вызовов CallableStatement.registerOutParameter. Теперь запрос может быть выполнен с помощью CallableStatement.executeUpadate(). В остальном работа с набором строк не отличается от описанной выше. Следующий пример демонстрирует работу с параметрами OUT:

CallableStatement cstmt =

con.prepareCall(
        "{call getMyData(?, ?)}");
cstmt.registerOutParameter(1,
        java.sql.Types.DECIMAL, 2);
cstmt.registerOutParameter(2,
        java.sql.Types.TYNYINT);
cstmt.executeQuery();

При работе с параметрами, также как и при чтении, возможна работа с "большими" данными. Для этого используются такие методы, как setBinaryStream и т. п.

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

8. Отображение типов данных SQL в Java (Mapping SQL data types into Java)

По сути, этот раздел содержит две таблицы отображения типов SQL в Java и Java в SQL. Соответствующие комментарии спецификации обосновывают выбранные варианты отображения. Мы же приведем, так сказать, только факты - сами таблицы.

SQL type Java Type
CHAR String
VARCHAR String
LONGVARCHAR String
NUMERIC java.sql.Numeric
DECIMAL java.sql.Numeric
BIT boolean
TINYNT byte
SMALLINT short
INTEGER int
BIGINT long
REAL float
FLOAT double
DOUBLE double
BINARY byte[ ]
VARBINARY byte[ ]
LONGVARBINARY byte[ ]
DATE java.sql.Date
TIME java.sql.Time
TIMESTAMP java.sql.Timestamp

Таблица 2.
Отображение типов SQL в Java.

9. Асинхронные запросы, потоки и транзакции (Asynchrony, Threading, and Transactions)

Некоторые API, ориентированные на работу с базами данных, предлагают специальные методы для поддержки асинхронных обращений к БД (например, фонового резервного копирования). JDBC не предлагает никаких специальных методов такого рода. Так как Java изначально разрабатывалась как многопоточный инструмент. вы, можете создавать новые потоки Java и выполнять в них операции, требующие асинхронной обработки информации. Соответственно выдвигаются и необходимые требования по поддержке многопоточности (multi-thread safe) к объектам, описывающим интерфейсы пакета java.sql.* .

Новое подключение к базе данных изначально стартует в режиме auto-commit. Это значит, что каждое выражение выполняется в отдельной транзакции. При необходимости можно отменить этот режим, вызвав метод Connection.setAutoCommit с параметром false. После этого необходимо в явном виде указывать операции завершения/отката транзакции посредством вызовов Connection.commit / Connection.rollback. Для определения и контроля уровней изолированности транзакций (набор которых может отличаться для разных серверов баз данных) предлагается использовать методы java.sql.DatabaseMetaData и java.sql.Connection.

10. Курсоры (Cursors)

JDBC предоставляет простейшую поддержку курсоров. Приложения могут использовать ResultSet.getCursorName() для получения курсора, ассоциированного с данным набором строк. После этого с помощью курсора можно выполнять выборочные (с позиционированием) обновления и удаления строк. Однако не все серверы БД поддерживают эти механизмы, поэтому перед их вызовом необходимо удостовериться в такой поддержке с помощью методов DatabaseMetaData.supportPositionedUpdate и DatabaseMetaData.supportPositionedDelete.

11, 12. Расширения SQL (11 SQL Extensions, 12 Variants and Extensions)

Ряд серверов баз данных поддерживают расширения SQL, выходящие за рамки входного уровня стандарта SQL-2. Данный раздел спецификации описывает следующие положения, необходимые для получения драйвером логотипа JDBC-CompliantTM:

Расширения также включают поддержку литералов, специфицирующих величины даты и времени, скалярные функции (в соответствии с семантикой, определенной X/Open CLI и ODBC), escape-последовательности для оператора LIKE и синтаксис outer join.

Раздел 12 включает дополнительные соображения, касающиеся специфики конкретных сервров, по сути, разъясняя достаточно "нейтральную" позицию спецификации, в отношении дополнительной поддержки драйверами особенностей серверов по определению и работе с метаданными. Основным моментом здесь является предоставление всей необходимой информации о расширениях через реализацию интерфейса java.sql.DatabaseMetaData (информация о метаданных).

13. Определение интерфейсов JDBC (JDBC Interface Definitions)

Спецификация не включает полного описания JDBC API, адресуя разработчиков к документации по пакету java.sql. Однако в данном разделе дается диаграмма связей между ними (рис. 6) перечисляются все базовые интерфейсы JDBC:

Интерфейсы ядра JDBC:

java.sql.CallableStatement
java.sql.Connection
java.sql.DataTruncation
java.sql.Date
java.sql.Driver
java.sql.DriverManager
java.sql.DriverPropertyInfo
java.sql.Numeric
java.sql.PreparedStatement

java.sql.ResultSet
java.sql.SQLException
java.sql.SQLWarning
java.sql.Statement
java.sql.Time
java.sql.Timestamp
java.sql.Types

Интерфейсы метаданных:

java.sql.DatabaseMetaData
java.sql.ResultSetMetaData

14. Динамический доступ к базам данных (Dynamic Database Access)

Авторами спецификации отмечается, что, хотя в большинстве случаев разработчики заранее знают схему базы данных (метаданные), существует ряд задач, когда анализ БД происходит "на лету". Именно для такого анализа и предназначены указанные в предыдущем разделе интерфейсы метаданных. Эти интерфейсы могут использоваться в сочетании с интерфейсами ядра JDBC без каких либо конфликтов со стороны менеджера драйверов или самих драйверов. В продолжение раздела 8 определяется соответствие SQL-типов объектам Java для использования методов getObject и setObject для ввода/вывода данных. В качестве примера использования этих методов можно привести следующий вариант уже знакомой последовательности вызовов на чтение данных:

Statement stmt = con.createStatement();
ResultSet res = stmt.executeQuery("SELECT
        StringColumn,IntColumn FROM MyTable");
while (res.next()) {
        Object sCol =
                res.getObject("StringColumn");
        Object iCol =
                res.getObject("IntColumn");
        ... // анализ реального типа объектов,
        // их преобразование и использование
}
Picture_6

Рисунок 6.
Важнейшие связи между интерфейсами JDBC.

Приложения

Спецификация JDBC включает четыре приложения.

Приложение A освещает некоторые приемы и методы, используемые при разработке JDBC-приложений .

Приложение B представляет собой набор примеров использования JDBC (использование SELECT и UPDATE).

В Приложении C даются краткие замечания по реализации JDBC на примере работы с результирующим набором строк через интерфейс ResultSetMetaData.

Приложение D содержит список последних изменений и дополнений стандарта JDBC.

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

Наиболее полную информацию (спецификации, слайд-шоу, примеры, статьи и т.п.) по JDBC и другим технологиям Java читатель может получить на Web-сервере.


Сергей Орлик, Московский офис Borland, тел. 238 - 36 - 11

Ваше имя:  E-mail: 
Оценка интересности и/или полезности статьи:
интересно и/или полезно
мало интересно или полезно
вредная статья

Стиль изложения
читается легко
несколько трудна для чтения
очень трудно читать
Ваш комментарий:


 

<< ПРЕДЫДУЩАЯ СТАТЬЯ ] [ ОГЛАВЛЕНИЕ ] [ СЛЕДУЮЩАЯ СТАТЬЯ >>
Banners System