ЧПУ (Человеко-понятные УРЛы) в java

November 2, 2007

Сегодня я расскажу о формировании ЧПУ для java-основанных веб-проектов на примере небольшого веб-магазинчика. Это будет очень простой магазинчик, все данные в котором помещаются в одной таблице. Информация же будет выводиться на нескольких страницах, в виде иерархии разделов каталога и, собственно, товаров внутри раздела.

Очевидно, что “Настоящему” программисту нет никакого дела до того, как называется его страница:
 index.jsp?sect=food&article=milk&date=fresh
Например, такой адрес я назову ППУ – программисто-понятные урлы (yes!, я сделал это первым, я ввел новый термин).

или же
 catalog/food/milk/fresh 
А этот стиль адресов “обозвали” еще до меня как ЧПУ (человеко-понятные урлы)

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

Другое дело, что для посетителя сайта второй вариант адреса более понятен и легче запоминается. Его проще вспомнить спустя пару дней и набрать без ошибок. Интуитивно ясно, что если в адресе, наш посетитель удалит фрагмент “fresh”, то он получит сведения только о молоке, если же удалить и часть “milk”, так чтобы остался “catalog/food”, то для клиента ожидаемо попасть, именно, в раздел со всеми продовольственными товарами. В Интернете иногда встречаются страшные байки о том, что поисковые системы не умеют качественно индексировать адреса страниц с передаваемыми после “?” переменными и их значениями – не верьте, это такой же фольклор как “летающие тарелки” и “каждой советской семье по квартире в 2000 году”. Так что, кроме фактора “usability” других оснований для внедрения ЧПУ нет – а разве этого мало?

Что самое приятное – добавление средств ЧПУ, даже к уже существующему сайту, занимает не более десятка минут. Вам вовсе не нужно перестраивать структуру каталогов свого сайта, даже не нужно переписывать код вашего jsp-файла или сервлета.

Виртуальный (не отображенный в физической структуре дерева каталогов сайта) адрес можно при поступлении запроса преобразовать (автоматически и абсолютно незаметно для, собственно, посетителя сайта) в старый стиль, с переменными и их значениями после знака “?”.

Как это сделать?



Для разработчиков сайтов использующих LAMP (linux&apache&mysql&php), известны две базовые методики обработки ЧПУ. Первая основана на том, что используется mod_rewrite. Этот модуль (расширение возможностей apache) срабатывает до вызова запрашиваемого файла. В файле .htaccess вы перечисляете шаблоны запрашиваемых адресов в стиле ЧПУ, каждому такому шаблону (при их записи используются регулярные выражения, чтобы придать шаблонам универсальность) ставится в соответствии новый адрес. Второй подход основан на возможности указать для сайта страницу, срабатывающую как обработчик ситуации “404 – страница не найдена”. Всякий раз, когда запрашивается ЧПУ (естественно, такой виртуальный адрес не находится) apache вызывает специальный файл 404. В этом файле пишется несложный код, который анализирует конфигурационные переменные сервера – в них хранится, в том числе, и запрошенный адрес ЧПУ – затем скрипт вычисляет, то какая страница (функция/метод класса) должна быть вызвана для показа запрошенной информации.

