翻譯-使用Ratpack和Spring Boot打造高效能的JVM微服務應用

黃博文發表於2016-02-23

這是我為InfoQ翻譯的文章,原文地址:Build High Performance JVM Microservices with Ratpack & Spring Boot,InfoQ上的中文地址:使用Ratpack與Spring Boot構建高效能JVM微服務

在微服務天堂中Ratpack和Spring Boot是天造地設的一對。它們都是以開發者為中心的執行於JVM之上的web框架,側重於生產率、效率以及輕量級部署。他們在服務程式的開發中帶來了各自的好處。Ratpack通過一個高吞吐量、非阻塞式的web層提供了一個反應式程式設計模型,而且對應用程式結構的定義和HTTP請求過程提供了一個便利的處理程式鏈;Spring Boot整合了整個Spring生態系統,為應用程式提供了一種簡單的方式來配置和啟用元件。Ratpack和Spring Boot是構建原生支援計算雲的基於資料驅動的微服務的不二選擇。

Ratpack並不關心應用程式底層使用了什麼樣的依賴注入框架。相反,應用程式可以通過Ratpack提供的DI抽象(被稱為Registry)訪問服務層元件。Ratpack的Registry是構成其基礎設施的一部分,其提供了一個介面,DI提供者可以使用註冊器回撥(registry backing)機制來參與到元件解決方案序列中。

Ratpack直接為Guice和Spring Boot提供了註冊器回撥機制,開發人員可以為應用程式靈活選擇使用的依賴注入框架。

在本文中我們將演示使用Ratpack和Spring Boot構建一個RESTful風格的基於資料驅動的微服務,背後使用了Spring Data用於運算元據。

開始構建Ratpack專案的最佳方式是建立Gradle指令碼以及標準的Java專案結構。Gradle是Ratpack原生支援的構建系統,其實由於Ratpack只是一組簡單的JVM庫,所以其實它適用於任何構建系統(不管你的需求有多特別)。如果你還未安裝Gradle,那麼安裝它最佳方式是通過Groovy enVironment Manager工具。示例專案的構建指令碼如列表1所示。

列表1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
buildscript {
  repositories {
    jcenter()
  }
  dependencies {
    classpath 'io.ratpack:ratpack-gradle:0.9.18'
  }
}

apply plugin: 'io.ratpack.ratpack-java'
apply plugin: 'idea'
apply plugin: 'eclipse'

repositories {
  jcenter()
}

dependencies {
  compile ratpack.dependency('spring-boot') (1)
}

mainClassName = "springpack.Main" (2)

eclipse {
  classpath {
    containers.remove('org.eclipse.jdt.launching.JRE_CONTAINER')
    containers 'org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8'
  }
}

在(1)部分中,構建指令碼通過呼叫Ratpack Gradle外掛的ratpack.dependency(..)方法引入了Ratpack和Spring Boot的整合。根據構建指令碼和當前專案結構,我們可以建立一個“主類”(main class),其作為可執行的類來啟動和執行應用程式。注意(2)中我們指定了主類的名稱,所以使用命令列工具時會更簡練。這意味著實際的主類名必須與之一致,所以需要在本專案的src/main/java目錄中建立一個名為springpack.Main的類。

在主類中,我們通過工廠方法構造了RatpackServer的一個例項,在start方法中提供了對應用程式的定義。該定義中我們編寫了RESTful API處理器鏈。請參見列表2中對Main類的演示。注意Ratpack要求的編譯環境為Java 8。

列表2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package springpack;

import ratpack.server.RatpackServer;

public class Main {

  public static void main(String[] args) throws Exception {
    RatpackServer.start(spec -> spec
      .handlers(chain -> chain (1)
          .prefix("api", pchain -> pchain (2)
            .all(ctx -> ctx (3)
              .byMethod(method -> method (4)
                .get(() -> ctx.render("Received GET request"))
                .post(() -> ctx.render("Received POST request"))
                .put(() -> ctx.render("Received PUT request"))
                .delete(() -> ctx.render("Received DELETE request"))
              )
            )
          )
      )
    );
  }

}

