自己編寫Java Web框架:Takes框架的Web App架構

ImportNew發表於2015-05-30

我用過Servlets、JSP、JAX-RS、 Spring框架、Play框架、帶Facelets的JSF以及Spark Framework。在我看來,這些框架並沒有很好地實現物件導向設計。它們充斥著靜態方法、未經測試的資料結構以及不夠美觀的解決方式。因此一個月前我決定開始編寫自己的Java Web框架,我制定了一些基本的信條:1) 沒有NULL,2) 沒有public static方法,3) 沒有可變類(mutable class),4) 沒有型別轉換、反射和instanceof操作。這四條基本準則應該足夠保證乾淨的程式碼和透明的架構。這就是Takes框架誕生的原因。讓我們看看這是如何實現的。

Java Web架構簡介

簡單來說,這就是我對一個Web應用架構以及其元件的理解。

首先,要建立一個Web伺服器,我們應該新建立一個網路套接字(socket),其將會在特定的TCP埠接受連線請求。通常這個埠是80,但是為了方便測試我將使用8080埠。這些在Java中用ServerSocket類完成。

import java.net.ServerSocket;
public class Foo {
  public static void main(final String... args) throws Exception {
    final ServerSocket server = new ServerSocket(8080);
    while (true);
  }
}

這些足夠去啟動一個Web伺服器。現在,socket已經就緒監聽8080埠。當有人在瀏覽器開啟 http://localhost:8080 ,將會建立連線並且等待的齒輪在瀏覽器上不停的旋轉。編譯這些片段試一下。我們剛剛沒有使用任何框架搭建了一個簡單的Web伺服器。我們並沒有對進入的連線做任何事情,但是也沒有拒絕它們。所有的連線都正在伺服器物件內部排隊。這些在後臺執行緒中完成,這就是為什麼需要在最後放一個 while(true) 的原因。沒有這個無限迴圈,應用將會立即終止操作並且伺服器套接字將會關閉。

下一步是接受進入的連線。在Java中,通過對 accept() 方法的阻塞呼叫來完成。

final Socket socket = server.accept();

這個方法將會一直阻塞執行緒等待直到一個新的連線到達。新連線一發生,accept() 方法就會返回一個Socket例項。為了接受下一個連線,我們將會再次呼叫 accept() 方法。因此簡單來講,我們的Web伺服器將會像下面一樣工作:

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請求來自於套接字的輸入流中,就像多行的文字塊。這就是你讀取套接字的輸入流將會看到的內容:

final BufferedReader reader = new BufferedReader(
  new InputStreamReader(socket.getInputStream())
);
while (true) {
  final String line = reader.readLine();
  if (line.isEmpty()) {
    break;
  }
  System.out.println(line);
}

你將會看到以下資訊:

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

客戶端(例如谷歌的Chrome瀏覽器)把這些文字傳給已建立的連線。它連線本地的8080埠,只要連線完成,它會立即將這些文字發給伺服器,然後等待響應。

我們的工作就是用從請求得到的資訊建立相應的HTTP響應。如果我們的伺服器非常原始,可以忽略請求中的所有資訊而對所有的請求僅僅返回“Hello, world! ”(簡單起見我用了IOUtils)。

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!”。

$ 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!

這就是你編譯web伺服器要做的所有事情。現在讓我們來討論如何讓它物件導向並且可元件化。讓我們看看Takes框架是如何建立的。

路由/分發

最重要的一步是決定誰來負責構建HTTP響應。每個HTTP請求都有1)一個查詢,2)一個方法,3)一些頭部資訊。要使用這三個引數,需要例項化一個物件來為我們構建響應。在大多數的Web框架中,這個過程叫做請求分發或路由。下面是如何用Takes完成這些。

final Take take = takes.route(request);
final Response response = take.act();
基本上有兩步。第一步從takes建立Take的例項,第二步從takes建立響應的例項。為什麼採用這種方式?主要是為了分離責任。Takes的例項負責分發請求並且初始化正確的Take,Take的例項負責建立響應。

用Takes建立一個簡單的應用,你應該建立兩個類。首先,一個實現Takes介面的類:

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介面的類:
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!");
  }
}
現在到啟動伺服器的時候了:
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類正是實現了上面解釋過的和socket一樣的操作。它在埠8080上啟動一個伺服器端的socket,通過傳給建構函式TsFoo例項來分發所有進入的連線。它在一個無限迴圈中完成分發,用Exit例項每秒檢查是否是時候停止。顯然,Exit.NEVER總是返回“請不要停止”。

