【Spring實戰】構建Spring Web應用程式
本章內容:
- 對映請求到Spring控制器
- 透明地繫結表單引數
- 校驗表單提交
狀態管理、工作流以及驗證都是Web 開發需要解決的重要特性。HTTP協議的無狀態性決定了這些問題都不那麼容易解決。
Spring的Web框架就是為了幫助解決這些關注點而設計的。Spring MVC基於模型-檢視-控制器(Model-View-Controller,MVC)模式實現,它能構建像Spring框架那樣靈活和鬆耦合的Web應用程式。
Spring MVC起步
Spring將請求在排程Servlet、處理器對映(handler mapping)、控制器以及檢視解析器(view resolver)之間移動。
跟蹤Spring MVC的請求
每當使用者在Web瀏覽器中點選連結或提交表單的時候,請求就開始工作了。請求會將資訊從一個地方帶到另一個地方,就像是快遞投送員。
從離開瀏覽器開始到獲取響應返回,請求會經歷好多站,在每站都會留下一些資訊同時也會帶上其他資訊。
在請求離開瀏覽器時 ①,會帶有使用者所請求內容的資訊,比如請求的URL,使用者提交的表單資訊。
請求旅程的第一站是Spring的DispatcherServlet
。與大多數基於Java的Web框架一樣,Spring MVC所有的請求都會通過一個前端控制器(front controller)Servlet。
前端控制器是常用的Web應用程式模式,在這裡一個單例項的Servlet將請求委託給應用程式的其他元件來執行實際的處理。在Spring MVC中,DispatcherServlet
就是前端控制器。
DispatcherServlet
的任務是將請求傳送給Spring MVC控制器(controller)。控制器是一個用於處理請求的Spring元件。在典型的應用程式會有多個控制器,DispatcherServlet
需要知道應該將請求傳送給哪個控制器。所以DispatcherServlet
以會查詢一個或多個處理器對映(handler mapping)②來確定請求的下一站在哪裡。處理器對映會根據請求所攜帶的URL資訊來進行決策。
一旦選擇了合適的控制器,DispatcherServlet
會將請求傳送給選中的控制器③ 。到了控制器,請求會卸下其負載(使用者提交的資訊)並等待控制器處理這些資訊。(設計良好的控制器本身只處理很少甚至不處理工作,而是將業務邏輯委託給一個或多個服務物件進行處理。)
控制器在完成邏輯處理後,通常會產生一些資訊,這些資訊需要返回給使用者並在瀏覽器上顯示。這些資訊被稱為模型(model)。這些資訊會以使用者友好的方式進行格式化,一般會是HTML。所以,資訊需要傳送給一個檢視(view),通常會是JSP。
控制器所做的最後一件事就是將模型資料打包,並且標示出用於渲染輸出的檢視名。接下來會將請求連同模型和檢視名傳送回DispatcherServlet
④。
這樣控制器就不會與特定的檢視相耦合,傳遞給DispatcherServlet
的檢視名並不直接表示某個特定的JSP。甚至並不能確定檢視就是JSP。它僅僅傳遞了一個邏輯名稱,這個名字將會用來查詢產生結果的真正檢視。DispatcherServlet
將會使用檢視解析器(view resolver)⑤來將邏輯檢視名匹配為一個特定的檢視實現。
DispatcherServlet
知道由哪個檢視渲染結果以後,請求的最後一站是檢視的實現⑥,請求在這裡交付模型資料。請求的任務完成。檢視將使用模型資料渲染輸出,這個輸出會通過響應物件傳遞給客戶端⑦。
請求要經過很多的步驟,最終才能形成返回給客戶端的響應。大多數的步驟都是在Spring框架內部完成的。
若不使用Spring MVC,則需要使用者負責編寫一個Dispatcher servlet
,這個Dispatcher servlet
要能做如下事情:
- 根據URI呼叫相應的action。
- 例項化正確的控制器類。
- 根據請求引數值來構造表單bean
- 呼叫控制器物件的相應方法。
- 轉向到一個檢視(JSP頁面)。
搭建Spring MVC
藉助於最近幾個Spring新版本的功能增強,使用Spring MVC變得非常簡單了。現在,使用最簡單的方式來配置Spring MVC:所要實現的功能僅限於執行所建立的控制器。
配置DispatcherServlet
Spring MVC中提供了一個Dispatcher Servlet
,它會呼叫控制器方法並轉發到檢視。DispatcherServlet
是Spring MVC的核心。在這裡請求會第一次接觸到框架,它要負責將請求路由到其他的元件之中。
傳統的方式,像DispatcherServlet
這樣的Servlet
會配置在web.xml
檔案中,這個檔案會放到應用的WAR
包裡面。
藉助於Servlet 3規範和Spring 3.1的功能增強,這種方式已經不是唯一的方案了,可以使用Java將DispatcherServlet
配置在Servlet容器中:
package spittr.config;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;
import spittr.web.WebConfig;
public class SpitterWebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class<?>[] { RootConfig.class };
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[] { WebConfig.class };
}
}
擴充套件AbstractAnnotationConfigDispatcherServletInitializer
的任意類都會自動地配置Dispatcher-Servlet和Spring應用上下文,Spring的應用上下文會位於應用程式的Servlet上下文之中。
AbstractAnnotationConfigDispatcherServletInitializer剖析
在Servlet 3.0環境中,容器會在類路徑中查詢實現
javax.servlet.ServletContainerInitializer
介面的類,如果能發現的話,就會用它來配置Servlet容器。Spring提供了這個介面的實現,名為SpringServletContainerInitializer
,這個類反過來又會查詢實現WebApplicationInitializer
的類並將配置的任務交給它們來完成。Spring 3.2引入了一個便利的WebApplicationInitializer
基礎實現,也就是AbstractAnnotationConfigDispatcherServletInitializer
因為我們的Spittr-WebAppInitializer
擴充套件了AbstractAnnotationConfigDispatcherServletInitializer
(同時也就實現了WebApplicationInitializer
),因此當部署到Servlet 3.0容器中的時候,容器會自動發現它,並用它來配置Servlet上下文。
儘管AbstractAnnotationConfigDispatcherServletInitializer
的名字很長,但使用起來很簡便。在上面的程式中,SpittrWebAppInitializer
重寫了三個方法。
第一個方法是getServletMappings()
,它會將一個或多個路徑對映到DispatcherServlet
上。在本例中,它對映的是“/”,這表示它會是應用的預設Servlet。它會處理進入應用的所有請求。
要理解其他的兩個方法,首先要理解DispatcherServlet和一個Servlet監聽器(ContextLoaderListener)的關係。
兩個應用上下文之間的故事
當DispatcherServlet
啟動的時候,它會建立Spring應用上下文,並載入配置檔案或配置類中所宣告的bean。getServletConfigClasses()
方法中,指明DispatcherServlet載入應用上下文時,使用定義在WebConfig配置類(使用Java配置)中的bean。
但是在Spring Web應用中,通常還會有另外一個應用上下文。另外的這個應用上下文是由ContextLoaderListener
建立的。
我們希望DispatcherServlet載入包含Web元件的bean,如控制器、檢視解析器以及處理器對映,而ContextLoaderListener要載入應用中的其他bean。這些bean通常是驅動應用後端的中間層和資料層元件。
實際上,AbstractAnnotationConfigDispatcherServletInitializer
會同時建立DispatcherServlet
和ContextLoaderListener
。GetServletConfigClasses()
方法返回的帶有@Configuration
註解的類將會用來定義DispatcherServlet應用上下文中的bean。getRootConfigClasses()
方法返回的帶有@Configuration
註解的類將會用來配置ContextLoaderListener
建立的應用上下文中的bean。
如果按照這種方式配置DispatcherServlet,而不是使用web.xml的話,只能部署到支援Servlet 3.0的伺服器中才能正常工作,如Tomcat 7或更高版本。
如果沒有使用支援Servlet 3.0的伺服器,就無法在AbstractAnnotationConfigDispatcherServletInitializer
子類中配置DispatcherServlet
,只能使用web.xml
配置。
啟用Spring MVC
啟用Spring MVC元件的方法不僅一種。使用XML進行配置的,可以使用<mvc:annotation-driven>
啟用註解驅動的Spring MVC。
基於Java的配置,使用帶有@EnableWebMvc
註解的類:
package spittr.web;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@Configuration
@EnableWebMvc
public class WebConfig {
}
但還有其他問題要解決:
- 沒有配置檢視解析器。Spring預設會使用
BeanNameView-Resolver
,這個檢視解析器會查詢ID與檢視名稱匹配的bean,並且查詢的bean要實現View介面,以這樣的方式來解析檢視。 - 沒有啟用元件掃描。Spring只能找到顯式宣告在配置類中的控制器。
-
DispatcherServlet
會對映為應用的預設Servlet,所以它會處理所有的請求,包括對靜態資源的請求,如圖片和樣式表(可能並不是想要的效果)。
需要為這個最小的Spring MVC配置再加上一些內容,從而讓它變得真正有用。
package spittr.web;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
@Configuration
@EnableWebMvc
@ComponentScan("spittr.web")
public class WebConfig extends WebMvcConfigurerAdapter {
@Bean
public ViewResolver viewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
return resolver;
}
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// TODO Auto-generated method stub
super.addResourceHandlers(registry);
}
}
WebConfig
現在新增了@Component-Scan
註解,因此將會掃描spitter.web
包來查詢元件。控制器如果帶有@Controller
註解,會使其成為元件掃描時的候選bean
。不需要在配置類中顯式宣告任何的控制器。
接著,新增了一個ViewResolver bean
。具體來講,是InternalResourceViewResolver
。它會查詢JSP檔案,在查詢的時候,會在檢視名稱上加一個特定的字首和字尾(例如,名為home的檢視將會解析為/WEB-INF/views/home.jsp
)。
新的WebConfig類還擴充套件了WebMvcConfigurerAdapter
並重寫了其configureDefaultServletHandling()
方法。通過呼叫DefaultServletHandlerConfigurer
的enable()
方法,要求DispatcherServlet
將對靜態資源的請求轉發到Servlet容器中預設的Servlet上,而不是使用DispatcherServlet
本身來處理此類請求。
Web相關的配置通過DispatcherServlet建立的應用上下文都已經配置好了,因此現在的RootConfig相對很簡單:
package spittr.config;
import java.util.regex.Pattern;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.web.servlet.config.annotation.EnableWebMVC;
@Configuration
@ComponentScan(basePackages={"spitter"},
excludeFilters={
@Filter(type=FilterType.ANNOTATION, value=EnableWebMVC.class)
})
public class RootConfig {
}
RootConfig使用了@ComponentScan
註解。這樣的話,就有很多機會用非Web的元件來充實完善RootConfig。
現在,已經可以開始使用Spring MVC構建Web應用了。
Spittr應用簡介
因為從Twitter借鑑了靈感並且通過Spring來進行實現,所以它就有了一個名字:Spitter。再進一步,應用網站命名中流行的模式,如Flickr,我們去掉字母e,這樣的話,我們就將這個應用稱為Spittr。這個名稱也 有助於區分應用名稱和領域型別,因為我們將會建立一個名為Spitter的領域類。
Spittr應用有兩個基本的領域概念:Spitter
(應用的使用者)和Spittle
(使用者釋出的簡短狀態更新)。當我們在書中完善Spittr應用的功能時,將會介紹這兩個領域概念。在本章中,會構建應用的Web層,建立展現Spittle的控制器以及處理使用者註冊成為Spitter的表。
編寫基本的控制器
在Spring MVC中,控制器只是方法上新增了@RequestMapping
註解的類,這個註解宣告瞭它們所要處理的請求。
假設控制器類要處理對“/”的請求,並渲染應用的首頁:
package spittr.web;
import static org.springframework.web.bind.annotation.RequestMethod.*;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class HomeController {
@RequestMapping(value = "/", method = GET)
public String home(){
return "home";
}
}
HomeController
是一個構造型(stereotype)的註解,它基於@Component
註解,輔助實現元件掃描。因為HomeController
帶有@Controller
註解,因此元件掃描器會自動找到HomeController
,並將其宣告為Spring應用上下文中的一個bean。
可以讓HomeController
帶有@Component
註解,效果相同,但是表意性會差一點。
HomeController
唯一的一個方法,也就是home()
方法,帶有@RequestMapping
註解。它的value屬性指定了這個方法所要處理的請求路徑,method屬性細化了它所處理的HTTP方法。當收到對“/”的HTTP GET請求時,就會呼叫home()
方法。
home()
方法返回了一個String型別的“home”。這個String將會被Spring MVC解讀為要渲染的檢視名稱。DispatcherServlet
會要求檢視解析器將這個邏輯名稱解析為實際的檢視。
根據檢視解析器InternalResourceViewResolver
的配置,檢視名“home”將會解析為“/WEB-INF/views/home.jsp”路徑的JSP。
定義一個簡單的首頁JSP檔案:
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page session="false" %>
<html>
<head>
<title>Spitter</title>
<link rel="stylesheet"
type="text/css"
href="<c:url value="/resources/style.css" />" >
</head>
<body>
<h1>Welcome to Spitter</h1>
<a href="<c:url value="/spittles" />">Spittles</a> |
<a href="<c:url value="/spitter/register" />">Register</a>
</body>
</html>
這個JSP提供了兩個連結:一個是檢視Spittle列表,另一個是在應用中進行註冊。下圖展現了此時的首頁的樣子:
現在,對這個控制器發起一些請求,看一下它是否能夠正常工作。測試控制器最直接的辦法可能就是構建並部署應用,然後通過瀏覽器對其進行訪問,但是自動化測試可提供更快的反饋和更一致的獨立結果。
測試控制器
編寫一個簡單的類來測試HomeController:
package java.spittr.web;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
import spittr.web.HomeController;
public class HomeControllerTest {
@Test
public void testHomePage() throws Exception {
HomeController controller = new HomeController();
assertEquals("home",controller.home());
}
}
程式碼只測試了home()
方法中會發生什麼。在測試中會直接呼叫home()
方法,並斷言返回包含“home”值的String。它完全沒有站在Spring MVC控制器的視角進行測試。這個測試沒有斷言當接收到針對“/”的GET請求時會呼叫home()
方法。因為它返回的值就是“home”,所以也沒有真正判斷home是檢視的名稱。
從Spring 3.2開始,我們可以按照控制器的方式來測試Spring MVC中的控制器了,而不僅僅是作為POJO進行測試。Spring現在包含了一種mock Spring MVC並針對控制器執行HTTP請求的機制。這樣的話,在測試控制器的時候,就沒有必要再啟動Web伺服器和Web瀏覽器了。
重寫HomeControllerTest並使用Spring MVC中新的測試特性:
package spittr.web;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
import org.junit.Test;
import org.springframework.test.web.servlet.MockMvc;
import spittr.web.HomeController;
public class HomeControllerTest {
@Test
public void testHomePage() throws Exception {
HomeController controller = new HomeController();
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(get("/")).andExpect(view().name("home"));
}
新版本的測試比之前更加完整地測試了HomeController。這次我們不是直接呼叫home()方法並測試它的返回值,而是發起了對“/”的GET請求,並斷言結果檢視的名稱為home。它首先傳遞一個HomeController例項到MockMvcBuilders.standaloneSetup()並呼叫build()來構建MockMvc例項。然後它使用MockMvc例項來執行鍼對“/”的GET請求並設定期望得到的檢視名稱。
定義類級別的請求處理
拆分@RequestMapping,並將其路徑對映部分放到類級別上:
package spittr.web;
import static org.springframework.web.bind.annotation.RequestMethod.*;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(value = "/")
public class HomeController {
@RequestMapping(method = GET)
public String home(){
return "home";
}
}
新版本的HomeController
中,路徑現在被轉移到類級別的@RequestMapping
上,而HTTP方法依然對映在方法級別上。當控制器在類級別上新增@RequestMapping
註解時,這個註解會應用到控制器的所有處理器方法上。處理器方法上的@RequestMapping
註解會對類級別上的@RequestMapping
的宣告進行補充。
HomeController只有一個控制器方法。與類級別的@Request-Mapping
合併之後,這個方法的@RequestMapping
表明home()
將會處理對“/”路徑的GET請求。
測試程式碼可以確保在這個修改過程中,沒有對原有的功能造成破壞。
當我們在修改@RequestMapping
時,還可以對HomeController
做另外一個變更@RequestMapping
的value屬效能夠接受一個String型別的陣列。到目前為止,我們給它設定的都是一個String型別的“/”。但是,我們還可以將它對映到對“/homepage”的請求,只需將類級別的@RequestMapping
改為如下所示:
@Controller
@RequestMapping({"/", "/homepage"})
public class HomeController {
...
}
現在,HomeController
的home()
方法能夠對映到對“/”和“/homepage”的GET請求。
傳遞模型資料到檢視中
大多數的控制器並不像HomeController
那麼簡單。在Spittr應用中,我們需要有一個頁面展現最近提交的Spittle列表。因此,我們需要一個新的方法來處理這個頁面。
首先,需要定義一個資料訪問的Repository。為了實現解耦以及避免陷入資料庫訪問的細節之中,我們將Repository定義為一個介面,並在稍後實現它(第10章中)。此時,我們只需要一個能夠獲取Spittle列表的Repository,如下所示的SpittleRepository功能已經足夠了:
package spittr.data;
import java.util.List;
import spittr.Spittle;
public interface SpittleRepository {
List<Spittle> findSpittles(long max, int count);
}
findSpittles()
方法接受兩個引數。其中max引數代表所返回的Spittle中,Spittle ID屬性的最大值,而count參數列明要返回多少個Spittle物件。為了獲得最新的20個Spittle物件,我們可以這樣呼叫findSpittles():
List<Spittle> recent = spittleRepository.findSpittles(Long.MAX_VALUE,20);
現在,定義一個Spittle類,讓Spittle類儘可能的簡單。它的屬性包括訊息內容、時間戳以及Spittle釋出時對應的經緯度:
package spittr;
import java.util.Date;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
public class Spittle {
private final Long id;
private final String message;
private final Date time;
private Double latitude;
private Double longitude;
public Spittle(String message, Date time){
this(message,time,null,null);
}
public Spittle(String message, Date time, Double latitude, Double longitude){
this.id = null;
this.message = message;
this.time = time;
this.longitude = longitude;
this.latitude = latitude;
}
public Long getId() {
return id;
}
public String getMessage() {
return message;
}
public Date getTime() {
return time;
}
public Double getLatitude() {
return latitude;
}
public Double getLongitude() {
return longitude;
}
@Override
public boolean equals(Object that){
return EqualsBuilder.reflectionEquals(this, that, "id", "time")
}
@Override
public int hashCode(){
return HashCodeBuilder.reflectionHashCode(this, "id", "time")
}
}
就大部分內容來看,Spittle就是一個基本的POJO資料物件沒有什麼複雜的。唯一要注意的是,我們使用Apache Common Lang
包來實現equals()
和hashCode()
方法。這些方法除了常規的作用以外,當我們為控制器的處理器方法編寫測試時,它們也是有用的。
使用Spring的MockMvc來斷言新的處理器方法中所期望的行為:
@Test
public void shouldShowRecentSpittles() throws Exception {
List<Spittle> expectedSpittles = createSpittleList(20);
SpittleRepository mockRepository =
mock(SpittleRepository.class);
when(mockRepository.findSpittles(Long.MAX_VALUE,20))
.thenReturn(expectedSpittles);
SpittleController controller = new SpittleController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller).setSingleView(
new InternalResourceView("/WEB-INF/views/spittles.jsp"))
.build();
mockMvc.perform(get("/spittles"))
.andExpect(view().name("spittles"))
.andExpect(model().attributeExists("spittleList"))
.andExpect(model().attribute("spittleList",
hasItems(expectedSpittles.toArray())));
}
. . .
private List<Spittle> createSpittleList(int count) {
List<Spittle> spittles = new ArrayList<Spittle>();
for (int i = 0; i < count ; i++) {
spittles.add(new Spittle("Spittle " + i, new Date()));
}
return spittles;
}
這個測試首先會建立SpittleRepository介面的mock實現,這個實現會從它的findSpittles()方法中返回20個Spittle物件。然後,它將這個Repository注入到一個新的SpittleController例項中,然後建立MockMvc並使用這個控制器。
這個測試在MockMvc構造器上呼叫了setSingleView()。這樣的話,mock框架就不用解析控制器中的檢視名了。在很多場景中,其實沒有必要這樣做。但是對於這個控制器方法,檢視名與請求路徑是非常相似的,這樣按照預設的檢視解析規則時,MockMvc就會發生失敗,因為無法區分檢視路徑和控制器的路徑。在這個測試中,構建InternalResourceView時所設定的實際路徑是無關緊要的,但我們將其設定為與InternalResourceViewResolver配置一致。
這個測試對“/spittles”發起GET請求,然後斷言檢視的名稱為spittles並且模型中包含名為spittleList的屬性,在spittleList中包含預期的內容。
如果此時執行測試的話,它將會失敗。它不是執行失敗,而是在編譯的時候就會失敗。這是因為我們還沒有編寫SpittleController。現在建立一個SpittleController:
package spittr.web;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import spittr.data.SpittleRepository;
@Controller
@RequestMapping("/spittles")
public class SpittleController {
private SpittleRepository spittleRepository;
@Autowired
public SpittleController(SpittleRepository spittleRepository) {
this.spittleRepository = spittleRepository;
}
@RequestMapping(method = RequestMethod.GET)
public String spittles(Model model) {
model.addAttribute(spittleRepository.findSpittles(Long.MAX_VALUE, 20));
return "spittles";
}
}
可以看到SpittleController有一個構造器,這個構造器使用了@Autowired註解,用來注入SpittleRepository。這個SpittleRepository又用在spittles()方法中,用來獲取最新的spittle列表。
在spittles()方法中給定了一個Model作為引數。這樣,spittles()方法就能將Repository中獲取到的Spittle列表填充到模型中。Model實際上就是一個Map(也就是key-value對的集合),它會傳遞給檢視,這樣資料就能渲染到客戶端了。當呼叫addAttribute()方法並且不指定key的時候,那麼key會根據值的物件型別推斷確定。在本例中,因為它是一個List<Spittle>,因此,鍵將會推斷為spittleList。
spittles()方法所做的最後一件事是返回spittles作為檢視的名字,這個檢視會渲染模型。
如果希望顯式宣告模型的key的話,也可以這樣進行指定:
@RequestMapping(method = RequestMethod.GET)
public String spittles(Model model) {
model.addAttribute("spittleList",
spittleRepository.findSpittles(Long.MAX_VALUE, 20));
return "spittles";
}
如果希望使用非Spring型別,可以用java.util.Map來代替Model:
@RequestMapping(method = RequestMethod.GET)
public String spittles(Map model) {
model.put("spittleList",
spittleRepository.findSpittles(Long.MAX_VALUE, 20));
return "spittles";
}
還有一種編寫spittles()的方式:
@RequestMapping(method = RequestMethod.GET)
public List<Spittles> spittles() {
return spittleRepository.findSpittles(Long.MAX_COUNT, 20);
}
這個版本與其他的版本有些差別。它並沒有返回檢視名稱,也沒有顯式地設定模型,這個方法返回的是Spittle列表。當處理器方法像這樣返回物件或集合時,這個值會放到模型中,模型的key會根據其型別推斷得出(在本例中,也就是spittleList)。
而邏輯檢視的名稱將會根據請求路徑推斷得出。因為這個方法處理針對“/spittles”的GET請求,因此檢視的名稱將會是spittles。
不管選擇哪種方式來編寫spittles()方法,所得到的結果都是相同的。模型中會儲存一個Spittle列表,key為spittleList,然後這個列表會傳送到名為spittles的檢視中。按照我們配置InternalResourceViewResolver的方式,檢視的JSP將會是“/WEB-INF/views/spittles.jsp”。
現在,資料已經放到了模型中,在JSP中該如何訪問它呢?實際上,
當檢視是JSP的時候,模型資料會作為請求屬性放到請求(request)
之中。因此,在spittles.jsp檔案中可以使用JSTL(JavaServer Pages
Standard Tag Library)的<c:forEach>標籤渲染spittle列表:
<c:forEach items="${spittleList}" var="spittle" >
<li id="spittle_<c:out value="spittle.id"/>">
<div class="spittleMessage"><c:out value="${spittle.message}" /></div>
<div>
<span class="spittleTime"><c:out value="${spittle.time}" /></span>
<span class="spittleLocation">(<c:out value="${spittle.latitude}" />, <c:out value="${spittle.longitude}" />)</span>
</div>
</li>
</c:forEach>
執行結果如圖,由於沒有初始化資料,因此結果列表為空:
接受請求的輸入
儘管SpittleController很簡單,但是它依然比HomeController更進一步了。但沒有處理任何形式的輸入。現在,我們要擴充套件SpittleController,讓它從客戶端接受一些輸入。
Spring MVC允許以多種方式將客戶端中的資料傳送到控制器的處理器方法中,包括:
- 查詢引數(Query Parameter)。
- 表單引數(Form Parameter)。
- 路徑變數(Path Variable)。
處理查詢引數
帶有查詢引數的請求,這也是客戶端往伺服器端傳送資料時,最簡單和最直接的方式。
在Spittr應用中,我們可能需要處理的一件事就是展現分頁的Spittle列表。在現在的SpittleController中,它只能展現最新的Spittle,沒有辦法向前翻頁檢視以前編寫的Spittle歷史記錄。如果想讓使用者每次都能檢視某一頁的Spittle歷史,那麼就需要提供一種方式讓使用者傳遞引數進來,進而確定要展現哪些Spittle集合。
假設要檢視某一頁Spittle列表,這個列表會按照最新的Spittle在前的方式進行排序。因此,下一頁中第一條的ID肯定會早於當前頁最後一條的ID。所以,為了顯示下一頁的Spittle,我們需要將一個Spittle的ID傳入進來,這個ID要恰好小於當前頁最後一條Spittle的ID。另外,還可以傳入一個引數來確定要展現的Spittle數量。
為了實現這個分頁的功能,編寫的處理器方法要接受如下的引數:
- before引數(表明結果中所有Spittle的ID均應該在這個值之前)。
- count引數(表明在結果中要包含的Spittle數量)。
首先新增一個測試,這個測試反映了新spittles()方法的功能。
@Test
public void shouldShowPagedSpittles() throws Exception {
List<Spittle> expectedSpittles = createSpittleList(50);
SpittleRepository mockRepository = mock(SpittleRepository.class);
when(mockRepository.findSpittles(238900, 50))
.thenReturn(expectedSpittles);
SpittleController controller = new SpittleController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller)
.setSingleView(new InternalResourceView("/WEB-INF/views/spittles.jsp"))
.build();
mockMvc.perform(get("/spittles?max=238900&count=50"))
.andExpect(view().name("spittles"))
.andExpect(model().attributeExists("spittleList"))
.andExpect(model().attribute("spittleList",
hasItems(expectedSpittles.toArray())));
}
測試方法關鍵針對“/spittles”傳送GET請求,同時還傳入了max和count引數。它測試了這些引數存在時的處理器方法,而另一個測試方法則測試了沒有這些引數時的情景。這兩個測試就緒後,我們就能確保不管控制器發生什麼樣的變化,它都能夠處理這兩種型別的請求:
@RequestMapping(method=RequestMethod.GET)
public List<Spittle> spittles(
@RequestParam(value="max") long max,
@RequestParam(value="count") int count) {
return spittleRepository.findSpittles(max, count);
}
SpittleController中的處理器方法要同時處理有引數和沒有引數的場景,需要對其進行修改,讓它能接受引數,同時,如果這些引數在請求中不存在的話,就使用預設值Long.MAX_VALUE和20。@RequestParam註解的defaultValue屬性可以完成這項任務:
@RequestMapping(method=RequestMethod.GET)
public List<Spittle> spittles(
@RequestParam(value="max", defaultValue=MAX_LONG_AS_STRING) long max,
@RequestParam(value="count", defaultValue="20") int count) {
return spittleRepository.findSpittles(max, count);
}
現在,如果max引數沒有指定的話,它將會是Long型別的最大值。因為查詢引數都是String型別的,因此defaultValue屬性需要String型別的值。因此,使用Long.MAX_VALUE是不行的。我們可以將Long.MAX_VALUE轉換為名為MAX_LONG_-AS_STRING的String型別常量:
private static final String MAX_LONG_AS_STRING =
Long.toString(Long.MAX_VALUE);
儘管defaultValue屬性給定的是String型別的值,但是當繫結到
方法的max引數時,它會轉換為Long型別。
如果請求中沒有count引數,count引數會被設為預設值20。
通過路徑引數接受輸入
在構建面向資源的控制器時,這種方式就是將傳遞引數作為請求路徑的一部分,實現資訊的輸入。
假設應用程式需要根據給定的ID來展現某一個Spittle記錄。其中一種方案就是編寫處理器方法,通過使用@RequestParam註解,讓它接受ID作為查詢引數:
@RequestMapping(value="/show", method=RequestMethod.GET)
public String showSpittle(
@RequestParam("spittle_id") long spittleId,
Model model) {
model.addAttribute(spittleRepository.findOne(spittleId));
return "spittle";
}
這個處理器方法將會處理形如“/spittles/show?spittle_id=12345”這樣的請求。儘管這也可以正常工作,但是從面向資源的角度來看這並不理想。理想情況下,要識別的資源(Spittle)應該通過URL路徑進行標示,而不是通過查詢引數。對“/spittles/12345”發起GET請求要優於對“/spittles/show?spittle_id=12345”發起請求。前者能夠識別出要查詢的資源,而後者描述的是帶有引數的一個操作——本質上是通過HTTP發起的RPC。
現在將這個需求轉換為一個測試:
@Test
public void testSpittle() throws Exception {
Spittle expectedSpittle = new Spittle("Hello", new Date());
SpittleRepository mockRepository = mock(SpittleRepository.class);
when(mockRepository.findOne(12345)).thenReturn(expectedSpittle);
SpittleController controller = new SpittleController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(get("/spittles/12345"))
.andExpect(view().name("spittle"))
.andExpect(model().attributeExists("spittle"))
.andExpect(model().attribute("spittle", expectedSpittle));
}
這個測試構建了一個mock Repository、一個控制器和MockMvc,這與本章中我們所編寫的其他測試很類似。這個測試的最後幾行,它對“/spittles/12345”發起GET請求,然後斷言檢視的名稱是spittle,並且預期的Spittle物件放到了模型之中。由於我們還沒有為這種請求實現處理器方法,因此這個請求將會失敗。但是,可以通過為SpittleController新增新的方法來修正這個失敗的測試。
到目前為止,在我們編寫的控制器中,所有的方法都對映到了(通過@RequestMapping)靜態定義好的路徑上。但是,如果想讓這個測試通過的話,我們編寫的@RequestMapping要包含變數部分,這部分代表了Spittle ID。
為了實現這種路徑變數,Spring MVC允許我們在@RequestMapping路徑中新增佔位符。佔位符的名稱要用大括號(“{”和“}”)括起來。路徑中的其他部分要與所處理的請求完全匹配,但是佔位符部分可以是任意的值。
下面的處理器方法使用了佔位符,將Spittle ID作為路徑的一部分:
@RequestMapping(value="/{spittleId}", method=RequestMethod.GET)
public String spittle(
@PathVariable("spittleId") long spittleId,
Model model) {
model.addAttribute(spittleRepository.findOne(spittleId));
return "spittle";
}
spittle()方法的spittleId引數上新增了@PathVariable("spittleId")註解,這表明在請求路徑中,不管佔位符部分的值是什麼都會傳遞到處理器方法的spittleId引數中。如果對“/spittles/54321”傳送GET請求,那麼將會把“54321”傳遞進來,作為spittleId的值。
因為方法的引數名碰巧與佔位符的名稱相同,因此可以去掉@PathVariable中的value屬性:
@RequestMapping(value="/{spittleId}", method=RequestMethod.GET)
public String spittle(
@PathVariable long spittleId, Model model) {
model.addAttribute(spittleRepository.findOne(spittleId));
return "spittle";
}
如果@PathVariable中沒有value屬性,它會假設佔位符的名稱與方法的引數名相同。這能夠讓程式碼稍微簡潔一些。但如果你想要重新命名引數時,必須要同時修改佔位符的名稱,使其互相匹配。
spittle()方法會將引數傳遞到SpittleRepository的findOne()方法中,用來獲取某個Spittle物件,然後將Spittle物件新增到模型中。模型的key將會是spittle,這是根據傳遞到addAttribute()方法中的型別推斷得到的。
這樣Spittle物件中的資料就可以渲染到檢視中了,此時需要引用請求中key為spittle的屬性(與模型的key一致)。渲染Spittle的JSP檢視片段如下:
<div class="spittleView">
<div class="spittleMessage"><c:out value="${spittle.message}" /></div>
<div>
<span class="spittleTime"><c:out value="${spittle.time}" /></span>
</div>
</div>
如果傳遞請求中少量的資料,查詢引數和路徑變數是很合適的。但通常還需要傳遞很多的資料(也許是表單提交的資料),那查詢引數顯得有些笨拙和受限了。
處理表單
Web應用的功能通常並不侷限於為使用者推送內容。大多數的應用允許使用者填充表單並將資料提交回應用中,通過這種方式實現與使用者的互動。像提供內容一樣,Spring MVC的控制器也為表單處理提供了良好的支援。
使用表單分為兩個方面:展現表單以及處理使用者通過表單提交的資料。在Spittr應用中,我們需要有個表單讓新使用者進行註冊。SpitterController是一個新的控制器,目前只有一個請求處理的方法來展現登錄檔單。
package spittr.web;
import static org.springframework.web.bind.annotation.RequestMethod.*;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/spitter")
public class SpitterController {
@RequestMapping(value = "/register", method = GET)
public String showRegistrationForm() {
return "registerForm";
}
}
showRegistrationForm()方法的@RequestMapping註解以及類級別上的@RequestMapping註解組合起來,宣告瞭這個方法要處理的是針對“/spitter/register”的GET請求。按照配置InternalResourceViewResolver,這意味著會使用“/WEB-INF/ views/registerForm.jsp”這個JSP來渲染登錄檔單。
儘管showRegistrationForm()方法非常簡單,但測試依然需要覆蓋到它:
package spittr.web;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.Test;
import org.springframework.test.web.servlet.MockMvc;
public class SpitterControllerTest {
@Test
public void shouldShowRegistration() throws Exception {
SpitterRepository mockRepository = mock(SpitterRepository.class);
SpitterController controller = new SpitterController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(get("/spitter/register"))
.andExpect(view().name("registerForm"));
}
對“/spitter/register”傳送GET請求,然後斷言結果的檢視名為registerForm。
因為檢視的名稱為registerForm,所以JSP的名稱需要為registerForm.jsp。這個JSP必須要包含一個HTML<form>標籤,在這個標籤中使用者輸入註冊應用的資訊:
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page session="false" %>
<html>
<head>
<title>Spitter</title>
<link rel="stylesheet" type="text/css"
href="<c:url value="/resources/style.css" />" >
</head>
<body>
<h1>Register</h1>
<form method="POST">
First Name: <input type="text" name="firstName" /><br/>
Last Name: <input type="text" name="lastName" /><br/>
Email: <input type="email" name="email" /><br/>
Username: <input type="text" name="username" /><br/>
Password: <input type="password" name="password" /><br/>
<input type="submit" value="Register" />
</form>
</body>
</html>
表單域中記錄使用者的名字、姓氏、使用者名稱以及密碼,還包含一個提交表單的按鈕。在瀏覽器渲染之後,它的樣子如下所示。
這裡的<form>標籤中並沒有設定action屬性。在這種情況下,當表單提交時,會提交到與展現時相同的URL路徑上。即提交到“/spitter/register”上。
意味著要在伺服器端處理該HTTP POST請求。需要在Spitter-Controller中再新增一個方法來處理這個表單提交。
編寫處理表單的控制器
當處理登錄檔單的POST請求時,控制器需要接受表單資料並將表單資料儲存為Spitter物件。為了防止重複提交,應該將瀏覽器重定向到新建立使用者的基本資訊頁面。這些行為通過下面的shouldProcessRegistration()進行了測試:
@Test
public void shouProcessRegistration() throws Exception{
SpitterRepository mockRepository = mock(SpitterRepository.class);
Spitter unsaved = new Spitter("jbauer", "24hours", "Jack", "Bauer", "jbauer@ctu.gov");
Spitter saved = new Spitter(24L, "jbauer", "24hours", "Jack", "Bauer", "jbauer@ctu.gov");
when(mockRepository.save(unsaved)).thenReturn(saved);
SpitterController controller = new SpitterController(mockRepository);
MockMvc mockMvc = standaloneSetup(controller).build();
mockMvc.perform(post("/spitter/register")
.param("firstName", "Jack")
.param("lastName", "Bauer")
.param("username", "jbauer")
.param("password", "24hours")
.param("email", "jbauer@ctu.gov"))
.andExpect(redirectedUrl("/spitter/jbauer"));
verify(mockRepository, atLeastOnce()).save(unsaved);
}
在構建完SpitterRepository的mock實現以及所要執行的控制器和MockMvc之後,shouldProcess-Registration()對“/spitter/register”發起了一個POST請求。作為請求的一部分,使用者資訊以引數的形式放到request中,從而模擬提交的表單。
在處理POST型別的請求時,在請求處理完成後,最好進行一下重定向,這樣瀏覽器的重新整理就不會重複提交表單了。在這個測試中,預期請求會重定到“/spitter/jbauer”,也就是新建使用者的基本資訊頁面。
最後,測試會校驗SpitterRepository的mock實現最終會真正用來儲存表單上傳入的資料。
通過shouldProcessRegistration()方法實現處理表單提交的控制器方法:
package spittr.web;
import static org.springframework.web.bind.annotation.RequestMethod.*;
import javax.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import spittr.Spitter;
import spittr.data.SpitterRepository;
@Controller
@RequestMapping("/spitter")
public class SpitterController {
private SpitterRepository spitterRepository;
@Autowired
public SpitterController(SpitterRepository spitterRepository) {
this.spitterRepository = spitterRepository;
}
@RequestMapping(value="/register", method=GET)
public String showRegistrationForm() {
return "registerForm";
}
@RequestMapping(value="/register", method=POST)
public String processRegistration(
@Valid Spitter spitter,
Errors errors) {
if (errors.hasErrors()) {
return "registerForm";
}
spitterRepository.save(spitter);
return "redirect:/spitter/" + spitter.getUsername();
}
@RequestMapping(value="/{username}", method=GET)
public String showSpitterProfile(@PathVariable String username, Model model) {
Spitter spitter = spitterRepository.findByUsername(username);
model.addAttribute(spitter);
return "profile";
}
}
新建立的processRegistration()方法,它接受一個Spitter物件作為引數。這個物件有firstName、lastName、username和password屬性,這些屬性將會使用請求中同名的引數進行填充。
當使用Spitter物件呼叫processRegistration()方法時,它會呼叫SpitterRepository的save()方法,SpitterRepository是在SpitterController的構造器中注入進來的。
processRegistration()方法最後返回一個String型別,用來指定檢視。但是這個檢視格式和以前我們所看到的檢視有所不同。不僅返回了檢視的名稱供檢視解析器查詢目標檢視,返回的值還帶有重定向的格式。
當InternalResourceViewResolver看到檢視格式中的“redirect:”字首時,它就知道要將其解析為重定向的規則,而不是檢視的名稱。
本例中,它將會重定向到使用者基本資訊的頁面。
除了“redirect:”,InternalResourceViewResolver還能識別“forward:”字首。當它發現檢視格式中以“forward:”作為字首時,請求將會前往(forward)指定的URL路徑,而不是重定向。
儘管HttpServletResponse.sendRedirect方法和RequestDispatcher.forward方法都可以讓瀏覽器獲 得另外一個URL所指向的資源,但兩者的內部執行機制有著很大的區別。
Forward和Redirect代表了兩種請求轉發方式:直接轉發和間接轉發。
- 直接轉發方式(Forward),客戶端和瀏覽器只發出一次請求,Servlet、HTML、JSP或其它資訊資源,由第二個資訊資源響應該請求,在請求物件request中,儲存的物件對於每個資訊資源是共享的。轉發頁面和轉發到的頁面可以共享request的資料。
- 間接轉發方式(Redirect)實際是兩次HTTP請求,伺服器端在響應第一次請求的時候,讓瀏覽器再向另外一個URL發出請求,從而達到轉發的目的。轉發頁面和轉發到的頁面不能共享request的資料。
因為我們重定向到了使用者基本資訊頁面,那麼我們應該往SpitterController中新增一個處理器方法showSpitterProfile(),用來處理對基本資訊頁面的請求:
@RequestMapping(value="/{username}", method=GET)
public String showSpitterProfile(@PathVariable String username, Model model) {
Spitter spitter = spitterRepository.findByUsername(username);
model.addAttribute(spitter);
return "profile";
}
SpitterRepository通過使用者名稱獲取一個Spitter物件,showSpitter-Profile()得到這個物件並將其新增到模型中,然後返回profile,也就是基本資訊頁面的邏輯檢視名。基本資訊檢視實現如下:
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page session="false" %>
<html>
<head>
<title>Spitter</title>
<link rel="stylesheet" type="text/css" href="<c:url value="/resources/style.css" />" >
</head>
<body>
<h1>Your Profile</h1>
<c:out value="${spitter.username}" /><br/>
<c:out value="${spitter.firstName}" /> <c:out value="${spitter.lastName}" /><br/>
<c:out value="${spitter.email}" />
</body>
</html>
基本資訊檢視渲染效果如下圖所示:
如果表單中沒有傳送username或password的話,如果firstName或lastName的值為空或太長的話,程式會出現問題,為此需要為表單提交新增校驗,避免資料呈現的不一致性。
校驗表單
如果使用者在提交表單的時候,username或password文字域為空的話,會導致在新建Spitter物件中,username或password是空的String。如果這種現象不處理的話,這將會出現安全問題,不管是誰只要提交一個空的表單就能登入應用。
同時應該阻止使用者提交空的firstName和/或lastName,使應用僅在一定程度上保持匿名性。可以限制這些輸入域值的長度,保持它們的值在一個合理的長度範圍,避免這些輸入域的誤用。
從Spring 3.0開始,在Spring MVC中提供了對Java校驗API的支援。在Spring MVC中使用Java校驗API,不需要什麼額外的配置。只要保證在類路徑下包含這個Java API的實現即可,比如Hibernate Validator。
Java校驗API定義了多個註解,這些註解可以放到屬性上,從而限制這些屬性的值。所有的註解都位於javax.validation.constraints包中。下表列出了這些校驗註解:
除了表中的註解,Java校驗API的實現可能還會提供額外的校驗註解。同時,也可以定義自己的限制條件。
修改Spitter類,為屬性新增校驗註解:
package spittr;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
public class Spitter {
private Long id;
@NotNull
@Size(min=5, max=16)
private String username;
@NotNull
@Size(min=5, max=25)
private String password;
@NotNull
@Size(min=2, max=30)
private String firstName;
@NotNull
@Size(min=2, max=30)
private String lastName;
. . .
}
現在,Spitter的所有屬性都新增了@NotNull註解,以確保它們的值不為null。屬性上也新增了@Size註解以限制它們的長度在最大值和最小值之間。對Spittr應用來說,這意味著使用者必須要填完登錄檔單,並且值的長度要在給定的範圍內。
接下來要修改processRegistration()方法來應用校驗功能。啟用校驗功能的processRegistration()如下所示:
@RequestMapping(value="/register", method=POST)
public String processRegistration(
@Valid Spitter spitter,
Errors errors) {
if (errors.hasErrors()) {
return "registerForm";
}
spitterRepository.save(spitter);
return "redirect:/spitter/" + spitter.getUsername();
}
Spitter引數新增了@Valid註解,這會告知Spring,需要確保這個物件滿足校驗限制。
在Spitter屬性上新增校驗限制並不能阻止表單提交。即便使用者輸入不符合規範
,processRegistration()方法依然會被呼叫。
如果有校驗出現錯誤的話,那麼這些錯誤可以通過Errors物件進行訪問,現在這個物件作為processRegistration()方法的引數。(需要注意,Errors引數要緊跟在帶有@Valid註解的引數後面。)
processRegistration()方法所做的第一件事就是呼叫Errors.hasErrors()來檢查是否有錯誤。如果有錯誤的話,Errors.hasErrors()將會返回到registerForm,也就是登錄檔單的檢視。這能夠讓使用者的瀏覽器重新回到登錄檔單頁面,所以他們能夠修正錯誤,然後重新嘗試提交。
如果沒有錯誤的話,Spitter物件將會通過Repository進行儲存,重定向到基本資訊頁面。
小結
Spring有一個強大靈活的Web框架。藉助於註解,Spring MVC提供了近似於POJO的開發模式,這使得開發處理請求的控制器變得非常簡單,同時也易於測試。
當編寫控制器的處理器方法時,Spring MVC極其靈活。如果你的處理器方法需要內容的話,只需將對應的物件作為引數,而它不需要的內容,則沒有必要出現在引數列表中。這樣,就為請求處理帶來了無限的可能性,同時還能保持一種簡單的程式設計模型。
相關文章
- 五、Spring Web應用程式構建SpringWeb
- Spring系列(六) Spring Web MVC 應用構建分析SpringWebMVC
- 使用Java和Spring MVC構建Web應用JavaSpringMVCWeb
- Spring MVC 構建入門級 Web 應用程式SpringMVCWeb
- 使用 Spring Boot 快速構建 Spring 框架應用Spring Boot框架
- Spring Boot實戰:Restful API的構建Spring BootRESTAPI
- Webpack實戰-構建同構應用Web
- Spring Boot應用監控實戰Spring Boot
- 【Spring 5】響應式Web框架實戰(上)SpringWeb框架
- 【Spring 5】響應式Web框架實戰(下)SpringWeb框架
- Webpack實戰-構建 Electron 應用Web
- Spring Boot 中 10 行程式碼構建 RESTful 風格應用Spring Boot行程REST
- Webpack實戰-構建離線應用Web
- 深入淺出Spring Web MVC:從零開始構建你的第一個Web應用SpringWebMVC
- 用Spring Web Flow和Terracotta搭建Web應用SpringWeb
- 設定Jenkins伺服器構建Spring Boot應用程式 - MarcusJenkins伺服器Spring Boot
- 使用JHipster構建Spring和React構建電子商務應用程式原始碼 -DEVSpringReact原始碼dev
- 使用Rust和WebAssembly構建Web應用程式RustWeb
- 使用 Go-Spring 構建最小 Web APIGoSpringWebAPI
- 使用JWT保護你的Spring Boot應用 - Spring Security實戰JWTSpring Boot
- Spring MVC之基於java config無xml配置的web應用構建SpringMVCJavaXMLWeb
- 使用Spring ViewComponent + htmx構建SpringBoot應用ViewSpring Boot
- 如何用 Spring AI + Ollama 構建生成式 AI 應用SpringAI
- 構建單頁Web應用Web
- 【Spring實戰】—— 2 構造注入Spring
- Spring Boot Serverless 實戰系列“部署篇” | Mall 應用Spring BootServer
- 八、【spring】web應用安全設計SpringWeb
- Spring Web 應用的最大敗筆SpringWeb
- Vue 2.0 構建單頁應用最佳實戰Vue
- Spring Boot Web應用程式下載Excel檔案 - simplesolutionSpring BootWebExcel
- 實戰Spring Boot 2.0系列(一) – 使用Gradle構建Docker映象Spring BootGradleDocker
- 實戰Spring Boot 2.0系列(一) - 使用Gradle構建Docker映象Spring BootGradleDocker
- Spring-Web-Flux實戰(三) - Stream 流SpringWebUX
- 使用Golang快速構建WEB應用GolangWeb
- Django與前端框架協作開發實戰:高效構建現代Web應用Django前端框架Web
- Spring 3.x 企業應用開發實戰Spring
- 構建一個基於 Spring 的 RESTful Web ServiceSpringRESTWeb
- Spring AOT應用實踐Spring