如果我們仔細剖析主類中的應用程式定義,我們可以識別出一些關鍵知識點,對於不熟悉Ratpack的人來說,我們需要對這些知識點做進一步解釋。第一個值得注意的點在(1)中處理器區域定義了一個處理器鏈,該處理器鏈用於處理Ratpack流中的HTTP請求。通過鏈式定義的處理器描述了它們能夠處理的請求型別。特別在(2)中我們定義了一個字首處理器型別,指定它被繫結到“api”這個HTTP路由。字首處理器建立了一個新的處理器鏈,用來處理匹配”/api” 埠(endpoint)到來的請求。在(3)處我們使用了所有的處理器型別來指定所有到來的請求應該執行在我們提供的處理器中,在(4)處我們使用Ratpack的byMethod機制來將get,post,put和delete處理器繫結到到各自的HTTP方法中。

在專案根目錄下,我們可以通過命令列簡單使用gradle的“run”命令執行該應用程式。這會啟動web伺服器並繫結到埠5050。為了演示當前專案的功能,確保處理器結構工作正常,我們可以在命令列中通過curl執行一些測試:

可以看到,應用程式處理器鏈可以正確地路由請求,我們建立了RESTful API的結構。接下來需要改善這些API…

為了演示的緣故,讓我們儘量保持簡單,改造該微服務以便可以對一個User領域物件進行CRUD操作。通過REST介面,客戶可以做以下事情:

  • 通過一個GET請求來請求指定的使用者賬號,使用者名稱作為路徑變數(path variable);
  • GET請求中如果未指定使用者名稱,則列出所有的使用者;
  • 通過POST一個JSON格式的使用者物件來建立一個使用者;
  • 使用PUT請求,使用者名稱作為路徑變數來更新該使用者的郵件地址;
  • 使用DELETE請求,使用者名稱作為路徑變數來刪除該使用者。

在之前小節中我們定義的處理器已經包含了大多數處理這種需求的基礎設施。但根據需求我們還需要做細微調整。例如,我們現在需要繫結處理器接收使用者名稱作為路徑變數。列表3中是更新後的程式碼,主類中的處理器可以滿足現在的需求。

列表3

1
2
3
4
5
6
7
8
9
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
35
36
37
package springpack;

import ratpack.server.RatpackServer;

public class Main {

  public static void main(String[] args) throws Exception {
    RatpackServer.start(spec -> spec
      .handlers(chain -> chain
        .prefix("api/users", pchain -> pchain (1)
          .prefix(":username", uchain -> uchain (2)
            .all(ctx -> { (3)
              String username = ctx.getPathTokens().get("username");
              ctx.byMethod(method -> method (4)
                .get(() -> ctx.render("Received request for user: " + username))
                                              .put(() -> {
                  String json = ctx.getRequest().getBody().getText();
                  ctx.render("Received update request for user: " + username + ", JSON: " + json);
                })
                .delete(() -> ctx.render("Received delete request for user: " + username))
              );
            })
          )
          .all(ctx -> ctx (5)
            .byMethod(method -> method
              .post(() -> { (6)
                String json = ctx.getRequest().getBody().getText();
                ctx.render("Received request to create a new user with JSON: " + json);
              })
              .get(() -> ctx.render("Received request to list all users")) (7)
            )
          )
        )
      )
    );
  }
}

