Статьи

Архитектура веб-приложения Java в Takes Framework

Раньше я использовал сервлеты, JSP, JAX-RS, Spring Framework, Play Framework, JSF с Facelets и немного Spark Framework. Все эти решения, по моему скромному мнению, очень далеки от объектно-ориентированного и элегантного. Все они полны статических методов, непроверяемых структур данных и грязных хаков. Примерно месяц назад я решил создать свой собственный веб-фреймворк на Java. Я положил в его основу несколько основных принципов: 1) нет NULL, 2) нет открытых статических методов, 3) нет изменяемых классов и 4) нет классов, операторов отражения и instanceof . Эти четыре основных принципа должны гарантировать чистый код и прозрачную архитектуру. Так родилась концепция Takes . Посмотрим, что было создано и как оно работает.

Создание Крестного отца (1972) Фрэнсисом Фордом Копполой

Создание Крестного отца (1972) Фрэнсисом Фордом Копполой

Веб-архитектура 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 .