Раньше я использовал сервлеты, JSP, JAX-RS, Spring Framework, Play Framework, JSF с Facelets и немного Spark Framework. Все эти решения, по моему скромному мнению, очень далеки от объектно-ориентированного и элегантного. Все они полны статических методов, непроверяемых структур данных и грязных хаков. Примерно месяц назад я решил создать свой собственный веб-фреймворк на Java. Я положил в его основу несколько основных принципов: 1) нет NULL, 2) нет открытых статических методов, 3) нет изменяемых классов и 4) нет классов, операторов отражения и instanceof . Эти четыре основных принципа должны гарантировать чистый код и прозрачную архитектуру. Так родилась концепция Takes . Посмотрим, что было создано и как оно работает.
Веб-архитектура Java в двух словах
Вот как я понимаю архитектуру веб-приложения и его компоненты в простых терминах.
Во-первых, чтобы создать веб-сервер, мы должны создать новый сетевой сокет , который принимает подключения через определенный порт TCP . Обычно это 80, но я собираюсь использовать 8080 для тестирования. Это делается в Java с ServerSocket класса ServerSocket :
|
1
2
3
4
5
6
7
|
import java.net.ServerSocket;public class Foo { public static void main(final String... args) throws Exception { final ServerSocket server = new ServerSocket(8080); while (true); }} |
Этого достаточно, чтобы запустить веб-сервер. Теперь сокет готов и прослушивает порт 8080. Когда кто-то открывает http://localhost:8080 в своем браузере, соединение будет установлено, и браузер будет вращать колесо ожидания навсегда. Скомпилируйте этот фрагмент и попробуйте. Мы только что создали простой веб-сервер без использования каких-либо фреймворков. Мы пока ничего не делаем с входящими соединениями, но и не отвергаем их. Все они выстраиваются внутри этого объекта server . Это делается в фоновом потоке; вот почему мы должны добавить это while(true) позже. Без этой бесконечной паузы приложение немедленно завершит свое выполнение и сокет сервера закроется.
Следующий шаг — принять входящие соединения. В Java это делается с помощью блокирующего вызова метода accept() :
|
1
|
final Socket socket = server.accept(); |
Метод блокирует свой поток и ожидает поступления нового соединения. Как только это происходит, возвращается экземпляр Socket . Чтобы принять следующее соединение, мы должны снова вызвать accept() . В общем, наш веб-сервер должен работать так:
|
01
02
03
04
05
06
07
08
09
10
11
12
|
public class Foo { public static void main(final String... args) throws Exception { final ServerSocket server = new ServerSocket(8080); while (true) { final Socket socket = server.accept(); // 1. Read HTTP request from the socket // 2. Prepare an HTTP response // 3. Send HTTP response to the socket // 4. Close the socket } }} |
Это бесконечный цикл, который принимает новое соединение, понимает его, создает ответ, возвращает ответ и снова принимает новое соединение. Протокол HTTP не имеет состояния, что означает, что сервер не должен помнить, что произошло при любом предыдущем соединении Все, что его волнует, это входящий HTTP-запрос в этом конкретном соединении.
HTTP-запрос поступает из входного потока сокета и выглядит как многострочный блок текста. Это то, что вы увидите, прочитав входной поток сокета:
|
01
02
03
04
05
06
07
08
09
10
|
final BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream()));while (true) { final String line = reader.readLine(); if (line.isEmpty()) { break; } System.out.println(line);} |
Вы увидите что-то вроде этого:
|
1
2
3
4
5
6
7
8
|
GET / HTTP/1.1Host: localhost:8080Connection: keep-aliveCache-Control: max-age=0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.89 Safari/537.36Accept-Encoding: gzip, deflate, sdchAccept-Language: en-US,en;q=0.8,ru;q=0.6,uk;q=0.4 |
Клиент (например, браузер Google Chrome) передает этот текст в установленное соединение. Он подключается к порту 8080 на localhost и, как только соединение готово, немедленно отправляет в него этот текст, а затем ожидает ответа.
Наша задача — создать HTTP-ответ, используя информацию, которую мы получаем в запросе. Если наш сервер очень примитивен, мы можем игнорировать всю информацию в запросе и просто вернуть «Hello, world!» на все запросы (я использую IOUtils для простоты):
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
import java.net.Socket;import java.net.ServerSocket;import org.apache.commons.io.IOUtils;public class Foo { public static void main(final String... args) throws Exception { final ServerSocket server = new ServerSocket(8080); while (true) { try (final Socket socket = server.accept()) { IOUtils.copy( IOUtils.toInputStream("HTTP/1.1 200 OK\r\n\r\nHello, world!"), socket.getOutputStream() ); } } }} |
Вот и все. Сервер готов. Попробуйте скомпилировать и запустить его. Направьте ваш браузер на http: // localhost: 8080 , и вы увидите Hello, world! :
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
|
$ javac -cp commons-io.jar Foo.java$ java -cp commons-io.jar:. Foo &$ curl http://localhost:8080 -v* Rebuilt URL to: http://localhost:8080/* Connected to localhost (::1) port 8080 (#0)> GET / HTTP/1.1> User-Agent: curl/7.37.1> Host: localhost:8080> Accept: */*>< HTTP/1.1 200 OK* no chunk, no close, no size. Assume close to signal end<* Closing connection 0Hello, world! |
Это все, что вам нужно для создания веб-сервера. Теперь давайте обсудим, как сделать его объектно-ориентированным и компонуемым. Давайте попробуем посмотреть, как был построен фреймворк Takes .
Маршрутизация / Диспетчерская
Самый важный шаг — решить, кто отвечает за построение HTTP-ответа. Каждый HTTP-запрос имеет 1) запрос, 2) метод и 3) количество заголовков. Используя эти три параметра, нам нужно создать экземпляр объекта, который создаст для нас ответ. Этот процесс в большинстве веб-сред называется диспетчеризацией запросов или маршрутизацией. Вот как мы это делаем в Takes:
|
1
2
|
final Take take = takes.route(request);final Response response = take.act(); |
Есть в основном два шага. Первый создает экземпляр Take from take , а второй создает экземпляр Response from take . Почему так сделано? Главным образом для того, чтобы разделить обязанности. Экземпляр Takes отвечает за отправку запроса и создание экземпляра права Take , а экземпляр Take отвечает за создание ответа.
Чтобы создать простое приложение в Takes, вы должны создать два класса. Во-первых, реализация Takes :
|
1
2
3
4
5
6
7
8
9
|
import org.takes.Request;import org.takes.Take;import org.takes.Takes;public final class TsFoo implements Takes { @Override public Take route(final Request request) { return new TkFoo(); }} |
Мы используем эти префиксы Ts и Tk для Takes и Take соответственно. Второй класс, который вы должны создать, это реализация Take :
|
1
2
3
4
5
6
7
8
9
|
import org.takes.Take;import org.takes.Response;import org.takes.rs.RsText;public final class TkFoo implements Take { @Override public Response act() { return new RsText("Hello, world!"); }} |
И теперь пришло время запустить сервер:
|
1
2
3
4
5
6
7
|
import org.takes.http.Exit;import org.takes.http.FtBasic;public class Foo { public static void main(final String... args) throws Exception { new FtBasic(new TsFoo(), 8080).start(Exit.NEVER); }} |
Этот класс FtBasic выполняет те же манипуляции с FtBasic выше. Он запускает сокет сервера на порту 8080 и отправляет все входящие соединения через экземпляр TsFoo который мы передаем его конструктору. Он выполняет эту диспетчеризацию в бесконечном цикле, проверяя каждую секунду, не пора ли остановиться на экземпляре Exit . Очевидно, что Exit.NEVER всегда отвечает «Не останавливайся, пожалуйста».
HTTP-запрос
Теперь давайте посмотрим, что внутри HTTP-запроса, поступающего в TsFoo и что мы можем из него получить. Вот как интерфейс Request определяется в Takes :
|
1
2
3
4
|
public interface Request { Iterable<String> head() throws IOException; InputStream body() throws IOException;} |
Запрос делится на две части: голова и тело. Заголовок содержит все строки, которые идут перед пустой строкой, начинающей тело, согласно спецификации HTTP в RFC 2616 . В фреймворке много полезных декораторов для Request . Например, RqMethod поможет вам получить имя метода из первой строки заголовка:
|
1
|
final String method = new RqMethod(request).method(); |
RqHref поможет извлечь часть запроса и проанализировать ее. Например, это запрос:
|
1
2
|
GET /user?id=123 HTTP/1.1Host: www.example.com |
Этот код извлечет это 123 :
|
1
2
3
|
final int id = Integer.parseInt( new RqHref(request).href().param("id").get(0)); |
RqPrint может получить весь запрос или его тело в виде String :
|
1
|
final String body = new RqPrint(request).printBody(); |
Идея здесь состоит в том, чтобы сделать интерфейс Request простым и обеспечить функциональность этого запроса для его декораторов. Такой подход помогает каркасу держать классы маленькими и связными. Каждый декоратор очень маленький и солидный, делает ровно одну вещь. Все эти декораторы находятся в пакете org.takes.rq . Как вы, наверное, уже поняли, префикс Rq означает Request .
Первое настоящее веб-приложение
Давайте создадим наше первое настоящее веб-приложение, которое будет делать что-то полезное. Я бы порекомендовал начать с класса Entry , который необходим Java для запуска приложения из командной строки:
|
1
2
3
4
5
6
7
|
import org.takes.http.Exit;import org.takes.http.FtCLI;public final class Entry { public static void main(final String... args) throws Exception { new FtCLI(new TsApp(), args).start(Exit.NEVER); }} |
Этот класс содержит только один статический метод main() который будет вызываться JVM при запуске приложения из командной строки. Как видите, он создает экземпляр FtCLI , предоставляя ему экземпляр класса TsApp и аргументы командной строки. Мы создадим класс TsApp за секунду. FtCLI (переводится как «интерфейс с интерфейсом командной строки») создает экземпляр того же FtBasic , оборачивая его в несколько полезных декораторов и конфигурируя его в соответствии с аргументами командной строки. Например, --port=8080 будет преобразован в номер порта 8080 и передан в качестве второго аргумента конструктора FtBasic .
Само веб-приложение называется TsApp и расширяет TsWrap :
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
import org.takes.Take;import org.takes.Takes;import org.takes.facets.fork.FkRegex;import org.takes.facets.fork.TsFork;import org.takes.ts.TsWrap;import org.takes.ts.TsClasspath;final class TsApp extends TsWrap { TsApp() { super(TsApp.make()); } private static Takes make() { return new TsFork( new FkRegex("/robots.txt", ""), new FkRegex("/css/.*", new TsClasspath()), new FkRegex("/", new TkIndex()) ); }} |
Мы обсудим этот класс TsFork через минуту.
Если вы используете Maven, вам следует начать с pom.xml :
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
<?xml version="1.0"?> <modelVersion>4.0.0</modelVersion> <groupId>foo</groupId> <artifactId>foo</artifactId> <version>1.0-SNAPSHOT</version> <dependencies> <dependency> <groupId>org.takes</groupId> <artifactId>takes</artifactId> <version>0.9</version> <!-- check the latest in Maven Central --> </dependency> </dependencies> <build> <finalName>foo</finalName> <plugins> <plugin> <artifactId>maven-dependency-plugin</artifactId> <executions> <execution> <goals> <goal>copy-dependencies</goal> </goals> <configuration> <outputDirectory>${project.build.directory}/deps</outputDirectory> </configuration> </execution> </executions> </plugin> </plugins> </build></project> |
Запуск mvn clean package должен создать файл foo.jar в target каталоге и набор всех зависимостей JAR в target/deps . Теперь вы можете запустить приложение из командной строки:
|
1
2
|
$ mvn clean package$ java -Dfile.encoding=UTF-8 -cp ./target/foo.jar:./target/deps/* foo.Entry --port=8080 |
Приложение готово, и вы можете развернуть его, скажем, в Heroku. Просто создайте файл Procfile в корне хранилища и Procfile репозиторий в Heroku. Вот как должен выглядеть Procfile :
|
1
|
web: java -Dfile.encoding=UTF-8 -cp target/foo.jar:target/deps/* foo.Entry --port=${PORT} |
TsFork
Этот класс TsFork представляется одним из основных элементов фреймворка. Это помогает направить входящий HTTP-запрос на правильный дубль . Его логика очень проста, и в ней всего несколько строк кода. Он инкапсулирует коллекцию «вилок», которые являются экземплярами интерфейса Fork<Take> :
|
1
2
3
|
public interface Fork<T> { Iterator<T> route(Request req) throws IOException;} |
Его единственный метод route() возвращает пустой итератор или итератор с одним Take . TsFork проходит через все форки, вызывая их методы route() пока один из них не вернет дубль . Как только это происходит, TsFork возвращает этот дубль вызывающей стороне, а именно FtBasic .
Давайте сами создадим простую форк. Например, мы хотим показать статус приложения при запросе URL-адреса /status . Вот код:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
final class TsApp extends TsWrap { private static Takes make() { return new TsFork( new Fork.AtTake() { @Override public Iterator<Take> route(Request req) { final Collection<Take> takes = new ArrayList<>(1); if (new RqHref(req).href().path().equals("/status")) { takes.add(new TkStatus()); } return takes.iterator(); } } ); }} |
Я считаю, что логика здесь ясна. Мы либо возвращаем пустой итератор, либо итератор с экземпляром TkStatus внутри. Если возвращается пустой итератор, TsFork попытается найти в коллекции другой форк, который на самом деле получает экземпляр Take , чтобы создать Response . Кстати, если ничего не найдено и все вилки возвращают пустые итераторы, TsFork выдаст исключение «Страница не найдена».
Эта точная логика реализована с помощью FkRegex форка FkRegex , который пытается сопоставить путь URI запроса с предоставленным регулярным выражением:
|
1
2
3
4
5
6
7
|
final class TsApp extends TsWrap { private static Takes make() { return new TsFork( new FkRegex("/status", new TkStatus()) ); }} |
Мы можем составить многоуровневую структуру классов TsFork ; например:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
|
final class TsApp extends TsWrap { private static Takes make() { return new TsFork( new FkRegex( "/status", new TsFork( new FkParams("f", "json", new TkStatusJSON()), new FkParams("f", "xml", new TkStatusXML()) ) ) ); }} |
Опять же, я считаю, что это очевидно. Экземпляр FkRegex попросит инкапсулированный экземпляр TsFork вернуть дубль и попытается извлечь его из инкапсулированного FkParams . Если HTTP-запрос /status?f=xml , будет возвращен экземпляр TkStatusXML .
HTTP-ответ
Теперь давайте обсудим структуру ответа HTTP и его объектно-ориентированную абстракцию Response . Вот как выглядит интерфейс:
|
1
2
3
4
|
public interface Response { Iterable<String> head() throws IOException; InputStream body() throws IOException;} |
Выглядит очень похоже на Request , не так ли? Ну, это идентично, в основном потому, что структура HTTP-запроса и ответа практически идентична. Единственная разница — первая строка.
Существует коллекция полезных декораторов, которые помогают в построении ответов. Они составные , что делает их очень удобными. Например, если вы хотите создать ответ, содержащий HTML-страницу, вы создаете их следующим образом:
|
01
02
03
04
05
06
07
08
09
10
11
12
|
final class TkIndex implements Take { @Override public Response act() { return new RsWithStatus( new RsWithType( new RsWithBody("<html>Hello, world!</html>"), "text/html" ), 200 ); }} |
В этом примере декоратор RsWithBody создает ответ с телом, но без заголовков. Затем RsWithType добавляет к нему заголовок Content-Type: text/html . Затем RsWithStatus удостоверяется, что первая строка ответа содержит HTTP/1.1 200 OK .
Вы можете создавать свои собственные декораторы, которые могут использовать существующие. Посмотрите, как это делается в RsPage от rultor.com.
Как насчет шаблонов?
Как мы видим, возврат простых страниц «Hello, world» не является большой проблемой. Но как насчет более сложных выходных данных, таких как HTML-страницы, XML-документы, наборы данных JSON и т. Д.? Есть несколько удобных декораторов Response которые позволяют все это. Давайте начнем с Velocity , простого движка шаблонов. Ну, это не так просто. Это довольно мощный, но я бы предложил использовать его только в простых ситуациях. Вот как это работает:
|
1
2
3
4
5
6
7
|
final class TkIndex implements Take { @Override public Response act() { return new RsVelocity("Hello, ${name}") .with("name", "Jeffrey"); }} |
Конструктор RsVelocity принимает один аргумент, который должен быть шаблоном Velocity. Затем вы вызываете метод with() , вставляя данные в контекст Velocity. Когда пришло время визуализировать HTTP-ответ, RsVelocity «оценит» шаблон в соответствии с настроенным контекстом. Опять же, я бы рекомендовал использовать этот шаблонный подход только для простых выходных данных.
Для более сложных документов HTML я бы порекомендовал вам использовать XML / XSLT в сочетании с Xembly. Я объяснил эту идею в нескольких предыдущих постах: XML + XSLT в браузере и RESTful API и веб-сайт в том же URL . Это просто и мощно — Java генерирует вывод XML, а процессор XSLT преобразует его в документы HTML. Вот как мы отделяем представление от данных. Таблица стилей XSL является «представлением», а TkIndex — «контроллером» с точки зрения MVC .
Очень скоро я напишу отдельную статью о шаблонировании с Xembly и XSL.
А пока мы создадим декораторы для рендеринга JSF / Facelets и JSP в Takes. Если вы заинтересованы в помощи, пожалуйста, раскройте фреймворк и отправьте свои запросы на получение.
Как насчет настойчивости?
Теперь возникает вопрос: что делать с постоянными сущностями, такими как базы данных, структуры в памяти, сетевые соединения и т. Д. Я предлагаю инициализировать их внутри класса Entry и передавать их в качестве аргументов в конструктор TsApp . Затем TsApp передаст их в конструкторы пользовательских дублей .
Например, у нас есть база данных PostgreSQL, которая содержит некоторые табличные данные, которые нам нужно отобразить. Вот как я могу инициализировать соединение с ним в классе Entry (я использую пул соединений BoneCP ):
|
01
02
03
04
05
06
07
08
09
10
11
12
13
|
public final class Entry { public static void main(final String... args) throws Exception { new FtCLI(new TsApp(Entry.postgres()), args).start(Exit.NEVER); } private static Source postgres() { final BoneCPDataSource src = new BoneCPDataSource(); src.setDriverClass("org.postgresql.Driver"); src.setJdbcUrl("jdbc:postgresql://localhost/db"); src.setUser("root"); src.setPassword("super-secret-password"); return src; }} |
Теперь конструктор TsApp должен принять один аргумент типа java.sql.Source :
|
01
02
03
04
05
06
07
08
09
10
|
final class TsApp extends TsWrap { TsApp(final Source source) { super(TsApp.make(source)); } private static Takes make(final Source source) { return new TsFork( new FkRegex("/", new TkIndex(source)) ); }} |
Класс TkIndex также принимает один аргумент класса Source . Я полагаю, что вы знаете, что делать с ним внутри TkIndex , чтобы получить данные таблицы SQL и преобразовать их в HTML. Дело в том, что зависимость должна быть TsApp в приложение (экземпляр класса TsApp ) в момент его создания. Это чистый и чистый механизм внедрения зависимостей, который абсолютно не содержит контейнеров. Подробнее об этом читайте в разделе «Контейнеры для инъекций зависимости являются загрязнителями кода» .
Модульное тестирование
Поскольку каждый класс является неизменным и все зависимости вводятся только через конструкторы, модульное тестирование чрезвычайно просто. Допустим, мы хотим протестировать TkStatus , который должен возвращать HTML-ответ (я использую JUnit 4 и Hamcrest ):
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
|
import org.junit.Test;import org.hamcrest.MatcherAssert;import org.hamcrest.Matchers;public final class TkIndexTest { @Test public void returnsHtmlPage() throws Exception { MatcherAssert.assertThat( new RsPrint( new TkStatus().act() ).printBody(), Matchers.equalsTo("<html>Hello, world!</html>") ); }} |
Кроме того, мы можем запустить все приложение или любое отдельное приложение на тестовом HTTP-сервере и проверить его поведение через настоящий сокет TCP; например (я использую jcabi-http, чтобы сделать HTTP-запрос и проверить вывод):
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
public final class TkIndexTest { @Test public void returnsHtmlPage() throws Exception { new FtRemote(new TsFixed(new TkIndex())).exec( new FtRemote.Script() { @Override public void exec(final URI home) throws IOException { new JdkRequest(home) .fetch() .as(RestResponse.class) .assertStatus(HttpURLConnection.HTTP_OK) .assertBody(Matchers.containsString("Hello, world!")); } } ); }} |
FtRemote запускает тестовый веб-сервер со случайного порта TCP и вызывает метод exec() в предоставленном экземпляре FtRemote.Script . Первым аргументом этого метода является URI только что запущенной домашней страницы веб-сервера.
Архитектура Takes Framework очень модульная и составная. Любой отдельный дубль может быть протестирован как отдельный компонент, абсолютно независимый от структуры и других дублей .
Почему имя?
Это вопрос, который я слышал довольно часто. Идея проста, и это происходит из кинобизнеса. Когда снимается фильм, съемочная группа снимает много дублей , чтобы запечатлеть реальность и снять ее на пленку. Каждый захват называется дублем .
Другими словами, дубль подобен снимку реальности.
То же самое относится и к этой структуре. Каждый экземпляр Take представляет собой реальность в определенный момент времени. Затем эта реальность отправляется пользователю в форме Response .
| Ссылка: | Архитектура Java Web App In Takes Framework от нашего партнера JCG Егора Бугаенко в блоге About Programming . |