重新構造後的API遵循了面向資源的模式,圍繞著user領域物件為中心。以下是一些修改點:

  • 在(1)中我們修改了入口級字首為/api/users;
  • 在(2)中我們繫結了一個新的字首處理器到:username路徑變數上。任何到來的請求路徑中的值會被轉換,並且Ratpack處理器可以通過ctx.getPathTokens()中的表來訪問該值。
  • 在(3)中我們為所有匹配/api/users/:username URI模式的請求繫結一個處理器; *(4)中我們使用byMethod機制來為HTTP GET,PUT和DELETE方法繫結處理器。通過這些處理器我們可以瞭解客戶端對指定使用者的操作意圖。在PUT處理器中,我們呼叫ctx.getRequest().getBody().getText()方法來捕獲到來的請求中的JSON資料;
  • 在(5)中我們附加一個處理器來匹配所有從/api/users埠到來的請求;
  • 在(6)中我們對/api/users處理器使用byMethod機制來附加一個POST處理器,當建立新使用者時該POST處理器會被呼叫。這裡又一次從到來的請求中取出JSON資料;
  • 最後在(7)中,我們附加了一個GET處理器,當客戶端需要所有使用者的列表時可以呼叫它。

再次啟動該應用程式並進行一系列curl命令列呼叫,來測試這些埠操作是否符合預期:

現在我們擁有了滿足需求的API的基礎框架,但仍需使其更加有用。我們可以開始設定服務層的依賴。在本例中,我們將使用Spring Data JPA元件作為資料訪問物件;列表4展示了對構建指令碼的修改。

列表4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
buildscript {
  repositories {
    jcenter()
  }
  dependencies {
    classpath 'io.ratpack:ratpack-gradle:0.9.18'
  }
}

apply plugin: 'io.ratpack.ratpack-java'
apply plugin: 'idea'
apply plugin: 'eclipse'

repositories {
  jcenter()
}

dependencies {
  compile ratpack.dependency('spring-boot')
  compile 'org.springframework.boot:spring-boot-starter-data-jpa:1.2.4.RELEASE' (1)
  compile 'com.h2database:h2:1.4.187' (2)
}

mainClassName = "springpack.Main"

eclipse {
  classpath {
    containers.remove('org.eclipse.jdt.launching.JRE_CONTAINER')
    containers 'org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8'
  }
}

在(1)中,我們引入了對Spring Boot Spring Data JPA的依賴,(2)中我們引入了H2嵌入式資料庫依賴。總共就這麼點修改。當在classpath中發現H2時,Spring Boot將自動配置Spring Data來使用它作為記憶體資料來源。通過該頁面可以詳細瞭解如何配置和使用Spring Data資料來源。

有了新的依賴後,我們必須做的第一件事是建模我們的微服務領域物件:User。User類為了演示的目的儘可能的簡單,列表5展示了一個正確建模的JPA領域實體。我們將其放置到專案的src/main/java/springpack/model/User.java類檔案中。

列表5

1
2
3
4
5
6
7
8
9
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
35
36
37
38
39
40
41
42
43
44
45
46
package springpack.model;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class User {
  private static final long serialVersionUID = 1l;

  @Id
  @GeneratedValue
  private Long id;

  @Column(nullable = false)
  private String username;

  @Column(nullable = false)
  private String email;

  public Long getId() {
    return id;
  }

  public void setId(Long id) {
    this.id = id;
  }

  public String getUsername() {
    return username;
  }

  public void setUsername(String username) {
    this.username = username;
  }

  public String getEmail() {
    return email;
  }

  public void setEmail(String email) {
    this.email = email;
  }

}

由於Spring Data已經處於該專案的編譯時需要的classpath中,所以我們可以使用javax.persistence.*註解。Spring Boot使用該註解可以實現與資料訪問物件一起設定及執行,所以我們可以使用Spring Data的腳手架功能中的Repository服務型別來模組化DAO。由於我們的API相對來說只是直接的CRUD操作,所以我們實現UserRepository DAO時,可以利用Spring Data提供的CrudRepository 固定層來編寫儘可能少的程式碼。

列表6

1
2
3
4
5
6
7
8
9
10
package springpack.model;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends CrudRepository<User, Long> {

  User findByUsername(String username); (1)
}