HTTP請求

現在讓我們來了解一下到達TsFoo的HTTP請求內部都有什麼,我們能從請求中得到什麼。下面是在Takes中定義的Request介面:

public interface Request {
  Iterable<String> head() throws IOException;
  InputStream body() throws IOException;
}

請求分為兩部分:頭部和正文。根據RFC 2616中HTTP規範,頭部包含用來開始正文的空行前的所有的行。框架中有很多有用的請求裝飾器。例如,RqMethod可以幫助從頭部第一行取到方法名。

final String method = new RqMethod(request).method();

RqHref用來幫助提取查詢部分並且進行解析。例如,下面是一個請求:

GET /user?id=123 HTTP/1.1
Host: www.example.com

程式碼將會提取得到“123”:

GET /user?id=123 HTTP/1.1
Host: www.example.com

RqPrint可以獲取整個請求或者正文,作為字串列印出來:

final String body = new RqPrint(request).printBody();

這裡的想法是保持請求介面簡單,並且用裝飾器提供解析請求的功能。每一個裝飾器都非常小巧穩定,只用來完成一件事。所有這些裝飾器都在“org.takes.rq”包中。你可能已經理解,“Rq”字首代表請求(Request)。

第一個真正的Web應用

讓我們建立我們第一個真正意義上的Web應用,它將會做一些有意義的事情。我推薦以一個Entry類開始。對Java來說,從命令列啟動一個應用是必須的。

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(翻譯成“front-end with command line interface”即“帶命令列介面的前端”)建立了FtBasic的例項,用一些有用的裝飾器對它進行包裝並根據命令列引數配置。例如,“–port=8080”將會轉換成8080埠號並被當做 FtBasic 建構函式的第二個引數傳入。

web應用本身繼承TsWrap,叫做TsApp:

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開始:

<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <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”會在“target ”目錄中生成一個 foo.jar 檔案並且在“target/deps”目錄生成一批所有JAR依賴包。現在你可以從命令列執行應用:

