Раньше я использовал сервлеты, 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.1 Host: localhost: 8080 Connection: keep-alive Cache-Control: max-age= 0 Accept: text/html,application/xhtml+xml,application/xml;q= 0.9 ,image/webp,*/*;q= 0.8 User-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.36 Accept-Encoding: gzip, deflate, sdch Accept-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 0 Hello, 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.1 Host: 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 . |