驚奇的是,列表6中展示的UesrRepository DAO實現短短一行程式碼已經對User領域物件實現了一個必要的完全成形的服務層。Spring Data提供的Repository介面允許基於我們對搜尋的實體的約定建立”helper”查詢方法。根據需求,我們知道API層需要通過使用者名稱查詢使用者,所以可以在(1)處新增findByUsername方法。我們把該UserRepository放置到專案中的/src/main/java/springpack/model/UserRepository.java類檔案中。

在修改API來使用UserRepository之前,我們首先必須定義Spring Boot 應用程式類。該類代表了一個配置入口,指向了Spring Boot自動配置引擎,並且可以構造一個Spring ApplicationContext,從而可以使用Ratpack應用程式中的註冊器回撥。列表7描述了該Spring Boot配置類。

列表7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package springpack;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class SpringBootConfig {

  @Bean
  ObjectMapper objectMapper() { (1)
    return new ObjectMapper();
  }
}

SpringBootConfig類中短小精悍的程式碼放置在src/main/java/springpack/SpringBootConfig.java類檔案中。在該類中我們顯式地自動配置了Jackson OjbectMapper的Spring bean。我們將在API層使用它來讀寫JSON資料。

@SpringBootApplication註解做了大部分事情。當初始化Spring Boot註冊器回撥時,該類會作為入口點。它的基礎設施將使用該註解來掃描classpath中任何可用的元件,並自動裝配這些元件到應用程式上下文中中,並且根據Spring Boot的約定規則來自動配置它們。例如,UserRepository類(使用了@Repository註解)存在於應用程式classpath中,所以Spring Boot將使用Spring Data引擎代理該介面,並配置其與H2嵌入式資料庫一塊工作,因為H2也在classpath中。藉助Spring Boot我們無需其它多餘的配置。

在實現API層之前我們需要做的另一個事情是構造Ratpack來使用Spring Boot應用程式作為註冊器。Ratpack的Spring Boot整合元件提供了一個固定層來無縫轉換Spring Boot應用程式為註冊器回撥程式,只需一行程式碼就可以合併這兩個世界。列表8中的程式碼展示了更新後的主類,這次使用SpringBootConfig類作為API層的註冊器。

列表8

1
2
3
4
5
6
7
8
9
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
35
36
37
38
39
40
package springpack;

import ratpack.server.RatpackServer;
import ratpack.spring.Spring;
import springpack.config.SpringBootConfig;

public class Main {

  public static void main(String[] args) throws Exception {
    RatpackServer.start(spec -> spec
      .registry(Spring.spring(SpringBootConfig.class)) (1)
      .handlers(chain -> chain
        .prefix("api/users", pchain -> pchain
          .prefix(":username", uchain -> uchain
            .all(ctx -> {
              String username = ctx.getPathTokens().get("username");
              ctx.byMethod(method -> method
                .get(() -> ctx.render("Received request for user: " + username))
                .put(() -> {
                  String json = ctx.getRequest().getBody().getText();
                  ctx.render("Received update request for user: " + username + ", JSON: " + json);
                })
                .delete(() -> ctx.render("Received delete request for user: " + username))
              );
            })
          )
          .all(ctx -> ctx
            .byMethod(method -> method
              .post(() -> {
                String json = ctx.getRequest().getBody().getText();
                ctx.render("Received request to create a new user with JSON: " + json);
              })
              .get(() -> ctx.render("Received request to list all users"))
            )
          )
        )
      )
    );
  }
}

唯一需要的修改是(1),我們通過一個顯式的Registry實現提供了對Ratpack應用程式的定義。現在我們可以開始實現API層。

