Table of contents
1 簡介
1.1 寫 API 既難處
好多時候我地寫網頁或者 back-end API 會遇到一個情況,就係需要唔同既資料。而要同時得到唔同既資料,就需要通過某種方式將唔同既資料組合埋一齊,例如 front-end 分別 call 幾個 back-end APIs,然後 front-end 再分別處理唔同 API responses 既資料;或者,front-end call 一個 back-end API,但呢個 API 背後已經會將各種 front-end 需要既資料 consolidate 埋喺一個 response 裡面。
舉個例,一個客戶登入帳號之後、到達 front-end 既首頁(網頁或 mobile app)之前,front-end 可能需要 call back-end 一次或者多次去拎一大堆初始化用既資料,呢啲資料可以黎自唔同既 database tables,資料包括有用戶基本資料、帳號狀態及權限、通知,以及 front-end 既首頁需要顯示既各種資訊(產品、推廣資訊)。不過呢啲用戶資料以及首頁需要顯示既各種資訊都有可能喺其他頁面由 front-end 單獨咁向 back-end 查詢出黎。
如果要用單一個 API call 就攞到曬需要既資料,咁呢度就有一個通用性既問題,雖然 service layer 都仲可以做到通用,但 controller layer 就變左會有啲 APIs 係針對特定場景去將唔同既 services 既資料 consolidate 埋一齊。因為唔同情況下有可能需要將唔同既資料 consolidate 埋一齊成為一個針對固定場景既 HTTP response,我地需要喺 back-end 寫多個 API 去應付唔同場景,亦需要寫大量 response classes 去裝住呢啲不同場合用既 consolidated 既資料。
另外,有啲 HTTP response 既做法係用返本身 entity class 做 response class,而裡面因為有 @OneToOne
、@OneToMany
等 annotations 既 relationship fields,serializer 會令到 JPA/Hibernate 幫我地 fetch 埋出黎。但如果考慮到 lazy/eager fetching、用錯既時候有可能出現既 circular reference loop、修改 nested objects 既時候 trigger update、cascade delete 等等既情況,JPA/Hibernate 既 @OneToOne
、@OneToMany
呢啲 annotations 可以好危險。而 JPA/Hibernate 既 entity graph 功能更加係令到成件事更加複雜。如果唔想用 nested objects,就只能夠寫 response wrapper class 將 parent 同 children objects 寫成平排既 fields,但咁做就變相要寫多啲 response wrapper classes。
當然 front-end 可以 async 咁 call 好多次 back-end,然後 front-end 自己分別處理唔同 API responses 既資料,但寫起上黎可能會幾麻煩,同埋每個 API call 都需要 back-end 去 validate session 係咪存在、用戶帳號既狀態係咪正常等等。
再者,好多時候我地會想個 response 裡面只包含部分特定既 fields,避免冇必要既 fields 增加左 network usage,我地通常既做法係複製啲現有既 class 然後自己客製化,撇除啲冇必要既 fields。
1.2 GraphQL API 既好處
因此,就有左 GraphQL 既出現。關於 GraphQL:
- 只係暴露
1
個 GraphQL API。
- 畀我地將多個 GraphQL
query
/mutation
操作 consolidate 埋做 1
個 GraphQL API call。
- 例子
- 一個 GraphQL API 可以同時要求得到
getOrder
GraphQL query 以及 getOrderDetails
GraphQL query 既資料。
- 准許 call GraphQL API 既時候要求 response 裡面只係包含部分特定既 fields。
- GraphQL schema、Java response class 可以重用,唔需要複製再客製化。
- 可以建立
subscription
- GraphQL server(back-end)可以透過 WebSocket 傳送資料畀 GraphQL client(另一個系統或者 front-end)。
1.3 GraphQL 注意事項
- GraphQL schema
- 要暴露 GraphQL API,我地需要先定義 GraphQL schema。
- GraphQL 語法
type Query
type Mutation
type Subscription
- 指 GraphQL server 推送資料畀 GraphQL client 既 APIs
input
係 (...)
parameter types 既叫法
type
係 response type
- GraphQL 基本 data types
String
Int
Float
Boolean
ID
!
係必須提供既 field,要寫喺個 type 既後面
[xxx]
係 array
- 可以唔寫
,
- 只需要用 space 或者 newline 分隔。
$
開頭既係 variable。
- Variables 可以喺
query
或者 mutation
後面既 ()
裡面 declare。
- 正因為 GraphQL 有佢自己既一套格式,寫起上黎可能會有啲麻煩,所以就有 variable section,畀我地將 variables 分隔開。
- Variable section 係正常既 JSON 格式。
- Response 係 JSON 格式
- 所有操作所返回既資料由一個叫
data
既 field 包住。
GraphQL API 既格式:
methodName(requestField1: InputType1 requestField2: InputType2 ...): ResponseType
註:必須對應返 Java 既 GraphQL API method 名、request classes 以及 response classes。
2 動手寫
Project structure:
src/main/java
/code
/entity
Order.java
OrderDetail.java
/graphql
OrderMutation.java
OrderQuery.java
OrderSubscription.java
/repo
OrderDetailRepo.java
OrderRepo.java
/service
MainApplication.java
src/main/resources
2.1 Maven dependencies
- GraphQL Spring Boot Starter 會幫我地暴露一個
/graphql
既 POST API。
- 如果用左 Spring Boot Starter Webflux,GraphQL Spring Boot Starter 會幫我地暴露一個
/subscriptions
既 WebSocket API。
- GraphiQL Spring Boot Starter 會幫我地暴露一個測試頁面既網址——
/graphiql
endpoint。
- 用 MySQL/MariaDB 作為 database。
1<parent>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-parent</artifactId>
4 <version>2.7.18</version>
5</parent>
6
7<dependencies>
8 <dependency>
9 <groupId>org.projectlombok</groupId>
10 <artifactId>lombok</artifactId>
11 </dependency>
12
13 <dependency>
14 <groupId>org.springframework.boot</groupId>
15 <artifactId>spring-boot-starter-web</artifactId>
16 </dependency>
17
18 <dependency>
19 <groupId>org.springframework.boot</groupId>
20 <artifactId>spring-boot-starter-webflux</artifactId>
21 </dependency>
22
23 <dependency>
24 <groupId>org.springframework.boot</groupId>
25 <artifactId>spring-boot-starter-websocket</artifactId>
26 </dependency>
27
28 <dependency>
29 <groupId>org.springframework.boot</groupId>
30 <artifactId>spring-boot-starter-actuator</artifactId>
31 </dependency>
32
33 <dependency>
34 <groupId>org.springframework.boot</groupId>
35 <artifactId>spring-boot-devtools</artifactId>
36 </dependency>
37
38 <dependency>
39 <groupId>org.springframework.boot</groupId>
40 <artifactId>spring-boot-starter-data-jpa</artifactId>
41 </dependency>
42
43 <dependency>
44 <groupId>com.mysql</groupId>
45 <artifactId>mysql-connector-j</artifactId>
46 </dependency>
47
48 <dependency>
49 <groupId>com.graphql-java-kickstart</groupId>
50 <artifactId>graphql-spring-boot-starter</artifactId>
51 <version>14.1.0</version>
52 </dependency>
53
54 <dependency>
55 <groupId>com.graphql-java-kickstart</groupId>
56 <artifactId>graphiql-spring-boot-starter</artifactId>
57 <version>11.1.0</version>
58 </dependency>
59</dependencies>
2.2 Application 配置
1spring:
2 jpa:
3 show-sql: true
4 open-in-view: false
5 hibernate:
6 ddl-auto: update
7 properties:
8 hibernate:
9 dialect: org.hibernate.dialect.MySQLDialect
10 datasource:
11 url: jdbc:mysql://localhost:3306/mydb?useSSL=false
12 username: root
13 password:
14 driver-class-name: com.mysql.cj.jdbc.Driver
15management:
16 endpoints:
17 web:
18 exposure:
19 include: "*"
20
21graphql:
22 servlet:
23 actuator-metrics: true
24 cors:
25 allowedOrigins: "*"
26graphiql:
27 enabled: true
2.3 寫 Java code
2.3.1 Entity
Order.java
:
1@Data
2@Accessors(chain = true)
3@FieldDefaults(level = PRIVATE)
4@Entity
5@Table(name = "tbl_order")
6public class Order {
7 @Id
8 @GeneratedValue(strategy = IDENTITY)
9 Long id;
10
11 BigDecimal totalPrice;
12}
OrderDetail.java
:
1@Data
2@Accessors(chain = true)
3@FieldDefaults(level = PRIVATE)
4@Entity
5@Table(name = "tbl_order_detail")
6public class OrderDetail {
7 @Id
8 @GeneratedValue(strategy = IDENTITY)
9 Long id;
10
11 Long orderId;
12 Long productId;
13 BigDecimal salePrice;
14 Integer quantity;
15}
2.3.2 Repository
OrderRepo.java
:
@Repository
public interface OrderRepo extends JpaRepository<Order, Long> {
}
OrderDetailRepo.java
:
@Repository
public interface OrderDetailRepo extends JpaRepository<OrderDetail, Long> {
List<OrderDetail> findAllByOrderId(Long orderId);
}
2.3.3 Service
OrderService.java
:
1import org.springframework.transaction.annotation.Transactional;
2
3@Service
4@Transactional(rollbackFor = Throwable.class)
5public class OrderService {
6
7 @Autowired OrderRepo orderRepo;
8 @Autowired OrderDetailRepo orderDetailRepo;
9
10 public Order getOrder(Long orderId) {
11 return orderRepo.findById(orderId)
12 .orElseThrow(() -> new RuntimeException("Order ID [" + orderId + "] does not exist!" ));
13 }
14
15 public List<OrderDetail> getOrderDetails(Long orderId) {
16 return orderDetailRepo.findAllByOrderId(orderId);
17 }
18
19 public OrderResponse placeOrder(OrderRequest orderRequest) {
20
21 final Order order = orderRepo.save(toOrderEntity(orderRequest));
22 final List<OrderDetail> orderDetails = orderDetailRepo.saveAll(toOrderDetailEntities(orderRequest, order.getId()));
23
24 return new OrderResponse().setOrder(order).setOrderDetails(orderDetails);
25 }
26
27 private Order toOrderEntity(OrderRequest orderRequest) {
28 return new Order()
29 .setTotalPrice(orderRequest.getOrderDetails().stream()
30 .map(e -> e.getSalePrice()
31 .multiply(new BigDecimal(String.valueOf(e.getQuantity()))))
32 .reduce(BigDecimal.ZERO, BigDecimal::add));
33 }
34
35 private List<OrderDetail> toOrderDetailEntities(OrderRequest orderRequest, Long orderId) {
36 return orderRequest.getOrderDetails()
37 .stream()
38 .map(e -> new OrderDetail()
39 .setOrderId(orderId)
40 .setProductId(e.getProductId())
41 .setSalePrice(e.getSalePrice())
42 .setQuantity(e.getQuantity()))
43 .collect(toList());
44 }
45}
2.3.4 GraphQL API
OrderMutation.java
:
1@Component
2public class OrderMutation implements GraphQLMutationResolver {
3
4 @Autowired OrderService orderService;
5 @Autowired Sinks.Many<OrderResponse> sink;
6
7 public OrderResponse placeOrder(OrderRequest orderRequest) {
8
9 final OrderResponse response = orderService.placeOrder(orderRequest);
10 sink.tryEmitNext(response);
11
12 return response;
13 }
14}
OrderQuery.java
:
1@Component
2public class OrderQuery implements GraphQLQueryResolver {
3
4 @Autowired OrderService orderService;
5
6 public Order getOrder(Long orderId) {
7 return orderService.getOrder(orderId);
8 }
9
10 public List<OrderDetail> getOrderDetails(Long orderId) {
11 return orderService.getOrderDetails(orderId);
12 }
13}
OrderSubscription.java
:
1@Component
2public class OrderSubscription implements GraphQLSubscriptionResolver {
3
4 @Autowired Sinks.Many<OrderResponse> orderSink;
5
6 public Publisher<OrderResponse> checkNewOrders() {
7 return orderSink.asFlux();
8 }
9}
2.4 GraphQL schema
schema.graphqls
:
1type Query {
2 getOrder(orderId: Int!): Order
3 getOrderDetails(orderId: Int!): [OrderDetail]
4}
5
6type Mutation {
7 placeOrder(orderRequest: OrderRequest!): OrderResponse
8}
9
10type Subscription {
11 checkNewOrders: OrderResponse
12}
13
14input OrderRequest {
15 orderDetails: [OrderDetailRequest]!
16}
17
18input OrderDetailRequest {
19 productId: Int!
20 salePrice: Float!
21 quantity: Int!
22}
23
24type OrderResponse {
25 order: Order
26 orderDetails: [OrderDetail]
27}
28
29type Order {
30 id: Int
31 totalPrice: Float
32}
33
34type OrderDetail {
35 id: Int
36 orderId: Int
37 productId: Int
38 salePrice: Float
39 quantity: Int
40}
3 測試
3.1 建立 database
先用 HeidiSQL(Windows)或者 Sequel Pro(macOS)連接 MySQL 或者 MariaDB。
然後執行 SQL:
CREATE DATABASE mydb;
3.2 啟動 Spring Boot web application
mvn spring-boot:run
如果 Spring Boot web application 成功啟動,咁我地既 tbl_order
、tbl_order_detail
tables 都會由個 application 自動創建。
3.3 訪問 GraphiQL 測試網頁
GraphiQL 可以模擬一個 GraphQL client 去 call 我地 back-end GraphQL APIs。又或者用 Postman 都可以。其實背後都係一個 POST API call。
3.4 用 GraphiQL call placeOrder
API
1mutation {
2 order1: placeOrder(orderRequest: {
3 orderDetails: [
4 { productId: 1 salePrice: 123.5 quantity: 5 }
5 { productId: 2 salePrice: 222.8 quantity: 1 }
6 ]
7 }) {
8 order { id totalPrice }
9 orderDetails { id orderId productId salePrice quantity }
10 }
11
12 order2: placeOrder(orderRequest: {
13 orderDetails: [
14 { productId: 2 salePrice: 222.8 quantity: 7 }
15 { productId: 3 salePrice: 345.6 quantity: 3 }
16 ]
17 }) {
18 order { id totalPrice }
19 orderDetails { id orderId productId salePrice quantity }
20 }
21}
解釋:
order1
、order2
- 我地將
2
個 placeOrder
既 GraphQL mutation
操作 consolidate 埋做 1
個 GraphQL API call。
- Response 裡面會用呢
2
個名黎對應返 response objects。
3.4.1 用 Postman call placeOrder
API
喺 Postman 裡面 call:
POST localhost:8080/graphql
GraphQL query body 照用返 GraphiQL 果個完全一樣就得。
3.5 用 GraphiQL call getOrder
、getOrderDetails
API
1query ($orderId : Int!) {
2 order: getOrder(orderId: $orderId) {
3 id
4 totalPrice
5 }
6
7 orderDetails: getOrderDetails(orderId: $orderId) {
8 id
9 orderId
10 productId
11 salePrice
12 quantity
13 }
14}
Variable section(JSON 格式):
{
"orderId": 1
}
解釋:
order
、orderDetails
- 我地將
getOrder
以及 getOrderDetails
既 2
個 GraphQL query
操作 consolidate 埋做 1
個 GraphQL API call。
- Response 裡面會用呢
2
個名黎對應返 response objects。
$orderId
3.5.1 用 Postman call getOrder
、getOrderDetails
API
喺 Postman 裡面 call:
POST localhost:8080/graphql
GraphQL query body 照用返 GraphiQL 果個完全一樣就得。
3.6 用 GraphiQL call checkNewOrders
API
1subscription {
2 checkNewOrders {
3 order {
4 id
5 totalPrice
6 }
7
8 orderDetails {
9 id
10 orderId
11 productId
12 salePrice
13 quantity
14 }
15 }
16}
解釋:
checkNewOrders
- 我地訂閱緊
checkNewOrders
,如果 WebSocket server 有新 message send 出黎,我地個 WebSocket client 就會收到。
4 Front-end call GraphQL API
我地試左用 GraphiQL 同 Postman call GraphQL API,但 front-end 要點樣 call?雖然已經有啲現成既 NPM libraries 可以做到 GraphQL client,但其實要自己用 JavaScript 做既話都好簡單。
4.1 用 Fetch API call placeOrder
API
非 dynamic 寫法:
1fetch("http://localhost:8080/graphql", {
2 method: "POST",
3 body: JSON.stringify({
4 query: `
5 mutation {
6 order1: placeOrder(orderRequest: {
7 orderDetails: [
8 { productId: 1 salePrice: 123.5 quantity: 5 }
9 { productId: 2 salePrice: 222.8 quantity: 1 }
10 ]
11 }) {
12 order { id totalPrice }
13 orderDetails { id orderId productId salePrice quantity }
14 }
15
16 order2: placeOrder(orderRequest: {
17 orderDetails: [
18 { productId: 2 salePrice: 222.8 quantity: 7 }
19 { productId: 3 salePrice: 345.6 quantity: 3 }
20 ]
21 }) {
22 order { id totalPrice }
23 orderDetails { id orderId productId salePrice quantity }
24 }
25 }
26 `,
27 }),
28})
29 .then((e) => e.json())
30 .then((res) => console.log(res?.data));
4.2 用 Fetch API call getOrder
、getOrderDetails
API
1fetch("http://localhost:8080/graphql", {
2 method: "POST",
3 body: JSON.stringify({
4 query: `
5 query ($orderId : Int!) {
6 order: getOrder(orderId: $orderId) {
7 id
8 totalPrice
9 }
10
11 orderDetail: getOrderDetails(orderId: $orderId) {
12 id
13 orderId
14 productId
15 salePrice
16 quantity
17 }
18 }
19 `,
20 variables: {
21 orderId: 1,
22 },
23 }),
24})
25 .then((e) => e.json())
26 .then((res) => console.log(res?.data));
4.3 用 WebSocket API call checkNewOrders
API
1const ws = new WebSocket("ws://localhost:8080/subscriptions", "graphql-ws");
2ws.onmessage = (e) => {
3 console.log(`%c${e.data}`, "color: cyan");
4
5 if (JSON.parse(e.data).type === "connection_ack") {
6 console.log("%cReceived type=connection_ack, subscribing to checkNewOrders...", "color: yellow");
7
8 ws.send(
9 JSON.stringify({
10 id: 1,
11 type: "start",
12 payload: {
13 query: "subscription { checkNewOrders { order { id totalPrice } orderDetails { id orderId productId salePrice quantity } } }",
14 variables: null,
15 },
16 })
17 );
18
19 console.log("%cSent subscription message to server.", "color: yellow");
20 }
21};
22ws.onopen = (e) => {
23 console.log("%cWebSocket connection to GraphQL subscriptions is open.", "color: yellow");
24
25 ws.send(
26 JSON.stringify({
27 type: "connection_init",
28 payload: {},
29 })
30 );
31
32 console.log("%cSent connection_init message to server.", "color: yellow");
33};
5 參考資料