自己編寫Java Web框架:Takes框架的Web App架構
我用過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!</pre>
這就是你編譯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(
); } }</pre>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 ; <modelVersion>4.0.0</modelVersion> <groupId>foo</groupId> <artifactId>foo</artifactId> <version>1.0-SNAPSHOT</version> <dependencies> <dependency>
</dependency> </dependencies> <build> <finalName>foo</finalName> <plugins><groupId>org.takes</groupId> <artifactId>takes</artifactId> <version>0.9</version> <!-- check the latest in Maven Central -->
</plugins> </build> </project></pre><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>
運行“ 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(
); } }</pre>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(
); } }</pre>new FkRegex("/status", new TkStatus())
我們可以組合多層結構的TsFork類,例如:
final class TsApp extends TsWrap { private static Takes make() { return new TsFork(
); } }</pre>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(
); } }</pre>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}")
} }</pre>.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(
); } }</pre>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(
); } }</pre>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(
); } }</pre>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形式發送。
原文鏈接: javacodegeeks 翻譯: ImportNew.com - zdpg
譯文鏈接: http://www.importnew.com/16045.html