如果你仔細觀察接下來的修改,就會理解Ratpack與傳統的基於servlet的web應用是完全不同的。之前我們提及過,Ratpack的HTTP層構建在非阻塞的網路介面上,該web框架天然支援高效能。而基於servlet的web應用會為每個到來的請求產生一個新的執行緒,雖然會降低資源利用率,但每個請求處理流時是隔離的。在這種機制下,web應用處理請求時會採用阻塞式的方式,比如呼叫資料庫並等待對應的結果然後返回,在等待期間(相對來說)並不關心這會影響它服務接下來的客戶端的能力。在非阻塞式的web應用中,如果客戶端或伺服器端不傳送資料,那麼網路層並不會被阻塞,所以執行緒池中少量的“請求任務”執行緒就可以服務大量高併發的請求。然而這意味著如果應用程式程式碼阻塞了一個“請求任務”執行緒,那麼吞吐量會顯著影響。因此,阻塞操作(比如對資料庫的操作)不能放置在請求執行緒中。

幸運的是,Ratpack通過在請求上下文中暴露一個阻塞介面來在應用程式中執行阻塞操作。該介面會把阻塞操作放置到另一個不同的執行緒池中,在維持高容量的情況服務新帶來的請求的同時,這些阻塞呼叫也可以同步完成。一旦阻塞呼叫完成,處理流會返回到“請求任務”執行緒中,應答會被寫回到客戶端。在我們構建的API層中,我們要確保所有對UserRepository的操作都被路由到阻塞固定層中。列表9展示了API層的實現。

列表9

1
2
3
4
5
6
7
8
9
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package springpack;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import ratpack.exec.Promise;
import ratpack.handling.Context;
import ratpack.server.RatpackServer;
import ratpack.spring.Spring;
import springpack.model.User;
import springpack.model.UserRepository;

import java.util.HashMap;
import java.util.Map;

public class Main {
  private static final Map<String, String> NOT_FOUND = new HashMap<String, String>() \{\{
    put("status", "404");
    put("message", "NOT FOUND");
  }};
  private static final Map<String, String> NO_EMAIL = new HashMap<String, String>() \{\{
    put("status", "400");
    put("message", "NO EMAIL ADDRESS SUPPLIED");
  }};

  public static void main(String[] args) throws Exception {
    RatpackServer.start(spec -> spec
      .registry(Spring.spring(SpringBootConfig.class))
      .handlers(chain -> chain
        .prefix("api/users", pchain -> pchain
          .prefix(":username", uchain -> uchain
            .all(ctx -> {
              // extract the "username" path variable
              String username = ctx.getPathTokens().get("username");
              // pull the UserRepository out of the registry
              UserRepository userRepository = ctx.get(UserRepository.class);
              // pull the Jackson ObjectMapper out of the registry
              ObjectMapper mapper = ctx.get(ObjectMapper.class);
              // construct a "promise" for the requested user object. This will
              // be subscribed to within the respective handlers, according to what
              // they must do. The promise uses the "blocking" fixture to ensure
              // the DB call doesn't take place on a "request taking" thread.
              Promise<User> userPromise = ctx.blocking(() -> userRepository.findByUsername(username));
              ctx.byMethod(method -> method
                .get(() ->
                  // the .then() block will "subscribe" to the result, allowing
                  // us to send the user domain object back to the client
                  userPromise.then(user -> sendUser(ctx, user))
                )
                .put(() -> {
                  // Read the JSON from the request
                  String json = ctx.getRequest().getBody().getText();
                  // Parse out the JSON body into a Map
                  Map<String, String> body = mapper.readValue(json, new TypeReference<Map<String, String>>() {
                  });
                  // Check to make sure the request body contained an "email" address
                  if (body.containsKey("email")) {
                    userPromise
                      // map the new email address on to the user entity
                      .map(user -> {
                        user.setEmail(body.get("email"));
                        return user;
                      })
                      // and use the blocking thread pool to save the updated details
                      .blockingMap(userRepository::save)
                      // finally, send the updated user entity back to the client
                      .then(u1 -> sendUser(ctx, u1));
                  } else {
                    // bad request; we didn't get an email address
                    ctx.getResponse().status(400);
                    ctx.getResponse().send(mapper.writeValueAsBytes(NO_EMAIL));
                  }
                })
                .delete(() ->
                  userPromise
                    // make the DB delete call in a blocking thread
                    .blockingMap(user -> {
                      userRepository.delete(user);
                      return null;
                    })
                    // then send a 204 back to the client
                    .then(user -> {
                      ctx.getResponse().status(204);
                      ctx.getResponse().send();
                    })
                )
              );
            })
          )
          .all(ctx -> {
            // pull the UserRepository out of the registry
            UserRepository userRepository = ctx.get(UserRepository.class);
            // pull the Jackson ObjectMapper out of the registry
            ObjectMapper mapper = ctx.get(ObjectMapper.class);
            ctx.byMethod(method -> method
              .post(() -> {
                // read the JSON request body...
                String json = ctx.getRequest().getBody().getText();
                // ... and convert it into a user entity
                User user = mapper.readValue(json, User.class);
                // save the user entity on a blocking thread and
                // render the user entity back to the client
                ctx.blocking(() -> userRepository.save(user))
                  .then(u1 -> sendUser(ctx, u1));
              })
              .get(() ->
                // make the DB call, on a blocking thread, to list all users
                ctx.blocking(userRepository::findAll)
                  // and render the user list back to the client
                  .then(users -> {
                    ctx.getResponse().contentType("application/json");
                    ctx.getResponse().send(mapper.writeValueAsBytes(users));
                  })
              )
            );
          })
        )
      )
    );
  }