В мире java, если вы используете tomcat или иной java-сервер совместно с apache (надо сказать, что это довольно неплохая идея – использовать apache для отображения статических страниц, а tomcat для выполнения java-кода), то вы можете использовать mod_rewrite. Если же apache не доступен, то вы могли бы создать собственную функцию обработчик события 404, анализировать в ней запрошенный адрес и все как в прошлый раз. С другой стороны, в java есть интересная и полезная концепция – сервлеты-фильтры. Сервлеты-фильтры вызываются до того непосредственно вызова запрашиваемого файла и после этого. Сфера применения фильтров огромна – наиболее часто мы используем их для защиты страниц от несанкционированного доступа, работа с интернализацией и локализацией веб-страниц, создание хитрых хаков обеспечивающих корректную работу унаследованных решений. Важно, что даже если запрошенный адрес не существует, все равно сервлет-фильтр будет вызван, а значит, в нем можно выполнить подмену адреса. Написать кода такого сервлета не сложно и могло бы стать неплохой тренировкой для начинающего серверного java-разработчика. Но мы, естественно, пойдем другим путем: существует множество java-библиотек, решающих нужные нам задачи, так что остается выбрать одну из них. На странице wikipedia (http://en.wikipedia.org/ Rewrite_engine) в разделе Rewrite engines for Java 2 Platform, Enterprise Edition указаны следующие библиотеки для rewrite-инга.

- http://www.zlatkovic.com/httpredirectfilter.en.html

- http://tuckey.org/urlrewrite/

- http://software.softeu.cz/rewriter/

Я расскажу о последней из них (http://software.softeu.cz/rewriter/). Предположим, вы скачали с сайта разрабочика jar-архив с библиотекой. Теперь я создам пустое веб-приложение и размещу его в папке tomcat (я использую tomcat 6.1.0 под jdk 1.6, работоспособность библиотеки в других ситуациях я не тестировал). В папке веб-приложения WEB-INF я создам подпапку lib, в которую скопирую архив библиотеки, а также в файле web.xml, подключу сервлет cz.softeu.rewriter.RewriterFilter:
  1. <?xml version="1.0" encoding="ISO-8859-1"?>
  2. <web-app xmlns="http://java.sun.com/xml/ns/j2ee"
  3.  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4.  xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_5.xsd"
  5.  version="2.5">
  6.  
  7.  <display-name>TestRewrite machine</display-name>
  8.  <description> based on http://software.softeu.cz/rewriter/ </description>
  9.  
  10.  <filter>
  11.    <filter-name>RewriterFilter</filter-name>
  12.    <filter-class>cz.softeu.rewriter.RewriterFilter</filter-class>
  13.  </filter>
  14.  
  15.  <filter-mapping>
  16.    <filter-name>RewriterFilter</filter-name>
  17.    <url-pattern>/catalog/*</url-pattern>
  18.  </filter-mapping>
  19.  
  20. </web-app>
В данном файле обратите внимание на регистрацию шаблона адресов, которые будет обрабатывать фильтр. Я говорю, что любые страницы, начинающиеся с “/catalog/”, должны быть обработаны на предмет преобразования адреса.

Теперь необходимо разработать прототип страницы магазина shop.jsp. В нем я буду принимать входные переменные: sect, article, date. Переменные будут служить для поиска информации в таблице базы данных mysql следующей структуры (см. рис. 1), затем я заполнил таблицу данными о товарах нашего магазинчика (см. рис. 2):





Для работы jsp-файла с информацией в mysql-базе мне понадобится jdbc-драйвер, который я загрузил с сайта mysql.com и поместил внутрь каталога lib моего веб-приложения. База создана в кодировке utf8 и, следовательно, я указываю параметры кодировки при соединении. В объект Properties помещаются такие параметры, как имя пользователя для входа на сервер mysql, его пароль и кодировки.

Дальнейшие действия тривиальны: я проверяю, какое количество параметров было передано в jsp-страницу (совершенно не заботясь об будущих ЧПУ), затем делаю выборку нужной информации и отображаю ее в виде таблицы со ссылками. Ссылки же здесь в формате ЧПУ.

В ходе работы сприпта выявилась проблема: когда в адресной строке браузера запрашивается адрес вида: catalog/food, а в возвращенной странице присутствуют ссылки вида: catalog/food/2007.1.14/milk, то браузер считает (и надо сказать вполне правильно, что итоговый адрес для запроса должен быть catalog/catalog/food/2007.1.14/milk). Это рушит нам схему именования страниц и самым простым способом задавать адреса будет добавление в секцию head страницы специального тега base. Его назначение задать “базу” – основу, от которой будет выполняться отсчет адресов всех страниц размещенных на сайте. Например, так <base href="http://localhost:8080/" />

Вот полный код примера jsp-страницы:
  1. <%@ page import="java.text.SimpleDateFormat" %>
  2. <%@ page import="java.sql.*" %>
  3. <%@ page import="java.util.*" %>
  4. <%@ page import="java.sql.Date" %>
  5. <%--
  6.  Created by IntelliJ IDEA.
  7.  User: Programmer
  8.  Date: 03.11.2007
  9.  Time: 21:24:28
  10.  To change this template use File | Settings | File Templates.
  11. --%>
  12. <%@ page contentType="text/html;charset=UTF-8" language="java" %>
  13. <html>
  14.   <head>
  15.    <title>Simple jsp page</title>
  16.    < base href="http://localhost:8080/" />
  17.   </head>
  18.  <body>
  19.  
  20.  <%
  21.  Class.forName("com.mysql.jdbc.Driver").newInstance();
  22.  Properties props = System.getProperties();
  23.  props.setProperty("user" , "root");
  24.  props.setProperty("password" , "");
  25.  props.setProperty("useUnicode" , "true");
  26.  props.setProperty("characterEncoding" , "utf8");
  27.  props.setProperty("characterSetResults" , "utf8");
  28.  
  29.  Connection coco = DriverManager.getConnection("jdbc:mysql://center/rewriteshop", props);
  30.  // создаем подключение к базе данных mysql с каталогом товаров
  31.  
  32.  String sql_sect = "select distinct sect from shop";
  33.  String sql_dates = "select distinct article, date_of from shop where sect = ?";
  34.  String sql_article = "select * from shop where sect = ? and date_of = ? and article = ?";
  35.  // задали строки sql-запросов для различных ситуаций когда нашу страницу запросили
  36.  
  37.  if (request.getParameter("sect") == null){
  38.    // первая ситуация когда не передано в страницу название секции, тогда следует
  39.    // отобрать и вывести список всех секций товаров
  40.    PreparedStatement pstmt_sects = coco.prepareStatement(sql_sect);
  41.     ResultSet rs = pstmt_sects.executeQuery();
  42.    out.print("<table border='1'>");
  43.    while (rs.next()){
  44.       String sect = rs.getString("sect");
  45.       out.print("<tr><td><a href='catalog/"+sect+"'>"+sect+"</a></td></tr>");
  46.       // печатаем строку таблицы с одной ячейкой и  размещаем в ней ссылку на раздел каталога
  47.    }
  48.    rs.close();
  49.    out.print("</table>");
  50.  }
  51.  else {
  52.   if (request.getParameter("date") == null) {
  53.      String sect = request.getParameter("sect");
  54.      PreparedStatement pstmt_dates = coco.prepareStatement(sql_dates);
  55.      pstmt_dates.setString(1, sect);
  56.      ResultSet rs = pstmt_dates.executeQuery();
  57.      out.print("<table border='1'>");
  58.  
  59.      while (rs.next()){
  60.        String date_of = rs.getString("date_of");
  61.        String article = rs.getString("article");
  62.        out.print("<tr><td><a href='catalog/"+sect+"/"+date_of+"/"+article+"'>"+sect+"/"+
  63.        date_of+"/"+article+"</a></td></tr>");
  64.       // печатаем строку таблицы с одной ячейкой и размещаем в ней ссылку на раздел каталога
  65.      }
  66.      rs.close();
  67.      out.print("</table>");
  68.   }
  69.   else {
  70.      if (request.getParameter("article") != null) {
  71.        String sect = request.getParameter("sect");
  72.        String date = request.getParameter("date");
  73.        String article = request.getParameter("article");
  74.  
  75.        PreparedStatement pstmt_article = coco.prepareStatement(sql_article);
  76.        pstmt_article.setString(1, sect);
  77.        pstmt_article.setString(2, date);
  78.        pstmt_article.setString(3, article);
  79.  
  80.        ResultSet rs = pstmt_article.executeQuery();
  81.        rs.next();
  82.        String info = rs.getString("info");
  83.        String image = rs.getString("img");
  84.        out.print("Article: " + article + "<br />");
  85.        out.print("Info: " + info + "<br />");
  86.        out.print("Image: <img src='pics/" + image + "'/><br />");
  87.        rs.close();
  88.        out.print("</table>");
  89.     }
  90.  }
  91. }
  92. %>
  93.  </body>
  94. </html>
И вот результат ее работы (рис.4 и рис. 5)





Теперь последний шаг: для того чтобы запрошенные адреса вида:
 http://localhost:8080/catalog/manufacture/2006.1.12/screwdriver
Преобразовались в следующие:
 /index.jsp?sect=manufacture&date=2006.1.12&article=screwdriver
Необходимо в папке web-inf создать файл с настройками для сервлета-фильтра rewriter-а.

Имя файла должно быть: rewriter-config.xml. Его же внутреннее устройство очевидно: внутри корневого тега rewriter-config, мы задаем множество тегов b:regex, каждый из которых задает одно, отдельное, правило преобразования запрашиваемого адреса. Входной адрес соответствует регулярному выражению внутри тега “from”, а целевой адрес должен быть задан в теге “to”. Очевидно, что вы можете группировать части исходного адреса с помощью круглых скобок, а затем ссылаться на них внутри шаблона “to” используя запись “$цифра”. Цифра должна быть равна порядковому номеру части regexp-шаблона заданного внутри тега “from”. И вот пример кода файла rewriter-config.xml:
  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <rewriter-config xmlns:b="http://rewriter.softeu.cz/basic/">
  3.     <b:regex>
  4.         <from>^/catalog/([^/]+)$</from>
  5.         <to>/index.jsp?sect=$1</to>
  6.     </b:regex>
  7.     <b:regex>
  8.         <from>^/catalog/([^/]+)/([^/]+)$</from>
  9.         <to>/index.jsp?sect=$1&date=$2</to>
  10.     </b:regex>
  11.     <b:regex>
  12.         <from>^/catalog/([^/]+)/([^/]+)/([^/]+)$</from>
  13.         <to>/index.jsp?sect=$1&date=$2&article=$3</to>
  14.     </b:regex>
  15. </rewriter-config>
Весь процесс обработки-преобразования адресов журналируется - это позволит быстрее отлаживать код регулярных выражений.



Categories: Java