$ mvn clean package
$ java -Dfile.encoding=UTF-8 -cp ./target/foo.jar:./target/deps/* foo.Entry --port=8080

應用已經就緒,你可以部署到Heroku。在倉庫的根目錄下建立一個Profile檔案,然後把倉庫推入Heroku。下面是Profile的內容:

web: java -Dfile.encoding=UTF-8 -cp target/foo.jar:target/deps/* foo.Entry --port=${PORT}

TsFork

TsFork類看上去是其中一個框架核心元素。它將進入的HTTP請求路由到正確的“take”。它的邏輯非常的簡單,程式碼也只有少量行。它封裝了“forks”的一個集合,“forks”是Fork<Take>介面的例項。

public interface Fork<T> {
  Iterator<T> route(Request req) throws IOException;
}

僅有的 route() 方法返回空迭代器或者含有單個take的迭代器。TsFork遍歷所有的forks,呼叫它們的 route() 方法直到其中一個返回take。一旦發生,TsFork會把這個take返回給呼叫者,即 FtBasic

現在我們自己來建立一個簡單的fork。例如,當請求URL“/status”時,我們想展示應用的狀態。以下是程式碼實現:

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將嘗試在集合中尋找另一個這樣的fork,它可以獲得Take的例項從而進行響應。順便提一下,如果什麼也沒發現所有的forks返回空迭代器,那麼TsFork將丟擲“Page not found”的異常。

這樣的邏輯通過叫做FkRegex的開箱即用fork實現,嘗試用提供的通用表示式去匹配請求的URI:

final class TsApp extends TsWrap {
  private static Takes make() {
    return new TsFork(
      new FkRegex("/status", new TkStatus())
    );
  }
}

我們可以組合多層結構的TsFork類,例如:

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())
        )
      )
    );
  }
}

Again, I believe it’s obvious. The instance of FkRegex will ask an encapsulated instance of TsFork to return a take, and it will try to fetch it from one that FkParams encapsulated. If the HTTP query is /status?f=xml, an instance of TkStatusXML will be returned.

我相信邏輯是很清晰的。FkRegex的例項將會要求TsFork的封裝例項返回一個take,並且它會嘗試從FkParams封裝的例項中獲取。

HTTP響應

現在讓我們討論HTTP響應的結構以及它的物件導向的抽象—— Response。以下是介面的定義:

public interface Response {
  Iterable<String> head() throws IOException;
  InputStream body() throws IOException;
}

Request看起來非常類似,是不是?好吧,它是相同的。因為HTTP請求和響應的結構幾乎是相同的,唯一的區別只是第一行。有很多有用的裝飾器幫助構建響應。他們是元件化的,這使得使用起來非常方便。例如,如果你想構建一個包含HTML頁面的響應,你可以這樣做:

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”。

你可以複用已有的裝飾器來建立自己的裝飾器。可以看看 rultor.com 上 RsPage 如何自定義裝飾器。

如何使用模板?

如你所見,返回簡單的“Hello, world”頁面並不是一個大問題。但是返回更復雜的輸出例如HTML頁面、XML文件、JSON資料集,又該怎麼辦?讓我們從一個簡單的模板引擎“Velocity”開始。好吧,其實它並不簡單。它相當強大,但是我只建議在簡單情形下使用。下面是關於它如何工作:

final class TkIndex implements Take {
  @Override
  public Response act() {
    return new RsVelocity("Hello, ${name}")
      .with("name", "Jeffrey");
  }
}

RsVelocity 構造器接受Velocity模板作為唯一引數。然後,你可以呼叫“with()”方法,往Velocity上下文注入資料。當到渲染HTTP響應的時候,RsVelocity 將會將模板和配置的上下文進行“評估”。再次強調,我只推薦在非常簡單的輸出時使用這種模板方式。

對於更復雜的HTML文件,我將推薦你使用結合Xembly使用XML/XSLT。在先前的幾篇部落格中我解釋了這種想法,XML+XSLT in a Browser 和RESTful API and a Web Site in the Same URL。這種方式簡單強大——用Java生成XML,XSLT 處理器將其轉換成HTML文件。這就是我們如何分離表示和資料。在MVC來看,XSL樣式表是一個“檢視”,TkIndex 是一個“控制器”。

不久我會單獨寫一篇文章來介紹使用Xembly和XSL模板生成頁面。

同時,我會在Takes框架中為 JSF/Facelets 和 JSP 渲染建立裝飾器。如果你對這部分工作感興趣,請fork這個框架並提交你的pull請求。

如何持久化?

現在,一個問題就出來了。如何處理諸如資料庫、記憶體結構、網路連線之類的持久層實體?我的建議是在Entry類中例項化它們,並把它們作為引數傳入TsApp的建構函式中。然後,TsApp將會把它們傳入自定義的“takes”的建構函式中。

例如,我們有一個PostgreSQL資料庫,包含一些用來渲染的表資料。這裡我將在Entry類中例項化資料庫連線(使用 BoneCP連線池):

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”型別的引數:

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型別的引數。為了取SQL表資料並把它轉換成HTML,相信你知道TkIndex內部如何處理的。這裡的關鍵點是在應用(TsApp類的例項)初始化時必須注入依賴。這是純粹乾淨的依賴注入機制,完全無需任何容器。更多相關閱讀請參閱“Dependency Injection Containers Are Code Polluters”

單元測試

因為每個類是不可變的並且所有的依賴都是通過建構函式注入,所以單元測試非常簡單。比如我們想測試“TkStatus”,假定它將會返回一個HTML響應(我使用JUnit 4 和Hamcrest):

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伺服器中啟動整個應用或者任何一個單獨的“take”,然後通過真實的TCP套接字測試它的行為;例如(我使用jcabi-http構造HTTP請求並且檢測輸出):

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埠啟動一個測試Web伺服器,並且在 FtRemote.Script 提供的例項中呼叫 exec() 方法。此方法的第一個引數是剛才啟動的web伺服器主頁面的URI。

Takes框架的架構非常模組化且易於組合。任何獨立的“take”都可以作為一個單獨的元件被測試,絕對獨立於框架和其它“takes”。

為什麼叫這個名字?

這是我聽到最頻繁的問題。想法很簡單,它和電影有關。當製作一部電影時,工作人員為了捕捉現實會拍攝很多鏡頭然後放入電影中。每一個拍攝稱作一個鏡頭(take)。

換句話說,一個鏡頭就像現實的一個快照。每一個鏡頭例項代表特定時刻的一個事實。這個事實然後以響應的形式傳送給使用者。

同樣的道理也適用於框架。每個Take例項都代表著特定某個時刻的真實存在。這個資訊會以Response形式傳送。

相關文章