Последнее изменение: 1 сентября 2007г.

Вавилонское столпотворение. Часть 3. Веб-приложения

Итак, третья часть серии "Вавилонское столпотворение". Сугубо прикладная. Речь в ней пойдет об использовании различных кодировок в веб-приложениях. На всем пути – от браузера до базы данных и обратно. Мы совершим путешествие с остановками по всем пунктам и приложим все усилия, чтобы вернуться назад целыми и невредимыми. В конце будут картинки. В смысле, иллюстрации.

Представим себя данными, введенными в форму. Наша задача – дойти до базы данных и вернуться назад к пользователю в первозданном виде. Этапы нашего пути будут следующими:

Ну, что? Поехали! И первая остановка –

Браузер

Что такое браузер – думаю, объяснять не надо. Их множество. Весь материал данной статьи я проверял на четырех из них – Microsoft Internet Explorer 6.0, FireFox 1.0.5, Netscape 8.0, Opera 8.5. Если окажется, что для других браузеров приведенные тут рецепты не работают – я был бы очень признателен за сообщение об этом.

Итак, пусть у нас есть браузер, а в нем – форма. Текстовое поле и кнопка. В поле вводятся данные, жмется кнопка. Данные пошли. Стоп! Вопрос. В каком виде пошли данные?

Пользователь ввел символы. Через сетевое соединение передаются байты. Как я уже упоминал в первой части, символы в байты можно перевести множеством различных способов. И для того, чтобы на стороне сервера перевести байты обратно в символы, причем корректно – нужно точно знать способ, которым символы были закодированы в набор байтов. Т.е. – знать кодировку. И тут индивидуальность браузеров расцветает махровым цветом.

Вот ссылка на спецификацию HTML 4.01, часть, посвященная формам – http://www.w3.org/TR/html401/interact/forms.html. Если вы посмотрите на п. 17.3 – The FORM Element, – то найдете у формы такой атрибут как accept-charset. Теоретически этот атрибут должен содержать список всех кодировок, которые понимает сервер. На мой неискушенный взгляд, это означает, что если указать в этом списке всего одну кодировку, то браузер должен понять, что сервер ждет именно ее и послать данные в ней. Так поступают Opera, Mozilla и Netscape. Но – НЕ IE. Microsoft, как обычно, не принимает во внимание стандарты и делает все, что хочет. В данном случае – отправляет данные в той кодировке, в которой прислана сама страница, содержащая форму. В кодировке, которая указана в заголовке страницы:

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

К сожалению, игнорировать такое поведение нельзя, ибо IE – все еще самый распространенный браузер. А потому, мы приходим к неизбежному выводу:

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

Я предлагаю в качестве примера использовать кодировку UTF-8. Во-первых, она может использоваться для кодирования любых символов набора Unicode, во-вторых, ее должна поддерживать любая реализация Java.

Итак, подводим черту: мы используем UTF-8 в качестве кодировки страниц, и в этой же кодировке отсылаем данные на сервер. Таким образом, набор байтов, который получился при отправке символов серверу, мы знаем. Следующая остановка –

Долгая дорога от браузера к серверу

Небольшой экскурс в протокол HTTP. Данные на сервер могут передаваться разными способами. Первый – указание нужных данных непосредственно как часть URL. Это происходит в случае использования метода GET. Второй – данные передаются в теле запроса. Это происходит при использовании метода POST.

Какие тут есть подводные камни? Прежде всего – в URL можно передать далеко не любой символ. Нас это ограничение никак не устраивает. Однако, тут о нас позаботились разработчики протокола. Все символы, которые нельзя указывать в URL, передаются в форме x-www-form-urlencoded – каждый байт заменяется на три символа: '%' и значение байта в шестнадцатиричном виде. Таким образом, символ 'И', например, в кодировке UTF-8 будет представлен как %D0%98. В таком представлении используются только цифры, шесть заглавных букв латиницы и символ процента. Все они разрешены для использования в URL. Так что эта проблема вроде решена.

Второй подводный камень – URL не может быть длиннее 4096 символов. Вернее, сейчас в разных браузерах и на разных серверах это число отличается, но суть от этого не меняется – существует ограничение на длину передаваемых через URL данных. А вот это нас уже не устраивает категорически. И потому – еще один вывод:

В форме предпочтительнее использовать метод POST.

При использовании метода POST, как я уже упоминал, данные передаются в теле HTTP-запроса. Они могут быть переведены в форму x-www-form-urlencoded, а могут и нет – это не суть важно для понимания процесса. В любом случае корректность их отправки обеспечивает браузер, а корректность приема – перевод в набор байтов, идентичный отправленному – сервер.

