![]() |
Технология Клиент-Сервер 2010'1 |
||||||
|
Grails Object Relational Mapping (GORM) API – одна из главных частей Web-фреймворка Grails. В статье «GORM: забавное название, серьезная технология» вы познакомились с основами GORM, включая простые отношения «один-ко-многим». Позже, в статье «Отношения многие-ко-многим с ложкой Ajax», вы использовали GORM, чтобы смоделировать более сложные отношения классов. Теперь вы увидите, как «ORM» в GORM гибко работает с именами таблиц и колонок в унаследованных БД, не соответствующих стандартным соглашениям по именованию, принятым в GORM.
Всегда, когда вы работаете с данными, хранящимися в БД, важно иметь актуальную резервную копию. Мэрфи, тот, который «Законы Мэрфи», наверно, мой святой покровитель. Все, что может пойти не так, пойдет не так, значит, нужно готовиться к худшему.
Кроме резервного копирования целевой БД с помощью обычно используемого ПО, я предлагаю сделать вторую, текстовую, копию данных. Это позволит легко создавать для разработки и тестирования БД с одним и тем же набором данных, а также легко перемещать данные между серверами БД (например, мигрировать с MySQL на DB2 и обратно).
Еще раз, в этой серии мы разрабатываем приложение Trip Planner, планировщик поездок. Листинг 1 – это Groovy-скрипт backupAirports.groovy, выполняющий резервное копирование записей из таблицы airport. С помощью трех выражений и менее чем 20 строк кода он подключается к БД, копирует все записи из таблицы и экспортирует их в виде XML.
sql = groovy.sql.Sql.newInstance( "jdbc:mysql://localhost/trip?autoReconnect=true", "grails", "server", "com.mysql.jdbc.Driver") x = new groovy.xml.MarkupBuilder() x.airports { sql.eachRow("select * from airport order by id") { row -> airport(id:row.id) { version(row.version) name(row.name) city(row.city) state(row.state) country(row.country) iata(row.iata) lat(row.lat) lng(row.lng) } } } |
Первое выражение в листинге 1 создает новый объект groovy.sql.Sql. Это тонкий Groovy-фасад над стандартным пучком JDBC-классов, включающим Connection, Statement и ResultSet. Вы, возможно, опознали четыре аргумента фабричного метода newInstance: строка JDBC-подключения, имя пользователя, пароль и JDBC-драйвер (это значения из grails-app/conf/DataSource.groovy).
Следующее выражение создает groovy.xml.MarkupBuilder. Этот класс позволяет создавать XML-документы на лету.
Последнее выражение (начинающееся с x.airports) создает XML-дерево. Корневой элемент XML-документа – это airports. Для каждой записи в БД создается элемент airport с атрибутом id. В элемент airport вложены элементы version, name и city (подробнее об использовании Sql и MarkupBuilder в Groovy см. Ресурсы).
В листинге 2 показан результирующий XML.
<airports> <airport id='1'> <version>2</version> <name>Denver International Airport</name> <city>Denver</city> <state>CO</state> <country>US</country> <iata>den</iata> <lat>39.8583188</lat> <lng>-104.6674674</lng> </airport> <airport id='2'>...</airport> <airport id='3'>...</airport> </airports> |
В скрипте резервного копирования важно, что записи копируются упорядоченно по первичному ключу. При восстановлении этих данных значения нужно вставлять в том же порядке, чтобы обеспечить соответствие внешних ключей (подробнее я расскажу об этом в следующем разделе).
Заметьте, что этот скрипт никак не зависит от фреймворка Grails. Чтобы он работал, на вашей системе должен быть установлен Groovy (см. Ресурсы). В classpath нужно указать путь к JAR-файлу JDBC-драйвера. Это можно делать при запуске скрипта. На UNIX это делается так:
groovy -classpath /path/to/mysql.jar:. backupAirports.groovy |
А на Windows:
groovy -classpath c:\path\to\mysql.jar;. backupAirports.groovy |
Я достаточно часто использую MySQL, поэтому я копирую JAR в каталог .groovy/lib в своем домашнем каталоге (/Users/sdavis под UNIX, c:\Documents and Settings\sdavis под Windows). JAR-ы их этого каталога автоматически включаются в classpath при запуске Groovy-скриптов из командной строки.
Скрипт из листинга 1 выводит результат на экран. Чтобы сохранить данные в файл, перенаправьте вывод при исполнении скрипта:
groovy backupAirports.groovy > airports.xml |
Вынуть данные из БД – это полдела. Не менее важно вернуть их обратно. Скрипт restoreAirports.groovy, показанный в листинге 3, читает XML, используя Groovy XmlParser, конструирует SQL-выражение insert и использует Groovy-объект Sql для выполнения этого выражения (подробнее об XmlParser см. Ресурсы).
if(args.size()) { f = new File(args[0]) println f sql = groovy.sql.Sql.newInstance( "jdbc:mysql://localhost/aboutgroovy?autoReconnect=true", "grails", "server", "com.mysql.jdbc.Driver") items = new groovy.util.XmlParser().parse(f) items.item.each{item -> println "${item.@id} -- ${item.title.text()}" sql.execute( "insert into item (version, title, short_description, description, url, type, date_posted, posted_by) values(?,?,?,?,?,?,?,?)", [0, item.title.text(), item.shortDescription.text(), item.description.text(), item.url.text(), item.type.text(), item.datePosted.text(), item.postedBy.text()] ) } } else { println "USAGE: itemsRestore [filename]" } |
Чтобы запустить этот скрипт, введите:
groovy restoreAirports.groovy airports.xml |
Вспомните, что для того, чтобы связи между таблицами работали, поле первичного ключа на стороне «одного» должно соответствовать полю внешнего ключа на стороне «многих». Например, значение, хранимое в колонке id таблицы airport должно совпадать со значением в колонке arrival_airline_id таблицы flight.
USGS предоставляет данные об аэропортах в виде так называемого шейпфайла (shapefile). Это известный формат файла для обмена географическими данными. Шейпфайл состоит как минимум из трех файлов. .shp-файл содержит географические данные – в данном случае широту/долготу для каждого аэропорта. .shx-файл – это пространственный индекс. .dbf-файл – как вы можете догадаться – это старый добрый dBase-файл, который содержит все непространственные данные (в данном случае название аэропорта, IATA-код и так далее).
Я использовал Geospatial Data Abstraction Library (GDAL) – open-source набор утилит командной строки для работы с географическими данными – для конвертации шейпфайла в файл на Geography Markup Language (GML), используемом в этой статье. Конкретнее, я использую команду ogr2ogr -f "GML" airports.xml airprtx020.shp. С помощью GDAL вы также можете конвертировать данные в CSV-файл, GeoJSON, Keyhole Markup Language (KML) и другие форматы.
Чтобы гарантировать, что автоматически нумерованные поля id при восстановлении получат те же номера, сбросьте все таблицы перед их восстановлением. Это сбросит автонумерацию в 0, когда Grails при следующем запуске будет заново создавать таблицы.
Теперь, когда данные по аэропортам надежно сохранены в резервных копиях (и, возможно, данные из других таблиц тоже), вы готовы к экспериментам с новыми «унаследованными» данными. Вы озадачены? В следующем разделе все станет ясно.
United States Geological Survey (USGS) публикует полный список аэропортов США, включающий IATA-коды и широту/долготу. Не удивительно, что поля USGS не соответствуют полям класса Airport, использовавшегося до сих пор. Можно изменить Grails-класс, чтобы он соответствовал таблице USGS, но это приведет к большим переделкам приложения. Вместо этого можно использовать пару других способов, которые позволят гладко отобразить существующий класс Airport на другую, новую, схему таблиц.
Для начала нужно импортировать «унаследованные» от USGS данные в БД. Запустите скрипт createUsgsAirports.groovy из листинга 4, чтобы создать новую таблицу (этот скрипт предполагает, что вы используете MySQL; поскольку разные СУБД используют несколько различающийся синтаксис создания таблиц, возможно, скрипт придется изменить в случае использования других СУБД).
sql = groovy.sql.Sql.newInstance( "jdbc:mysql://localhost/trip?autoReconnect=true", "grails", "server", "com.mysql.jdbc.Driver") ddl = """ CREATE TABLE usgs_airports ( airport_id bigint(20) not null, locid varchar(4), feature varchar(80), airport_name varchar(80), state varchar(2), county varchar(50), latitude varchar(30), longitude varchar(30), primary key(airport_id) ); """ sql.execute(ddl) |
Посмотрите на usgs-airports.xml из листинга 5. Это пример GML-формата. Этот XML несколько сложнее, чем простой XML из листинга 2, созданный скриптом резервного копирования. Каждый элемент содержится в пространстве имен, и элементы более глубоко вложены.
<?xml version="1.0" encoding="utf-8" ?> <ogr:FeatureCollection xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://ogr.maptools.org/airports.xsd" xmlns:ogr="http://ogr.maptools.org/" xmlns:gml="http://www.opengis.net/gml"> <gml:featureMember> <ogr:airprtx020 fid="F0"> <ogr:geometryProperty> <gml:Point> <gml:coordinates>-156.042831420898438,19.73573112487793</gml:coordinates> </gml:Point> </ogr:geometryProperty> <ogr:AREA>0.000</ogr:AREA> <ogr:PERIMETER>0.000</ogr:PERIMETER> <ogr:AIRPRTX020>1</ogr:AIRPRTX020> <ogr:LOCID>KOA</ogr:LOCID> <ogr:FEATURE>Airport</ogr:FEATURE> <ogr:NAME>Kona International At Keahole</ogr:NAME> <ogr:TOT_ENP>1271744</ogr:TOT_ENP> <ogr:STATE>HI</ogr:STATE> <ogr:COUNTY>Hawaii County</ogr:COUNTY> <ogr:FIPS>15001</ogr:FIPS> <ogr:STATE_FIPS>15</ogr:STATE_FIPS> </ogr:airprtx020> </gml:featureMember> <gml:featureMember>...</gml:featureMember> <gml:featureMember>...</gml:featureMember> </ogr:FeatureCollection> |
Теперь создайте скрипт restoreUsgsAirports.groovy, показанный в листинге 6. Чтобы получить элементы с пространствами имен, нужно объявить пару переменных groovy.xml.Namespace. Вместо использования простой нотации с точками, используемой в предыдущем скрипте restoreAirport.groovy (листинг 3), нужно окружить элементы с пространствами имен квадратными скобками.
if(args.size()) { f = new File(args[0]) println f sql = groovy.sql.Sql.newInstance( "jdbc:mysql://localhost/trip?autoReconnect=true", "grails", "server", "com.mysql.jdbc.Driver") FeatureCollection = new groovy.util.XmlParser().parse(f) ogr = new groovy.xml.Namespace("http://ogr.maptools.org/") gml = new groovy.xml.Namespace("http://www.opengis.net/gml") FeatureCollection[gml.featureMember][ogr.airprtx020].each{airprtx020 -> println "${airprtx020[ogr.LOCID].text()} -- ${airprtx020[ogr.NAME].text()}" points = airprtx020[ogr.geometryProperty][gml.Point][gml.coordinates] .text().split(",") sql.execute( "insert into usgs_airports ( airport_id, locid, feature, airport_name, state, county, latitude, longitude) values(?,?,?,?,?,?,?,?)", [airprtx020[ogr.AIRPRTX020].text(), airprtx020[ogr.LOCID].text(), airprtx020[ogr.FEATURE].text(), airprtx020[ogr.NAME].text(), airprtx020[ogr.STATE].text(), airprtx020[ogr.COUNTY].text(), points[1], points[0]] ) } } else{ println "USAGE: restoreAirports [filename]" } |
Чтобы вставить в созданную таблицу данные из файла usgs_airports.xml, введите в командной строке:
groovy restoreUsgsAirports.groovy usgs-airports.xml |
Чтобы проверить, что это работает корректно, войдите в MySQL из командной строки и проверьте наличие данных, как показано в листинге 7:
$ mysql --user=grails -p --database=trip mysql> desc usgs_airports; +--------------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +--------------+-------------+------+-----+---------+-------+ | airport_id | bigint(20) | NO | PRI | | | | locid | varchar(4) | YES | | NULL | | | feature | varchar(80) | YES | | NULL | | | airport_name | varchar(80) | YES | | NULL | | | state | varchar(2) | YES | | NULL | | | county | varchar(50) | YES | | NULL | | | latitude | varchar(30) | YES | | NULL | | | longitude | varchar(30) | YES | | NULL | | +--------------+-------------+------+-----+---------+-------+ 8 rows in set (0.01 sec) mysql> select count(*) from usgs_airports; +----------+ | count(*) | +----------+ | 901 | +----------+ 1 row in set (0.44 sec) mysql> select * from usgs_airports limit 1\G *************************** 1. row *************************** airport_id: 1 locid: KOA feature: Airport airport_name: Kona International At Keahole state: HI county: Hawaii County latitude: 19.73573112487793 longitude: -156.042831420898438 |
Теперь, когда унаследованная таблица на месте, осталось последнее: отключить переменную dbCreate в grails-app/conf/DataSource.groovy. Вспомните, в «GORM: забавное название, серьезная технология» я писал, что эта переменная говорит GORM создать соответсвующую таблицу, если она не существует, и изменить существующие таблицы, если они не соответствуют доменным классам Grails. Если вы имеете дело с унаследованными таблицами, вы должны отключить это, чтобы GORM не разрушил схему, которую могут использовать другие приложения.
Хорошо было бы селективно включать dbCreate для некоторых таблиц и отключать для других. Увы, это глобальная настройка – «все или ничего». В ситуациях, когда имеется смесь новых и унаследованных таблиц, я позволяю GORM создать новые таблицы, затем отключаю dbCreate и импортирую мои собственные, унаследованные таблицы. Как видите, в таких ситуациях очень важно иметь хорошую стратегию резервирования и восстановления.
Первая стратегия отображения доменного класса на унаследованную таблицу, которую я покажу, - это использование статического блока отображения. Я использую их в большинстве случаев, поскольку они наиболее в духе Grails. Я обычно добавляю к доменным классам статические блоки constraints, так что добавление статических блоков mapping лежит вполне в русле остального.
Скопируйте файл grails-app/domain/Airport.groovy в grails-app/domain/AirportMapping.groovy. Это имя служит только целям демонстрации. Вам понадобятся три класса, отображаемые на одну и ту же таблицу, поэтому вам нужен способ их уникального именования (в реальных приложениях такого, скорее всего, не случится).
Закомментируйте поля city и country, поскольку в новой таблице их нет. Удалите их также из блока constraints. Теперь добавьте статический блок mapping, чтобы связать имена Grails с именами в БД, как показано в листинге 8:
class AirportMapping { static constraints = { name() iata(maxSize:3) state(maxSize:2) lat() lng() } static mapping = { table "usgs_airports" version false columns { id column: "airport_id" name column: "airport_name" iata column: "locid" state column: "state" lat column: "latitude" lng column: "longitude" } } String name String iata //String city String state //String country = "US" String lat String lng String toString() { "${iata} - ${name}" } } |
Первое выражение в блоке mapping связывает класс AirportMapping с таблицей usgs_airports. Следующее выражение говорит Grails, что таблица не содержит колонки version (GORM обычно создает ее, чтобы способствовать оптимистической блокировке). Наконец, блок columns отображает имена Grails на имена БД.
Заметьте, что благодаря использованию этой техники вы можете игнорировать отдельные поля таблицы. В данном случае в доменном классе отсутствуют поля feature и county. Чтобы добиться обратного – иметь в доменном классе поля, отсутствующие в таблице, нужно добавить статическую строку transients. Эта строка выглядит похоже на переменную belongsTo, используемую в отношениях один-ко-многим. Например, если бы в классе Airport было два поля, которые не нужно хранить в таблице, код выглядел бы так:
static transients = ["tempField1", "tempField2"] |
Показанный здесь блок mapping всего лишь краем задевает то, что можно сделать с помощью этой техники. Подробнее см. Ресурсы.
Введите grails generate-all AirportMapping, чтобы создать контроллер и GSP-представления. Поскольку эта таблица, в сущности, является справочником, откройте grails-app/controllers/AirportMappingController.groovy и оставьте там только замыкания list и show. Удалите delete, edit, update, create и save (и не забудьте удалить delete, edit и save из переменной allowedMethods; можно полностью удалить строку или оставить пустые квадратные скобки).
Чтобы сделать представления доступными только для чтения, необходимо сделать пару небольших изменений. Прежде всего, удалите ссылку New AirportMapping в начале файла grails-app/views/airportMapping/list.gsp. Сделайте то же в grails-app/views/airportMapping/show.gsp. Наконец, удалите кнопки edit и delete в конце show.gsp.
Введите grails run-app, чтобы убедиться, что блок mapping работает. Вы должны увидеть страницу, показанную на рисунке 1.
Теперь, когда с блоком mapping все ясно, двинемся дальше. Нетрудно представить, что если у вас есть унаследованные таблицы, у вас могут быть и унаследованные Java-файлы. Две следующие техники отображения предполагают, что вы хотите использовать существующий Java-код с данными из существующих таблиц.
Прежде чем в Java 1.5 появились аннотации, пользователи Hibernate создали XML-файлы отображения, или HBM-файлы. Если вспомнить, что GORM – это тонкий Groovy-фасад над Hibernate, неудивительно будет, что все старые приемы из Hibernate по-прежнему работают.
Для начала скопируйте унаследованные файлы с Java-кодом в src/java. Если вы используете пакеты, создайте по каталогу для каждого пакета. Например, файл AirportHbm.java, показанный в листинге 9, лежит в пакете org.davisworld.trip. Это значит, что полный путь к этому файлу должен быть src/java/org/davisworld/trip/AirportHbm.java.
package org.davisworld.trip; public class AirportHbm { private long id; private String name; private String iata; private String state; private String lat; private String lng; public long getId() { return id; } public void setId(long id) { this.id = id; } // место для остальных геттеров/сеттеров // all of the other getters/setters go here } |
Когда Java-файл на месте, можно создать рядом с ним «теневой» файл с названием AirportHbmConstraints.groovy, показанный в листинге 10. В него можно поместить статический блок constraints, обычно находящийся в доменном классе. Удостоверьтесь, что он находится в том же пакете, что и Java-класс.
package org.davisworld.trip static constraints = { name() iata(maxSize:3) state(maxSize:2) lat() lng() } |
Файлы в каталоге src будут компилироваться при исполнении приложения или создании WAR-файла для развертывания. Если Java-код уже скомпилирован, можно просто превратить его в JAR и поместить в каталог lib.
Теперь нужно настроить контроллер. Следуя соглашению по конфигурации, контроллер должен называться AirportHbmController.groovy. Поскольку Java-класс находится в пакете, вы можете либо поместить контроллер в тот же пакет, либо импортировать Java-класс в начало файла. Я предпочитаю импорт, как показано в листинге 11.
import org.davisworld.trip.AirportHbm class AirportHbmController { def scaffold = AirportHbm } |
Теперь скопируйте существующие HBM-файлы в grails-app/conf/hibernate. У вас должен получиться единый hibernate.cfg.xml, показанный в листинге 12, в котором указан каждый файл отображения, использованный для каждого класса. В данном случае должно иметься вхождение для файла AirportHbm.hbm.xml.
<?xml version='1.0' encoding='utf-8'?> <!DOCTYPE hibernate-configuration PUBLIC "-//Hibernate/Hibernate Configuration DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd"> <hibernate-configuration> <session-factory> <mapping resource="AirportHbm.hbm.xml"/> </session-factory> </hibernate-configuration> |
Каждому классу может соответствовать собственный HBM-файл. Этот файл является XML-эквивалентом статического блока mapping, использованного выше. В листинге 13 показан файл AirportHbm.hbm.xml:
<?xml version="1.0"?> <!DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd"> <hibernate-mapping> <class name="org.davisworld.trip.AirportHbm" table="usgs_airports"> <id name="id" column="airport_id"> <generator class="native"/> </id> <property name="name" type="java.lang.String"> <column name="airport_name" not-null="true" /> </property> <property name="iata" type="java.lang.String"> <column name="locid" not-null="true" /> </property> <property name="state" /> <property name="lat" column="latitude" /> <property name="lng" column="longitude" /> </class> </hibernate-mapping> |
Заметьте, что в ссылках на Java-класс указывается полное имя пакета. Остальные вхождения отображают Java-имена на имена из таблицы. Вхождения для полей name и iata демонстрируют длинную форму. Поскольку поле state совпадает в Java-коде и в таблице, можно сократить это вхождение; последние два поля – lat и lng – демонстрируют сокращенный синтаксис (подробнее о Hibernate-файлах отображениях см. Ресурсы).
Перезапустите Grails, если он все еще работает. Теперь вы должны видеть отображенные Hibernate данные по адресу http://localhost:8080/trip/airportHbm.
Как я уже говорил, в Java 1.5 появились аннотации. Аннотации позволяют добавить метаданные прямо в Java-класс, предваряя их префиксом @. В Groovy 1.0, вышедшем в декабре 2006 года, не поддерживались такие возможности Java 1.5, как аннотации. Все изменилось с выходом Groovy 1.5 годом позже. Это значит, что вы можете использовать в Grails-приложениях Java-файлы с EJB3-аннотациями.
Начнем с Java-файла с EJB3-аннотациями. Поместите файл AirportAnnotation.java, приведенный в листинге 14, в src/java/org.davisworld.trip, рядом с файлом AirportHbm.java:
package org.davisworld.trip; import javax.persistence.*; @Entity @Table(name="usgs_airports") public class AirportAnnotation { private long id; private String name; private String iata; private String state; private String lat; private String lng; @Id @Column(name="airport_id", nullable=false) public long getId() { return id; } @Column(name="airport_name", nullable=false) public String getName() { return name; } @Column(name="locid", nullable=false) public String getIata() { return iata; } @Column(name="state", nullable=false) public String getState() { return state; } @Column(name="latitude", nullable=false) public String getLat() { return lat; } @Column(name="longitude", nullable=false) public String getLng() { return lng; } // Методы-сеттеры не имеют аннотаций . // Здесь они не показаны, но они должны быть в файле, // если вы хотите изменять значения. } |
Обратите внимание – в этот файл нужно импортировать пакет javax.persistence. @Entity и @Table аннотируют декларацию класса, отображая его на соответствующую таблицу базы данных. Остальные аннотации появляются над методами-геттерами каждого поля. Все поля должны иметь аннотацию @Column для отображения имени поля на имя колонки. У первичного ключа должна быть аннотация @ID.
Файл AirportAnnotationConstraints.groovy из листинга 15 не отличается от предыдущего примера, приведенного в листинге 10:
package org.davisworld.trip static constraints = { name() iata(maxSize:3) state(maxSize:2) lat() lng() } |
AirportAnnotationController.groovy (листинг 16) выглядит как обычно:
import org.davisworld.trip.AirportAnnotation class AirportAnnotationController { def scaffold = AirportAnnotation } |
Здесь еще раз появляется файл hibernate.cfg.xml. На этот раз его синтаксис слегка отличается. Вместо того, чтобы указывать на HBM-файл, он указывает напрямую на класс, как показано в листинге 17.
<?xml version='1.0' encoding='utf-8'?> <!DOCTYPE hibernate-configuration PUBLIC "-//Hibernate/Hibernate Configuration DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd"> <hibernate-configuration> <session-factory> <mapping resource="AirportHbm.hbm.xml"/> <mapping class="org.davisworld.trip.AirportAnnotation"/> </session-factory> </hibernate-configuration> |
Чтобы аннотации заработали, осталось сделать еще одно. Исходно Grails не сконфигурирован для работы с EJB3-аннотациями. В grails-app/conf/DataSource.groovy нужно импортировать специальный класс, как показано в листинге 18:
import org.codehaus.groovy.grails.orm.hibernate.cfg.GrailsAnnotationConfiguration dataSource { configClass = GrailsAnnotationConfiguration.class pooled = false driverClassName = "com.mysql.jdbc.Driver" username = "grails" password = "server" } |
Импортировав org.codehaus.groovy.grails.orm.hibernate.cfg.GrailsAnnotationConfiguration и позволив Spring вставить его в блок dataSource как configClass, Grails начинает поддерживать EJB3-аннотации так же, как HBM-файлы и собственные блоки отображения.
Если забыть про этот, последний, шаг (что со мной случается почти каждый раз при использовании EJB3-аннотаций в Grails), вы получите следующее сообщение об ошибке:
org.hibernate.MappingException: An AnnotationConfiguration instance is required to use <mapping class="org.davisworld.trip.AirportAnnotation"/> |
К этому моменту для вас не должно представлять трудностей отображение своих объектов на реляционные БД в Grails (не зря же, в конце концов, это называется GORM). Если вы можете легко сохранить и восстановить свои данные, у вас есть множество способов заставить Grails работать с нестандартными соглашениями по именованию, принятыми в унаследованных базах данных. Статические блоки mapping – самый простой путь решения проблемы, поскольку он наиболее в духе Grails. Но если у вас есть Java-классы, уже отображенные на унаследованную БД, нет смысла заново изобретать колесо. Неважно, используете вы HBM-файлы или более новые EJB3-аннотации, Grails сможет использовать уже сделанную работу и позволит перейти к другим задачам
В следующей статье вы сможете поиграть с моделью событий Grails. Все, от создания скриптов до индивидуальных Grails-артефактов (доменных классов, контроллеров и т.д.), генерирует события. Вы узнаете, как настраивать слушатели для перехвата этих событий и их обработки.
Создание Web-сайта – это упражнение в разработке, основанной на событиях. Приложение сидит и тихо ждет, когда пользователь пришлет запрос. Оно возвращает объект и снова засыпает до следующего вызова. В дополнение к традиционному жизненному циклу HTTP-запросов и откликов Grails предоставляет ряд точек, позволяющих воспользоваться событийной моделью и задать собственное поведение.
В этой статье вы узнаете о множестве событий, генерируемых в процессе сборки. Вы сможете изменить поведение приложения при его запуске и завершении. Наконец, вы исследуете жизненный цикл событий доменных классов Grails.
ПРИМЕЧАНИЕ Впервые Gant-скрипты упоминались в первой статье этой серии. Вспомните, что Gant – это тонкий Groovy-фасад для Apache Ant. Gant не реализует заново задачи Ant – он вызывает нижележащий Ant-код, максимизируя совместимость. Все что можно сделать в Ant, можно сделать и в Gant. Единственное различие состоит в том, что Gant-скрипты – это Groovy-скрипты, а не XML-файлы. |
Первый ваш шаг как Grails-разработчика – это напечатать grails create-app. Последнее, что вы вводите – grails run-app или grails war. Эти команды и все, что вы вводите между ними, генерируют события в ключевых точках процесса.
Посмотрите на каталог $GRAILS_HOME/scripts. Файлы в этом каталоге – это Gant-скрипты, соответствующие вводимым вами командам. Например, когда вы вводите grails clean, вызывается Clean.groovy.
Откройте Clean.groovy в текстовом редакторе. Первая цель – это default, см. листинг 20.
target ('default': "Cleans a Grails project") { clean() cleanTestReports() } |
Как видите, не слишком много. Здесь запускается цель clean, а затем – цель cleanTestReports. Идя по стеку вызовов, посмотрите на цель clean, показанную в листинге 21:
target ( clean: "Implementation of clean") { event("CleanStart", []) depends(cleanCompiledSources, cleanGrailsApp, cleanWarFile) event("CleanEnd", []) } |
Если вы хотите изменить поведение команды clean, можете добавить сюда собственный код. Проблема такого подхода состоит в том, что эти изменения придется переносить при каждом обновлении Grails. Это также делает вашу среду более уязвимой при переносе с компьютера на компьютер (инсталляционные файлы Grails редко сохраняют в системах контроля версий, в отличие от кода приложений). Чтобы избежать синдрома «но на моей машине это работает», я предпочитаю сохранять изменения такого типа в проекте. Это гарантирует, что любое восстановление из системы контроля версий содержит все изменения, необходимые для успешной сборки. Это также помогает поддерживать все в согласованном виде при использовании сервера непрерывной интеграции, например, CruiseControl.
Заметьте, что цель clean генерирует пару событий. Событие CleanStart происходит до начала процесса, а CleanEnd – после завершения. Вы можете использовать эти события в проекте, сохраняя ваш пользовательский код с кодом проекта, и оставляя файлы Grails нетронутыми. Все, что нужно сделать – создать слушатель.
Создайте файл Events.groovy в каталоге скриптов вашего проекта. Введите в него код, приведенный в листинге 22.
eventCleanStart = { println "### About to clean" } eventCleanEnd = { println "### Cleaning complete" } |
Если теперь ввести grails clean, будет выдано нечто похожее на листинг 23:
$ grails clean Welcome to Grails 1.0.3 - http://grails.org/ Licensed under Apache Standard License 2.0 Grails home is set to: /opt/grails Base Directory: /src/trip-planner2 Note: No plugin scripts found Running script /opt/grails/scripts/Clean.groovy Environment set to development Found application events script ### About to clean [delete] Deleting: /Users/sdavis/.grails/1.0.3/projects/trip-planner2/resources/web.xml [delete] Deleting directory /Users/sdavis/.grails/1.0.3/projects/trip-planner2/classes [delete] Deleting directory /Users/sdavis/.grails/1.0.3/projects/trip-planner2/resources ### Cleaning complete |
Конечно, можно не только писать простые сообщения в консоли, но и выполнять реальную работу. Возможно, требуется удалить какие-то лишние каталоги. Может, вы захотите «перезагрузить» XML-файлы, переписав уже существующие. Все, что можно сделать в Groovy (или в Java), можно сделать и так.
Вот еще один пример событий. Каждый раз, когда вы вводите одну из команд create, генерируется событие CreatedFile. Посмотрите на scripts/CreateDomainClass.groovy (листинг 24):
Ant.property(environment:"env") grailsHome = Ant.antProject.properties."env.GRAILS_HOME" includeTargets << new File ( "${grailsHome}/scripts/Init.groovy" ) includeTargets << new File( "${grailsHome}/scripts/CreateIntegrationTest.groovy") target ('default': "Creates a new domain class") { depends(checkVersion) typeName = "" artifactName = "DomainClass" artifactPath = "grails-app/domain" createArtifact() createTestSuite() } |
Здесь вы не увидите вызова события CreatedFile, но посмотрите на цель createArtifact в $GRAILS_HOME/scripts/Init.groovy (цель createTestSuite в $GRAILS_HOME/scripts/CreateIntegrationTest.groovy в конце также вызывает цель createArtifact из $GRAILS_HOME/scripts/Init.groovy). В предпоследней строке createArtifact можно увидеть следующий вызов: event("CreatedFile", [artifactFile]).
Это событие имеет одно существенное отличие от события CleanStart: оно возвращает значение обработчику события. В данном случае это полный путь к только что созданному файлу (как вы вскоре увидите, второй аргумент – это список – вы можете вернуть столько разделенных запятыми значений, сколько захотите). Обработчик событий должен уметь перехватывать входящие значения.
Предположим, что вы хотите автоматически добавлять созданные файлы в систему контроля версий. В Groovy вы можете заключить все, что вы обычно вводите в командной строке, в кавычки, и вызвать execute() для этой строки. Добавьте в scripts/Events.groovy обработчик события (листинг 25).
eventCreatedFile = { fileName -> "svn add ${fileName}".execute() println "### ${fileName} was just added to Subversion." } |
Теперь введите grails create-domain-class Hotel и посмотрите на результат. Если вы не используете Subversion, эта команда просто тихо не выполнится. Если вы используете Subversion, введите svn status. Вы должны увидеть список добавленных файлов (доменный класс и соответствующий интеграционный тест).
Самый быстрый путь узнать, какие события генерируются какими скриптами – поискать в скриптах Grails вызовы event(). В UNIX можно использовать grep для поиска строки event в Groovy-скриптах (листинг 26).
$ grep "event(" *.groovy Bootstrap.groovy: event("AppLoadStart", ["Loading Grails Application"]) Bootstrap.groovy: event("AppLoadEnd", ["Loading Grails Application"]) Bootstrap.groovy: event("ConfigureAppStart", [grailsApp, appCtx]) Bootstrap.groovy: event("ConfigureAppEnd", [grailsApp, appCtx]) BugReport.groovy: event("StatusFinal", ["Created bug-report ZIP at ${zipName}"]) |
Теперь, когда вы знаете, что вызывается, вы можете создать соответствующие слушатели в scripts/Events.groovy и выполнить глубокую настройку среды.
Конечно, теперь, когда вы знаете как это работает, ничто не мешает вам создать свои собственные события. Если вам нужно изменять скрипты из $GRAILS_HOME/scripts (что мы и собираемся делать, чтобы генерировать события), я очень рекомендую скопировать их в каталог scripts вашего проекта. Это значит, что измененный скрипт будет сохранен в системе контроля версий вместе со всем остальным. Grails спросит, какую версию скрипта использовать – из $GRAILS_HOME или из локального каталога scripts.
Скопируйте $GRAILS_HOME/scripts/Clean.groovy в локальный каталог scripts и добавьте после события CleanEnd следующее событие:
event("TestEvent", [new Date(), "Some Custom Value"]) |
Первый аргумент – это имя события. Второй аргумент – это список возвращаемых значений. В данном случае вы возвращаете текущую дату и некое сообщение.
Добавьте в scripts/Events.groovy замыкание из листинга 27.
eventTestEvent =
{
timestamp, msg -> println "### ${msg} occurred at ${timestamp}"
}
|
Если ввести grails clean и выбрать локальную версию скрипта, вы увидите:
### Some Custom Value occurred at Wed Jul 09 08:27:04 MDT 2008 |
Вы можете использовать не только события сборки, но и события приложения. Файл grails-app/conf/BootStrap.groovy исполняется при каждом запуске и остановке Grails. Откройте BootStrap.groovy в текстовом редакторе. При запуске вызывается замыкание init. При закрытии приложения вызывается замыкание destroy.
Для начала добавим в замечания простой текст, как показано в листинге 28.
def init = { println "### Starting up" } def destroy = { println "### Shutting down" } |
Введите grails run-app для запуска приложения. В конце этого процесса должно появится сообщение ### Starting up.
Теперь нажмите CTRL+C. Видите сообщение ### Shutting Down? Я тоже не вижу. Дело в том, что CTRL+C бесцеремонно завершает работу сервера, не вызывая замыкание destroy. Можете поверить, что при завершении работы сервера приложений замыкание будет вызвано. Но вам не надо писать grails war и загружать WAR в Tomcat или IBM WebSphere, чтобы увидеть событие destroy.
Чтобы увидеть события init и destroy, запустите Grails в интерактивном режиме, написав в консоли interactive. Теперь введите run-app, чтобы запустить приложение, и exit, чтобы завершить работу сервера. Работа в интерактивном режиме существенно ускоряет процесс разработки, поскольку JVM в этом случае все время на ходу. В качестве бонуса, приложение завершается более мягко, чем в случае грубого CTRL+C.
Что можно сделать со скриптом BootStrap.groovy, кроме простого консольного вывода? Часто его используют для вставки записей в БД.
Для начала добавьте в созданный ранее класс Hotel поле name, как показано в листинге 29.
class Hotel
{
String name
}
|
Теперь создадим HotelController, как показано в листинге 30.
class HotelController
{
def scaffold = Hotel
}
|
ПРИМЕЧАНИЕ Примечание: Если вы закомментировали переменную dbCreate в grails-app/conf/DataSource.groovy, как говорилось в статье «Grails и унаследованные базы данных», ее надо вернуть на место и дать ей значение update. Можно, конечно, вместо этого вручную синхронизировать таблицу Hotel и класс Hotel. |
Теперь добавьте в BootStrap.groovy код из листинга 31.
def init = { servletContext -> new Hotel(name:"Marriott").save() new Hotel(name:"Sheraton").save() } def destroy = { Hotel.findByName("Marriott").delete() Hotel.findByName("Sheraton").delete() } |
В следующей паре примеров лучше держать открытой консоль MySQL и следить за БД. Введите mysql --user=grails -p --database=trip (пароль - server). Затем выполните следующие шаги:
Вы можете захотеть большей отказоустойчивости для вставки и удаления записей с помощью BootStrap.groovy. Если вы не проверяете перед вставкой, существует ли уже такая запись, в вашей БД могут появиться дубликаты. Если вы попытаетесь удалить несуществующую запись, вы получите исключение. В листинге 32 показано, как выполнять отказоустойчивые вставки и удаления.
def init = { servletContext -> def hotel = Hotel.findByName("Marriott") if(!hotel) { new Hotel(name:"Marriott").save() } hotel = Hotel.findByName("Sheraton") if(!hotel) { new Hotel(name:"Sheraton").save() } } def destroy = { def hotel = Hotel.findByName("Marriott") if(hotel) { Hotel.findByName("Marriott").delete() } hotel = Hotel.findByName("Sheraton") if(hotel) { Hotel.findByName("Sheraton").delete() } } |
Если вы вызовете Hotel.findByName("Marriott"), и этот отель не существует в таблице, вам будет возвращен null-объект. Следующая строка, if(!hotel), равна true только для не-null значений. Это гарантирует, что новый Hotel будет сохранен только если он еще не существует. В замыкании destroy выполняются такие же проверки, чтобы убедиться, что вы не пытаетесь удалить несуществующие записи.
ПРИМЕЧАНИЕ switch в Groovy Обратите внимание – выражение switch в Groovy более надежно, чем в Java. В Java-коде вы можете переключаться только на целочисленные значения. В Groovy можно переключаться и на строковые значения. |
Если вы хотите, чтбы что-то происходило только если вы работаете в каком-то особо режиме, вам поможет класс GrailsUtil. Импортируйте в начало файла grails.util.GrailsUtil. Статический метод GrailsUtil.getEnvironment() (сокращенный до GrailsUtil.environment благодаря сокращенному синтаксису геттеров Groovy) позволяет выяснить, в каком режиме вы работаете. Объедините это знание с выражением switch, как показано в листинге 33, и вы получите зависимое от среды поведение при запуске Grails.
import grails.util.GrailsUtil class BootStrap { def init = { servletContext -> switch(GrailsUtil.environment) { case "development": println "#### Development Mode (Start Up)" break case "test": println "#### Test Mode (Start Up)" break case "production": println "#### Production Mode (Start Up)" break } } def destroy = { switch(GrailsUtil.environment) { case "development": println "#### Development Mode (Shut Down)" break case "test": println "#### Test Mode (Shut Down)" break case "production": println "#### Production Mode (Shut Down)" break } } } |
Теперь записи вставляются только в тестовом режиме. Но не останавливайтесь на этом. Я часто выношу свои тестовые данные в XML-файлы. Объедините то, что узнали здесь, со скриптами резервирования и восстановления из статьи «Grails и унаследованные базы данных» и вы получите мощную тестовую платформу.
Поскольку BootStrap.groovy – исполняемый скрипт, а не пассивный конфигурационный файл, вы можете выполнять буквально все, что возможно в Groovy. Возможно, вы захотите при запуске вызывать Web-сервисы, оповещая центральный сервер, что данный экземпляр запущен и работает. Возможно, вы захотите синхронизировать локальные справочники с общим источником. Возможности воистину безграничны.
Теперь, когда вы видели события на макроуровне, вот примеры событий на микроуровне.
Если создать пару особо именованных полей, GORM автоматически вставит в класс временную метку, как показано в листинге 34.
class Hotel
{
String name
Date dateCreated
Date lastUpdated
}
|
Как следует из имен, поле dateCreated заполняется при первой вставке данных в БД. Поле lastUpdated заполняется при каждом обновлении записи в БД,
Чтобы проверить, что эти поля скрыто заполняются, сделайте еще одну вещь: отключите их в представлениях create и edit. Это можно сделать, введя grails generate-views Hotel и удалив поля из файлов create.gsp и edit.gsp, но скаффолдинг позволяет быть несколько динамичнее. В статье «Изменяем вид Groovy Server Pages» я рассказывал, как изменять вид скаффолдинга с помощью grails install-templates. Теперь добавьте два поля временных меток в список excludedProps в шаблонах, как показано в листинге 35.
excludedProps = ['dateCreated','lastUpdated', 'version', 'id', Events.ONLOAD_EVENT, Events.BEFORE_DELETE_EVENT, Events.BEFORE_INSERT_EVENT, Events.BEFORE_UPDATE_EVENT] |
Это исключит создание полей в представлениях create и edit, но оставит эти поля в представлениях list и show. Создайте Hotel или два и убедитесь, что поля автоматически обновляются.
Если ваше приложение уже использует эти имена полей, вы можете легко отключить эту возможность, как показано в листинге 36.
static mapping = { autoTimestamp false } |
В «Grails и унаследованные базы данных» говорилось, что можно также указать version false, если вы хотите отключить автоматическое создание и обновление поля version, используемого для оптимистических блокировок.
Кроме создания временных меток в доменных классах, можно использовать четыре перехватчика событий: beforeInsert, befortUpdate, beforeDelete и onLoad.
Имена этих замыканий говорят сами за себя. Замыкание beforeInsert вызывается перед методом save(). Замыкание beforeUpdate вызывается перед методом update().Замыкание beforeDelete вызывается перед методом delete(). Наконец, onLoad вызывается, когда класс загружается из БД.
Предположим, что в вашей компании принята политика простановки временных меток для записей БД, и что эти поля у вас стандартно называются cr_time and up_time. У вас есть две возможности заставить Grails вести себя в соответствии с корпоративной политикой. Первая – использовать прием со статическим отображением, показанный в предыдущей статье, чтобы привязать имена полей, используемые по умолчанию Grails, к именам колонок, используемым по умолчанию в компании (листинг 37).
class Hotel { Date dateCreated Date lastUpdated static mapping = { columns { dateCreated column: "cr_time" lastUpdated column: "up_time" } } } |
Вторая возможность – именовать поля доменного класса в соответствии с корпоративным стандартом и создать замыкания beforeInsert и beforeUpdate, чтобы заполнять эти поля, как показано в листинге 38 (не забудьте сделать новые поля nullable – иначе метод save() в BootStrap.groovy не сработает).
class Hotel { static constraints = { name() crTime(nullable:true) upTime(nullable:true) } String name Date crTime Date upTime def beforeInsert = { crTime = new Date() } def beforeUpdate = { upTime = new Date() } } |
Несколько раз запустите и закройте приложение, чтобы убедится, что новые поля заполняются так, как предполагалось.
Что делать с этими событиями, как и со всеми рассмотренными выше, вы решите сами. Вспомните, как в «Grails и мобильный Web» вы создавали сервис Geocoding для конвертации адресов в широту/долготу точек, чтобы отобразить на карте аэропорт. В этой статье этот сервис вызывается в замыканиях save и update в AirportController. Я бы попробовал переместить этот вызов сервиса в beforeInsert и beforeUpdate в классе Airport, чтобы все происходило автоматически и прозрачно.
Как можно распространить такое поведение на все классы? Я бы добавил эти поля и замыкания в используемый по умолчанию шаблон DomainClass в src/templates.
События в Grails помогают настраивать поведение приложение. Можно расширить процесс сборки, не изменяя стандартных Grails-скриптов, создав файл Events.groovy в каталоге scripts. Можно изменить процесс запуска и завершения приложения, добавляя собственный код в замыкания init и destroy в файле BootStrap.groovy. Наконец, добавляя такие замыкания, как beforeInsert и beforeUpdate, в доменный класс, можно реализовать такое поведение, как создание временных меток или геокодирование.
В следующей статье я расскажу о том, как использовать Grails для создания Web-сервисов, основанных на Representational State Transfer (REST). Вы увидите, как Grails поддерживает HTTP-методы GET, PUT, POST и DELETE, необходимые для поддержки Web-сервисов нового поколения, RESTful.
В этом месяце я собираюсь показать, как ваши Grails-приложения могут работать источником исходных данных – в частности, XML, – которые могут использовать другие Web-приложения. Я обычно называю это созданием Web-сервиса для Grails-приложения, но в наши этот термин перегружен скрытыми смыслами. Многие ассоциируют Web-сервисы с
Когда дело доходит до RESTful Web-сервисов, не менее важно понимать, почему, чем как. Докторская диссертация Рой Филдинг (см. Ресурсы) - источник сокращения REST – описывает два подхода к Web-сервисам: сервис-ориентированный и ресурсно-ориентированный. Прежде чем я покажу вам код реализации вашей собственной RESTful ресурсно-ориентированной архитектуры (РОА), я поясню различие между этими двумя философиями дизайна, и расскажу о двух конкурирующих популярных определениях REST. В качестве награды за чтение рассуждений в первой части этой статьи, вы найдете много Grails-кода во второй.
Когда разработчики говорят о RESTful Web-сервисах, они обычно имеют в виду, что хотят предоставить простой, беспроблемный способ получения XML от их приложений. RESTful Web-сервисы обычно предоставляют URL, возвращающий XML в ответ на HTTP-запрос GET (чуть позже я дам более формальное определение REST, уточняющее это определение в одном тонком, но важном смысле).
Yahoo! предоставляет ряд RESTful Web-сервисов (см. Ресурсы), возвращающих POX в ответ на простой http-запрос GET. Наберите, например, http://api.search.yahoo.com/WebSearchService/V1/webSearch?appid=YahooDemo&query=beatles в адресной строке браузера. Вы получите в XML-виде те же результаты поиска, что обычно выводятся в виде HTML при задании beatles в качестве условия поиска на домашней странице Yahoo!.
Если бы (гипотетически) Yahoo! поддерживало SOAP-интерфейс (оно не поддерживает), SOAP-запрос возвратил бы те же самые данные, но создание такого запроса потребовало бы несколько больших усилий от разработчика. Запрашивающей стороне пришлось бы отправлять строго определенный XML-документ с секциями SOAP-заголовка и телом вместо простого набора пар имя/значение в строке запроса – причем отправлять запрос, используя HTTP POST вместо GET. После всей этой дополнительной работы ответ пришел бы в виде формального XML-документа, имеющего, как и запрос, SOAP-заголовок и тело, которые нужно удалить, чтобы добраться до результатов запроса. RESTful-подход к Web-сервисам обычно рассматривается как «менее церемонная» альтернатива сложности SOAP.
Некоторые тренды показывают, что RESTful к Web-сервисам набирает популярность. Amazon.com предоставляет как RESTful, так и SOAP-сервисы. Исследования реального использования Web-сервисов показывают, что девять из десяти пользователей выбирают RESTful-интерфейс. Еще один примечательный пример – Google официально объявил свои SOAP Web-сервисы устаревшими еще в декабре 2006 года. Все его службы данных (собранные под крышей Google Data API) используют RESTful-подход.
Если бы разница между REST и SOAP сводилась к относительным достоинствам GET и POST, различить их было бы просто. Используемый HTTP-метод важен, но не по тем причинам, которые вы можете с ходу предположить. Чтобы полностью осознать разницу между REST и SOAP, требуется оценить глубинную семантику этих двух стратегий. SOAP воплощает сервис-ориентированный подход к Web-сервисам – тот, при котором методы (или команды) являются основным способом взаимодействия с сервисом. REST же использует ресурсно-ориентированный подход, в котором центральное место отводится объекту.
В SOA вызов сервиса выглядит как вызов удаленной процедуры (RPC). Гипотетически, если у вас есть Java-класс Weather с методом getForecast(String zipcode), вы можете легко выставить этот метод как Web-сервис. На самом деле у Yahoo! есть как раз такой Web-сервис. Введите в браузере http://weather.yahooapis.com/forecastrss?p=94089, подставив в параметр p собственный ZIP-код. Сервис Yahoo! поддерживает второй параметр – u – принимающий "f" для Фаренгейта и "с" для Цельсия. Нетрудно представить перегрузку сигнатуры метода вашего гипотетического класса, чтобы он принимал второй параметр: getForecast("94089", "f").
Вернувшись к показанному выше запросу к Yahoo!, нетрудно представить, как переписать его в виде вызова метода.
http://api.search.yahoo.com/WebSearchService /V1/webSearch?appid = YahooDemo&query=beatles |
легко превращается в
WebSearchService.webSearch("YahooDemo", "beatles"). |
Так если вызовы Yahoo! являются, в сущности, RPC-вызовами, не противоречит ли это моему предположению, что Yahoo!-сервисы – это RESTful-сервисы? К сожалению, да. Но я не одинок в своем заблуждении. Yahoo! также называет их RESTful-сервисами, хотя честно признает, что они не соответствуют строгому смыслу определения. В Yahoo! Web Services FAQ ответ на вопрос "Что такое REST?" звучит как "REST означает Representational State Transfer. Большая часть Yahoo! Web-сервисов использует 'REST-Like'-операции в стиле RPC через HTTP GET или POST..."
В REST продолжаются дебаты. Проблема в том, что нет никакой удачной фразы, сжато описывающей «основанные на RPC Web-сервисы, предпочитающие использование GET использованию POST, и простые URL-запросы XML-запросам». Некоторые называют это HTTP/POX или REST/RPC-сервисами. Другие называют их Low REST, чтобы отличать их от High REST Web-сервисов – сервисов, которые более точно соответствует исходному определению ресурсно-ориентированной архитектуры, данному Филдингом.
Неформально я называю сервисы, подобные сервисам Yahoo!'s, GETful-сервисами. Я не вкладываю в это уничижительного смысла – наоборот, я думаю, что в Yahoo! проделали огромную работу по созданию коллекции «бесцеремонных» Web-сервисов. Это название отражает главное достоинство RPC-сервисов Yahoo! – получение XML-результатов с помощью простого HTTP-запроса GET – без неверного употребления термина, введенного Филдингом.
ПРИМЕЧАНИЕ POST vs. PUT В REST-сообществе идут споры по поводу ролей POST и PUT при вставке новых ресурсов. Определение PUT в исходном RFC для HTTP 1.1 (где Филдинг был ведущим автором) говорит, что если ресурс не существует, сервер может создать его. " А если ресурс уже существует, "...содержащуюся в нем сущность следует рассматривать как модифицированную версию той, что находится на исходном сервере". Таким образом, если ресурс не существует, PUT равно INSERT. Если же ресурс существует, PUT равно UPDATE. Все было бы достаточно просто, если бы в определении POST не говорилось: «POST должен обеспечить единый метод реализации следующих функций: Аннотирование существующих ресурсов; Размещение сообщений на досках объявлений, в телеконференциях, списках рассылки или аналогичных ресурсах; Предоставление блоков данных, таких, как результат отправки форм, процессам, обрабатывающим данные; Расширение баз данных через операцию добавления». «Аннотирование существующих ресурсов» по идее подразумевает UPDATE, а «Размещение сообщений на досках объявлений» и «Расширение баз данных» должно бы подразумевать INSERT. Учитывая тот факт, что все основные браузеры не поддерживают метод PUT при отправке данных HTML-форм (они поддерживают только GET и POST) трудно определить, что народная мудрость должна сказать о том, когда какие методы использовать. Atom Publishing Protocol – популярный формат синдикации, следующий RESTful-принципам. Авторы RFC для Atom пытаются закончить споры вокруг POST и PUT с помощью определения: «Atom Publishing Protocol использует HTTP-методы для работы с ресурсами следующим образом: GET используется, чтобы получить представление известного Ресурса. POST используется для создания новых, динамически именуемых, Ресурсов... PUT используется для редактирования известных Ресурсов. Он не используется для создания Ресурса. DELETE используется для удаления известных Ресурсов". В этой статье я руководствуюсь подходом Atom, используя POST для вставки и PUT для обновления. Однако если вы решите сделать все наоборот, вы тоже будете в хорошей компании – в книге RESTful Web Services (см. Ресурсы) рекомендуется использовать PUT как INSERT. |
Итак, что же требуется от сервиса, чтобы быть реально ресурсно-ориентированным? Все сводится к созданию хорошего URI (Uniform Resource Identifier) и использованию четырех HTTP-методов (GET, POST, PUT и DELETE) стандартизованным образом вместо одного (GET) в сочетании с вызовами пользовательских методов.
Возвращаясь к запросу Beatles, первый шаг к более формальному RESTful-интерфейсу – изменение URI. Вместо передачи Beatles в качестве аргумента метода webSearch, Beatles становится центральным ресурсом в URI. Например, URI статьи в Википедии, посвященной Beatles – http://en.wikipedia.org/wiki/Beatles.
Но что на самом деле отличает философии GETful и RESTful – это метод, используемый для возврата представления ресурса. RPC-интерфейс Yahoo! определяет ряд методов (webSearch, albumSearch, newsSearch и т.д.). Без чтения документации нельзя узнать имя вызываемого метода. В случае Yahoo! я могу следовать шаблону и предположить, что существуют методы songSearch, imageSearch и videoSearch, но никаких гарантий этому нет. Кроме того, другие Web-сайты могут использовать другие соглашения по именованию, и называть методы, например, findSong или songQuery. В случае Grails такие пользовательские действия, как aiport/list и airport/show, стандартны для приложения, но эти имена методов никак не стандартны для других Web-фреймворков.
В отличие от этого, RESTful-подход всегда использует HTTP GET, чтобы возвратить представление запрашиваемого ресурса. Поэтому я знаю, что GET – это стандартный способ получения любого ресурса из Википедии (http://en.wikipedia.org/wiki/Beatles, http://en.wikipedia.org/wiki/United_Airlines или http://en.wikipedia.org/wiki/Peanut_butter_and_jelly_sandwich).
Сила стандартизации вызовов методов становится очевиднее, когда вы имеете дело с полным жизненным циклом ресурса, Create/Retrieve/Update/Delete (CRUD). RPC-интерфейс не предлагает стандартного способа создания нового ресурса. Пользовательский метод может называться create, new, insert, add или почти как угодно еще. В RESTful-интерфейсе отправка URI POST-запроса создает новый ресурс, PUT обновляет ресурс, а DELETE удаляет его (см. POST vs. PUT).
Теперь, когда вы лучше понимаете разницу между GETful и RESTful Web-сервисами, можно начать создание собственного сервиса на Grails. Я приведу примеры обоих подходов, но начну с простого POX-примера.
Самый быстрый способ создать POX из Grails-приложения – импортировать пакет grails.converters.* и добавить пару новых замыканий, как показано в листинге 39:
import grails.converters.* class AirportController{ def xmlList = { render Airport.list() as XML } def xmlShow = { render Airport.get(params.id) as XML } //... оставшаяся часть контроллера } |
Вы уже видели работу пакета grails.converters в статье «Отношения многие-ко-многим с ложкой Ajax». Этот пакет дает невероятно простую поддержку JavaScript Object Notation (JSON) и XML-вывода. На рисунке 2 показаны результаты вызова xmlList:
Генерируемый по умолчанию XML хорош для отладки, но, скорее всего, вы захотите несколько изменить формат. К счастью, метод render() предлагает Groovy MarkupBuilder, который позволяет определять пользовательский XML «на лету» (см. Ресурсы). В листинге 40 показан код для настройки выдачи XML:
def customXmlList = { def list = Airport.list() render(contentType:"text/xml") { airports { for(a in list) { airport(id:a.id, iata:a.iata) { "official-name"(a.name) city(a.city) state(a.state) country(a.country) location(latitude:a.lat, longitude:a.lng) } } } } } |
На рисунке 3 показан результирующий XML:
Обратите внимание, насколько код соответствует XML-результату. Можно задать любое имя элемента (airports, airport, city), независимо от того, соответствуют ли они именам полей класса. Если вы хотите создать имя элемента с дефисом (например, official-name) или добавит поддержку пространств имен, просто заключите имя элемента в кавычки. Атрибуты (такие как id и iata) определяются с использованием hashmap-синтаксиса Groovy: key:value. Чтобы заполнить тело элемента, используйте значение без key.
Создать отдельные замыкания, возвращающие HTML- и XML-представления данных, довольно просто, но нельзя ли заставить одно замыкание выполнять обе задачи? Это возможно благодаря заголовку Accept, включаемому в HTTP-запрос. Этот кусочек метаданных в запросе говорит серверу: «У тебя может быть несколько представлений ресурса для этого URI – так мне нужно вот такое».
cURL – это удобная открытая HTTP-утилита командной строки (см. Ресурсы). Введите в командной строке curl http://localhost:9090/trip/airport/list, чтобы симулировать запрос браузера к списку аэропортов. Вы должны увидеть на экране ответ в формате HTML.
Теперь сделаем два маленьких изменения запроса. На этот раз сделаем не GET-, а HEAD-запрос. HEAD – это стандартный HTTP-метод, возвращающий только метаданные, но не тело ответа (он включен в спецификацию HTTP как раз для такой отладки, которой мы сейчас занимаемся). Кроме этого, задайте cURL режим verbose, чтобы видеть метаданные, как показано в листинге 41.
$ curl --request HEAD --verbose http://localhost:9090/trip/airport/list * About to connect() to localhost port 9090 (#0) * Trying ::1... connected * Connected to localhost (::1) port 9090 (#0) > HEAD /trip/airport/list HTTP/1.1 > User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3 > Host: localhost:9090 > Accept: */* > < HTTP/1.1 200 OK < Content-Language: en-US < Content-Type: text/html; charset=utf-8 < Content-Length: 0 < Server: Jetty(6.1.4) < * Connection #0 to host localhost left intact * Closing connection #0 |
Обратите внимание на заголовок Accept в запросе. Когда клиент отправляет */*, он, в сущности, говорит: «Мне все равно, какой формат вы возвратите. Я все приму».
cURL позволяет переопределить это значение с помощью параметра --header. Введите curl --request HEAD --verbose --header Accept:text/xml http://localhost:9090/trip/airport/list, и вы убедитесь что теперь заголовок Accept запрашивает text/xml. Это MIME-тип ресурса.
Как же Grails реагирует на заголовок Accept на стороне сервера? Добавьте еще одно замыкание в AirportController, как показано в листинге 42:
def debugAccept = { def clientRequest = request.getHeader("accept") def serverResponse = request.format render "Client: ${clientRequest}\nServer: ${serverResponse}\n" } |
Первая строка листинга 4 выбирает из запроса заголовок Accept. Вторая строка показывает, как Grails интерпретирует запрос, и какой он отправит ответ.
Теперь используем cURL для некоторых исследований, как показано в листинге 43.
$ curl http://localhost:9090/trip/airport/debugAccept Client: */* Server: all $ curl --header Accept:text/xml http://localhost:9090/trip/airport/debugAccept Client: text/xml Server: xml |
Откуда взялись значения all и xml? Посмотрите на grails-app/conf/Config.groovy. В начале файла вы увидите hashmap, использующий простые имена в качестве ключей (наподобие all и xml) и MIME-типы в качестве значений (см. листинг 44).
grails.mime.types = [ html: ['text/html','application/xhtml+xml'], xml: ['text/xml', 'application/xml'], text: 'text-plain', js: 'text/javascript', rss: 'application/rss+xml', atom: 'application/atom+xml', css: 'text/css', csv: 'text/csv', all: '*/*', json: ['application/json','text/json'], form: 'application/x-www-form-urlencoded', multipartForm: 'multipart/form-data' ] |
ПРИМЕЧАНИЕ Расширенное согласование содержания Заголовок Accept типичного Web-браузера несколько сложнее упрощенного, использовавшегося нами в cURL. Например, заголовок Accept браузера Firefox 3.0.1 для Mac OS X 10.5.4 выглядит так: text/html,application /xhtml+xml,application/xml; q=0.9,*/*;q=0.8 Это разделенный запятыми список с дополнительными атрибутами q, означающими предпочтение определенных типов MIME (значение q – сокращение от quailty – float-значение в диапазоне от 0,0 до 1,0). Поскольку application/xml имеет значение q, равное 0,9, Firefox предпочитает XML-данные всему остальному. Вот заголовок Accept браузера Safari версии 3.1.2 для Mac OS X 10.5.4: text/xml,application/xml, application/xhtml+xml, text/html;q=0.9,text/ plain;q=0.8,image/png, */*;q=0.5 MIME-тип text/html имеет значение q, равное 0,9, поэтому HTML является предпочтительным типом, за ним идет text/plain со значением 0.8 и */* со значением 0,5. Подробнее см. Ресурсы. |
Итак, теперь, когда вы знаете немного больше о согласовании содержания, вы можете добавить блок withFormat в list действие, чтобы возвращать надлежащий тип данных, основываясь на заголовке запроса Accept, как показано в листинге 45.
def list = { if(!params.max) params.max = 10 def list = Airport.list(params) withFormat { html { return [airportList:list] } xml { render list as XML } } } |
Последняя строка каждого блока формата должна содержать render, return или redirect – как и в обычном действии. Если заголовок Accept содержит */*, используется первое вхождение блока.
Настройка заголовка Accept в cURL – это хорошо, но можно также выполнить некоторое тестирование, настраивая URI. И http://localhost:8080/trip/airport/list.xml, и http://localhost:8080/trip/airport/list?format=xml представляют собой методы явно переопределить заголовок Accept. Поиграйте с cURL и различными URI чтобы проверить, что ваш блок withFormat работает как ожидалось.
Если вы хотите сделать такое поведение стандартным для Grails, не забудьте, что можно использовать grails install-templates и редактировать файлы в /src/templates.
Теперь, когда все строительные блоки на месте, последний кирпичик – это перейти от GETful-интерфейса к истинно RESTful-интерфейсу.
Для начала нужно обеспечить, что контроллер откликается на четыре базовых HTTP-метода. Вспомним, что замыкание index закрытие является точкой входа в контроллер, если пользователем не определены пользовательские действия, например, list или show. По умолчанию index выполняет переадресацию на действие list: def index = { redirect(action:list,params:params) } Замените этот код на код из листинга 46:
def index = { switch(request.method) { case "POST": render "Create\n" break case "GET": render "Retrieve\n" break case "PUT": render "Update\n" break case "DELETE": render "Delete\n" break } } |
Используйте cURL, как показано в листинге 57, чтобы убедиться, что выражение switch работает корректно:
$ curl --request POST http://localhost:9090/trip/airport Create $ curl --request GET http://localhost:9090/trip/airport Retrieve $ curl --request PUT http://localhost:9090/trip/airport Update $ curl --request DELETE http://localhost:9090/trip/airport Delete |
Поскольку вы уже знаете, как возвращать XML, реализовать метод GET должно быть несложно. Есть, правда, одно «но». GET-запрос к http://localhost:9090/trip/airport должен возвратить список аэропортов. GET-запрос к http://localhost:9090/trip/airport/den должен возвращать экземпляр аэропорта с IATA-кодом den. Чтобы это сделать, вам нужно создать собственное отображение URL.
Откройте grails-app/conf/UrlMappings.groovy в текстовом редакторе. Отображение по умолчанию, /$controller/$action?/$id?, должно выглядеть знакомо. URL http://localhost:9090/trip/airport/show/1 отображается на AiportController и действие show, а значение params.id равно 1. Заключительный знак вопроса, идущий после действия и ID, означает, что элемент URL не является обязательным.
Добавьте в блок static mappings строку, отображающую RESTful-запросы на AirportController, как показано в листинге 48. Я пока что жестко запрограммировал контроллер, поскольку в других контроллерах еще не реализована поддержка REST. Позже вы, возможно, замените часть URL, airport, на $controller.
class UrlMappings { static mappings = { "/$controller/$action?/$id?" { constraints { // apply constraints here } } "/rest/airport/$iata?"(controller:"airport",action:"index") "500"(view:'/error') } |
Это отображение гарантирует, что все URI, начинающиеся с /rest, перенаправляются в действие index (это избавляет от необходимости согласования содержания). Это также означает, что вы можете проверить наличие params.iata, чтобы определить, следует ли возвращать список или экземпляр.
Измените действие index так, как показано в листинге 49:
def index = { switch(request.method) { case "POST": //... case "GET": if(params.iata){render Airport.findByIata(params.iata) as XML} else{render Airport.list() as XML} break case "PUT": //... case "DELETE": //... } } |
Введите в браузере http://localhost:9090/trip/rest/airport и http://localhost:9090/trip/rest/airport/den, чтобы убедиться, что отображение URL на месте.
ПРИМЕЧАНИЕ Отображение URL по HTTP-методу Можно использовать несколько иной подход к настройке RESTful-отображения URL. Можно перенаправить запросы отдельному действию, основанному на HTTP-запросе. Например, вот так можно отобразить GET, PUT, POST и DELETE на соответствующие (уже существующие) Grails-действия: static mappings = { "/airport/$id"(controller:"airport") { action = [GET:"show", PUT:"update", DELETE:"delete", POST:"save"] } } |
Добавление поддержки DELETE не сильно отличается от добавления GET. В данном случае я, впрочем, хочу позволить удалять только отдельные аэропорты по их коду IATA. Если пользователь отправит HTTP DELETE без IATA-кода, я возвращу HTTP-код 400, Bad Request. А если пользователь отправит код IATA, который не получается найти, я возвращу вечно популярный код 404 Not Found. Только при удачном удалении я возвращу стандартный код 200 OK (дополнительную информацию о кодах статуса HTTP см. в разделе Ресурсы).
Добавьте код из листинга 50 в DELETE case в действии index:
def index = { switch(request.method) { case "POST": //... case "GET": //... case "PUT": //... case "DELETE": if(params.iata) { def airport = Airport.findByIata(params.iata) if(airport) { airport.delete() render "Successfully Deleted." } else { response.status = 404 //Not Found render "${params.iata} not found." } } else { response.status = 400 //Bad Request render """DELETE request must include the IATA code Example: /rest/airport/iata """ } break } } |
Сперва попробуем удалить заведомо существующий аэропорт, как показано в листинге 51:
Deleting a Good Airport</heading> $ curl --verbose --request DELETE http://localhost:9090/trip/rest/airport/lga > DELETE /trip/rest/airport/lga HTTP/1.1 < HTTP/1.1 200 OK Successfully Deleted. |
Теперь попробуем удалить заведомо отсутствующий аэропорт, как показано в листинге 52:
$ curl --verbose --request DELETE http://localhost:9090/trip/rest/airport/foo > DELETE /trip/rest/airport/foo HTTP/1.1 < HTTP/1.1 404 Not Found foo not found. |
Наконец, попробуем выполнить запрос DELETE без IATA, как показано в листинге 53:
$ curl --verbose --request DELETE http://localhost:9090/trip/rest/airport > DELETE /trip/rest/airport HTTP/1.1 < HTTP/1.1 400 Bad Request DELETE request must include the IATA code Example: /rest/airport/iata |
Следующая цель – вставка нового аэропорта. Создайте файл с названием simpleAirport.xml, как показано в листинге 54.
<airport> <iata>oma</iata> <name>Eppley Airfield</name> <city>Omaha</city> <state>NE</state> <country>US</country> <lat>41.3019419</lat> <lng>-95.8939015</lng> </airport> |
Если XML-представление ресурса плоское (без глубокой вложенности элементов), и каждое элемента соответствует имени поля в классе, Grails может сконструировать новый класс прямо по XML. К корневому элементу XML-документа можно обращаться через params, как показано в листинге 55:
def index = { switch(request.method) { case "POST": def airport = new Airport(params.airport) if(airport.save()) { response.status = 201 // Создано render airport as XML } else { response.status = 500 //Internal Server Error render "Could not create new Airport due to errors:\n ${airport.errors}" } break case "GET": //... case "PUT": //... case "DELETE": //... } } |
XML должен быть плоским, поскольку params.airport является, по сути, HashMap (Grails скрыто выполняет за вас XML-HashMap). Это означает, что на самом деле вы используете конструктор именованных аргументов для Airport:
def airport = new Airport(iata:"oma", city:"Omaha", state:"NE") |
Чтобы протестировать новый код, используйте cURL и отправьте с помощью POST файл simpleAirport.xml, как показано в листинге 56:
$ curl --verbose --request POST --header "Content-Type: text/xml" --data @simpleAirport.xml http://localhost:9090/trip/rest/airport > POST /trip/rest/airport HTTP/1.1 > Content-Type: text/xml > Content-Length: 176 > < HTTP/1.1 201 Created < Content-Type: text/xml; charset=utf-8 <?xml version="1.0" encoding="utf-8"?><airport id="14"> <arrivals> <null/> </arrivals> <city>Omaha</city> <country>US</country> <departures> <null/> </departures> <iata>oma</iata> <lat>41.3019419</lat> <lng>-95.8939015</lng> <name>Eppley Airfield</name> <state>NE</state> </airport> |
В случае более сложного XML нужно выполнять демаршаллинг вручную. Вспомните, например, тот XML-форматы, что вы определили выше? Создайте файл newAirport.xml, показанный в листинге 57.
<airport iata="oma"> <official-name>Eppley Airfield</official-name> <city>Omaha</city> <state>NE</state> <country>US</country> <location latitude="41.3019419" longitude="-95.8939015"/> </airport> |
Теперь в действии index замените строку def airport = new Airport(params.airport) кодом из листинга 58:
def airport = new Airport() airport.iata = request.XML.@iata airport.name = request.XML."official-name" airport.city = request.XML.city airport.state = request.XML.state airport.country = request.XML.country airport.lat = request.XML.location.@latitude airport.lng = request.XML.location.@longitude |
Объект request.XML – это groovy.util.XmlSlurper, который содержит сырой XML. Это на самом деле корневой элемент, так что можно запрашивать дочерние элементы по имени (request.XML.city). Если имя содержит дефис или пространство имен, заключите его в кавычки (request.XML."official-name"). Чтобы получить доступ к атрибутам элемента, используется символ @ (request.XML.location.@latitude). Подробнее об XmlSlurper см. Ресурсы.
И наконец, проверьте его с помощью cURL:
curl --request POST --header "Content-Type: text/xml" --data @newAirport.xml http://localhost:9090/trip/rest/airport |
Последний HTTP-метод из тех, что нужно поддерживать – метод PUT. Код поддержки POST и PUT практически идентичен. Единственное различие состоит в том, что вместо построения классов непосредственно по XML, вам нужно запросить у Gorm уже существующие классы. Затем строка airport.properties = params.airport заменяет имеющиеся данные полей новыми данными из XML, как показано в листинге 59:
def index = { switch(request.method) { case "POST": //... case "GET": //... case "PUT": def airport = Airport.findByIata(params.airport.iata) airport.properties = params.airport if(airport.save()) { response.status = 200 // OK render airport as XML } else { response.status = 500 //Internal Server Error render "Could not create new Airport due to errors:\n ${airport.errors}" } break case "DELETE": //... } } |
Создайте файл editAirport.xml, показанный в листинге 60:
<airport> <iata>oma</iata> <name>xxxEppley Airfield</name> <city>Omaha</city> <state>NE</state> <country>US</country> <lat>41.3019419</lat> <lng>-95.8939015</lng> </airport> |
Протестируйте результат с помощью cURL:
curl --verbose --request PUT --header "Content-Type: text/xml" --data @editAirport.xml http://localhost:9090/trip/rest/airport. |
Я охватил довольно много за короткий промежуток времени. You should now understand the difference between an SOA and an ROA. Теперь вы должны понимать разницу между SOA и ROA. Вы должны также знать, что не все RESTful Web-сервисы одинаковы. Некоторые из них являются GETful-сервисам – они используют http-запрос GET для RPC-подобных вызовов методов. Другие действительно ресурсно-ориентированы, и у них URI является ключом для доступа к ресурсу, а стандартные HTTP-методы GET POST PUT и DELETE обеспечивают полные CRUD-возможности. Какой подход вы ни выберете – GETful или RESTful, Grails предлагает надежную поддержку как для вывода XML, так и для запихивания его обратно.
В следующей серии нашего сериала я займусь тестированием. Grails исходно предлагает большие возможности для тестирования. То, чего в нем нет, можно добавить в виде плагинов. Учитывая, сколько времени вы потратили на Grails-разработку к этому моменту, необходимо убедиться, что на данный момент ошибок нет, и что так оно и останется на весь срок жизни приложения.
Copyright © 1994-2016 ООО "К-Пресс"