文章出處

這是我為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的企業級應用程序架構的專業知識。


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()