Итак, мы постепенно приблизились к следующей стадии. Остановка под названием ...

Сервер

Вот сервер, который построил админ. А это – веб-приложение, которое работает на сервере, который построил админ. А это – сервлет, который входит в веб-приложение, которое работает на сервере, который построил админ. Или JSP, которая – суть тот же сервлет. И вот получает этот сервлет запрос. А в нем данные. Какие? Набор байтов. А сервлету что нужно? Символы. Вопрос – какую кодировку использовать?

Еще один экскурс в HTTP. Данные от формы при использовании метода POST передаются с указанием определенного типа содержимого:

Content-type: application/x-www-form-urlencoded

Теоретически, тут же может указываться и кодировка передаваемых данных:

Content-type: application/x-www-form-urlencoded; charset=UTF-8

Во всяком случае, Tomcat ее пытается отсюда получить. На практике же – кодировку указывает только Opera. Может, что-то изменится в будущем, но сейчас факты таковы – кодировка пришедших данных неизвестна! И с этим надо что-то делать.

Единственный вариант, который я вижу на текущий момент – установить кодировку вручную. Благо у класса javax.servlet.ServletRequest есть метод setCharacterEncoding(String). И делать это лучше всего в фильтре, причем в первом в цепочке, ибо любая попытка чтения параметра приведет к преобразованию байтов в символы, причем, возможно, необратимому.

Код такого фильтра прост до невозможности:

package ru.skipy.juga_ru.web.i18n_tutorial;

import java.io.*;
import javax.servlet.*;

/**
 * FormEncodingSetterFilter
 *
 * @author Eugene Matyushkin
 */
public class FormEncodingSetterFilter implements Filter{

    private static final String FILTERABLE_CONTENT_TYPE="application/x-www-form-urlencoded";

    private static final String ENCODING_DEFAULT = "UTF-8";

    private static final String ENCODING_INIT_PARAM_NAME = "encoding";

    private String encoding;

    public void destroy(){
    }

    public void doFilter(ServletRequest req, ServletResponse resp,
                         FilterChain chain) throws ServletException, IOException{
        String contentType = req.getContentType();
        if (contentType != null && contentType.startsWith(FILTERABLE_CONTENT_TYPE))
            req.setCharacterEncoding(encoding);
        chain.doFilter(req, resp);
    }

    public void init(FilterConfig config) throws ServletException{
        encoding = config.getInitParameter(ENCODING_INIT_PARAM_NAME);
        if (encoding == null)
            encoding = ENCODING_DEFAULT;
    }
}

Соответственно, в web.xml он прописывается так:

<filter>
    <filter-name>FormEncodingSetterFilter</filter-name>
    <filter-class>ru.skipy.juga_ru.web.i18n_tutorial.FormEncodingSetterFilter</filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>UTF-8</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>FormEncodingSetterFilter</filter-name>
    <url-pattern>/i18n-tutorial/*</url-pattern>
</filter-mapping>

И все. После выполнения метода doFilter у запроса стоит правильная кодировка, в результате чего байты превращаются... байты превращаются... превращаются байты... в необходимые нам символы! И без всяких технических неувязок!

Этот метод действителен в одном предположении – мы знаем, в какой кодировке приходят байты от клиента. И используем эту кодировку для их обработки. Как я показывал выше, обеспечить приход байтов в определенной кодировке – вполне в наших силах.

Но это все работает для метода POST. В случае с GET, когда данные передаются в самом URL, ситуация совершенно иная. Прежде всего, в этом случае может отсутствовать заголовок HTTP Content-type. И тогда совершенно непонятно, как интерпретировать байты данных. Вот тут все зависит от... сервера как такового. У каждого из серверов кодировка URI настраивается по-разному:

  • У Tomcat есть параметр URIEncoding, указываемый у коннектора. По умолчанию – iso-8859-1. См. документацию: http://tomcat.apache.org/tomcat-5.5-doc/config/http.html. Можно даже дать указание использовать кодировку тела запроса, установив значение useBodyEncodingForURI=true, но как я уже говорил, Content-type у GET-запроса указывается не всегда.
  • У Resin 3.0.x это элемент конфигурации character-encoding, который можно установить на разных уровнях. По умолчанию – iso-8859-1. См. документацию: http://www.caucho.com/resin-3.0/config/env.xtp#character-encoding.
  • У Resin 2.1.x – это параметр url-character-encoding элемента конфигурации host. По умолчанию – UTF-8. См. документацию: http://www.caucho.com/resin-2.1/ref/http-config.xtp#url-character-encoding.
  • У Sun AS 8.1 в sun-web.xml можно указать <parameter-encoding default-charset="UTF-8"/>. Если я правильно понимаю, по умолчанию там используется iso-8859-1. См. статью: http://java.sun.com/developer/technicalArticles/Intl/HTTPCharset/.

И т.д. и т.п. Этот момент придется смотреть в документации по серверу. Но, еще раз подчеркиваю – это ТОЛЬКО для метода GET. А как мы выяснили выше, предпочтительнее использовать POST.

Что мы имеем на данный момент? Мы уже преодолели дорогу от браузера до сервера. На очереди –

База данных

Базы данных гораздо более индивидуальны, нежели браузеры или сервлет-контейнеры. Да, они поддерживают JDBC, иначе как бы вы их использовали, но на этом сходство между ними кончается. Я могу говорить только про MySQL, с которым я работал. Что касается любых других баз – надо смотреть документацию по ним.

Теоретически MySQL позволяет задать кодировку для текстовых полей таблицы. Практически это получается не всегда. MySQL сервер, работающий на juga.ru, например, отказался понимать любые упоминания кодировки. Сочетание CHARACTER SET хоть на уровне таблицы, хоть на уровне поля расценивалось им как личное оскорбление. Пришлось создавать таблицу вообще без указаний кодировки:

CREATE TABLE `i18ntest` (
    `timestamp` bigint(20) NOT NULL default '0',
    `data` text NOT NULL,
    PRIMARY KEY  (`timestamp`)
) TYPE=MyISAM

Это он проглотил. Как ни странно – хватило. При соединении через JDBC я использовал магические параметры, вычитанные из документации Connector/J:

jdbc:mysql://localhost:3306/skipy?useUnicode=true&characterEncoding=utf8

Означают они, что драйверу в явном виде предписывается использовать Unicode и UTF-8. Если посмотреть на содержимое таблицы через PHPMyAdmin – видны характерные группы байтов UTF-8. Чтение происходит без проблем – данные не теряются и не портятся. Проверял на русском, английском, французском и японском текстах.

Итак, мы счастливо добрались до базы и повернули назад. Теперь дорога наша лежит обратно к браузеру через сервер. Следующая остановка –

Представление данных и путь обратно

Для представления данных и генерации страниц у нас полный простор. Я остановлюсь на двух методах – создание страниц с помощью Velocity (http://jakarta.apache.org/velocity/) и с помощью JSP.

Прежде всего общий момент. Как мы уже решили, страницы будут в кодировке UTF-8. Вот ее-то и надо указать в виде тега HTML:

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

На всякий случай напоминаю, что <meta ...> указывается в разделе <head></head>.

Пару слов о Velocity. Этот фреймворк позволяет указать как входную кодировку страниц, так и выходную. Делается это с помощью свойств при его инициализации:

Properties props = new Properties();
props.setProperty(Velocity.FILE_RESOURCE_LOADER_PATH,
                  getServletContext().getRealPath(".")+RESOURCE_DIR);
props.setProperty(Velocity.INPUT_ENCODING, "UTF-8");
props.setProperty(Velocity.OUTPUT_ENCODING, "UTF-8");
try {
    Velocity.init(props);
} catch (Exception ex) {
    throw new ServletException("Unable to initialize Velocity engine: " +
                                ex.getMessage(), ex);
}

Небольшое пояснение. RESOURCE_DIR – это директория, в которой лежат шаблоны Velocity, указаная относительно контекста сервлета. Удобно держать шаблоны в /WEB-INF/resources, там до них никто кроме сервлета не дотянется.

Дальше в контекст кладутся данные, ищется шаблон, делается merge – и результат пишется напрямую в httpResponse.getWriter(). Собственно, все. Я решил упомянуть Velocity только потому, что он достаточно удобен для генерации страниц, если не использовать JSP.

Теперь о JSP. Начнем с основного – директивы page:

<%@ page contentType="text/html;charset=UTF-8" language="java" pageEncoding="UTF-8" %>

Бдительный читатель сразу углядит в этой директиве ДВА упоминания кодировки. Для не особо бдительных я их выделил жирным шрифтом. Вопрос. Зачем их два?

Господи, сколько я слышал вариантов ответа... От "один старый, другой новый, старый оставлен для совместимости" до "а один из них вообще не нужен". Нужен, еще как. Оба нужны. И разница между ними есть.

pageEncoding – это кодировка текущего файла JSP. Если я буду создавать JSP-страницу в любимом редакторе UltraEdit под Windows, и напишу там что-нибудь по-русски – страница будет в кодировке windows-1251. Эту кодировку использует компилятор JSP (Jasper и иже с ним) для корректной интерпретации исходного текста страницы при компиляции.

charset – это принципиально другое: кодировка страницы, полученной в результате работы JSP. Чувствуете разницу? JSP скомпилировали, использовав pageEncoding, вызвали – и она сгенерировала страницу в кодировке charset. Очевидно, что ОБА эти параметра нужны. Более того, они могут существенно облегчить жизнь. Если в старом проекте JSP написаны в windows-1251 – не надо их все перекодировать. Достаточно указать pageEncoding=windows-1251 и charset=UTF-8 – и все страницы, созданные приложением, будут в UTF-8.

Почему возникает путаница. Дело в том, что если один параметр опущен – другой принимается равным ему. Потому и говорят одни, что charset не нужен, а нужно указывать pageEncoding, а другие – ровно наоборот. Если эти параметры совпадают – действительно можно указывать только один. Я предпочитаю указывать оба.

Еще один момент, связаный с кодировками в JSP – включение JSP. Пусть у нас есть две страницы:

<!-- included.jsp -->
<%@ page language="java" contentType="text/html;charset=windows-1251"
    pageEncoding="windows-1251"%>
Русский текст, включаемый в другую страницу
<!-- sample.jsp -->
<%@ page language="java" contentType="text/html;charset=windows-1251"
    pageEncoding="windows-1251"%>
<html>
<head>
<META http-equiv="Content-Type" content="text/html; charset=windows-1251"/>
</head>
<body>
<h1>Заголовок</h1>
Какой-то текст<br>
<%@ include file="included.jsp" %>
<br>
Еще текст
</body>
</html>

Мы вызываем sample.jsp, а included.jsp включается в нее на стадии компиляции. Вопрос. В каком отношении должны быть кодировки, указаные в этих страницах?

Ответ таков. На pageEncoding ограничения не налагаются. А вот charset должны совпадать! Иначе получится, что страница должна генерироваться в разных кодировках. Компилятор JSP этого не допустит и выдаст ошибку:

Page directive: illegal to have multiple occurrences of contentType with different values
    (old: text/html;charset=windows-1251,
     new: text/html;charset=iso-8859-1)

Итак, теперь мы в состоянии сгенерировать страницу в необходимой кодировке. Поскольку данные, прошедшие путь от клиента до базы и обратно, не попортились – не попортятся они и тут. И вернутся к пользователю целыми и невредимыми. Тут и сказке конец, а кто дослушал – умнее станет!

Иллюстрация

Все эти рассуждения – не просто так. На их основе я сделал два тестовых примера. Один из них – на основе JSP. Принимает данные из формы и отсылает их же назад. Второй – на основе сервлета и Velocity. Принимает данные, пишет их в базу, создает список данных в базе и возвращает по запросу соответствующие данные. Исходные коды этих примеров можно найти вот тут: i18n-tutorial.zip. Как обычно, в архиве присутствует build-файл для ant. По умолчанию он собирает веб-архив (i18n-tutorial.war). Хочу обратить особое внимание, что для компиляции и запуска кода в директории lib должны быть также следующие библиотеки – velocity, commons-collections, log4j. Если они у вас уже есть – просто скопируйте их в директорию lib. Если нет – можете скачать самостоятельно или взять отсюда: i18n-tutorial-libs.zip. Просто распакуйте в ту же директорию, что и предыдущий архив.

Еще пара замечаний. Поскольку код этот работает с базой данных – надо его под нее настроить. Для этого, в классе ru.skipy.juga_ru.web.i18n_tutorial.db.DBInterface необходимо подправить константу CONNECTION_URL, прописав в ней имя реальной базы. Также необходимо подправить WEB-INF/web.xml, прописав в нем имя пользователя для базы и его пароль. Иначе второй пример будет выдавать ошибку.

Ну и, разумеется, в базе должна быть нужная таблица. Для ее создания необходимо выполнить SQL-запрос, указаный выше. Еще раз напоминаю – пример написан для MySQL. При использовании другой базы изменится как запрос, создающий таблицу, так и URL соединения с базой.

Особо подчеркиваю – этот пример направлен прежде всего на демонстрацию работы с кодировками. Потому на стиль JSP можете не обращать внимания. Больше скажу – ТАК писать не стоит. :)

* * *

Собственно говоря, это все. Надеюсь, эта статья будет полезной, ибо вопросы об использовании кодировок в веб-приложениях появляются регулярно. А пока – до новых встреч!