  private static void notFound(Context context) {
    ObjectMapper mapper = context.get(ObjectMapper.class);
    context.getResponse().status(404);
    try {
      context.getResponse().send(mapper.writeValueAsBytes(NOT_FOUND));
    } catch (JsonProcessingException e) {
      context.getResponse().send();
    }
  }

  private static void sendUser(Context context, User user) {
    if (user == null) {
      notFound(context);
    }

    ObjectMapper mapper = context.get(ObjectMapper.class);
    context.getResponse().contentType("application/json");
    try {
      context.getResponse().send(mapper.writeValueAsBytes(user));
    } catch (JsonProcessingException e) {
      context.getResponse().status(500);
      context.getResponse().send("Error serializing user to JSON");
    }
  }
}

API層最值得關注的點是對阻塞機制的使用,這次阻塞操作可以從每個請求的Conext物件中抽取出來。當呼叫ctx.blocking()方法時,會返回一個Promise物件,我們必須訂閱該物件以便執行程式碼。我們可以抽取一個promise(在prefix(“:username”)中展示的一樣)從而在不同的處理器中重用,保持程式碼簡潔。

現在實現了API後,可以執行一系列curl測試來確保該微服務符合預期:

通過上面的命令序列可以看出API層工作完全正確,我們擁有了一個完全正式的資料驅動的基於Ratpack和Spring Boot的微服務,並且使用了Spring Data JPA!

整個過程的最後一步是部署。部署的最簡單方式是執行gradle installDist命令。這會打包應用程式以及整個執行時依賴到一個traball(.tar檔案)和zip(.zip檔案)存檔檔案中。它另外也會建立跨平臺的啟動指令碼,可以在任何安裝了Java 8的系統中啟動我們的微服務。當installDist任務完成後,可以在專案的build/distributions目錄中找到這些存檔檔案。

通過本文章你已經學會了如何利用Spring Boot提供的大量生態系統以及Ratpack提供的高效能特性來打造一個微服務應用程式。你可以使用該示例作為起點來構建JVM上原生支援雲的資料驅動的微服務程式。

歡迎使用Ratpack和Srping Boot!

關於作者

Daniel Woods醉心於企業級Java、Groovy以及Grails開發。他在JVM軟體開發領域擁有10餘年的工作經驗,並且樂於向開源專案(比如GrailsRatpack)貢獻他的經驗。Dan曾在Gr8conf和SpringOne 2GX大會上做過演講嘉賓,展示了他基於JVM的企業級應用程式架構的專業知識。

相關文章