➜ Old React website
Chung Cheuk Hang MichaelJava Web Developer
Java 8 HashMap 原理Spring 項目使用 Redis 做 caching

Spring 項目暴露 GraphQL API

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 querymutation 操作 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 語法
    • GraphQL 有佢自己既一套語法/格式。
  • type Query
    • 指查詢用既 APIs
    • 必須提供
  • type Mutation
    • 指會修改系統既 APIs
  • type Subscription
    • 指 GraphQL server 推送資料畀 GraphQL client 既 APIs
  • input(...) parameter types 既叫法
  • type 係 response type
  • GraphQL 基本 data types
    • String
      • 要用 "xxx" 而唔係 'xxx'
    • Int
      • 32-bit
    • Float
    • Boolean
    • ID
      • 格式跟 String
  • ! 係必須提供既 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
        • OrderService.java
      • MainApplication.java
  • src/main/resources
    • /graphql
      • schema.graphqls
    • application.yml

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。
HostPort
127.0.0.13306
然後執行 SQL:
CREATE DATABASE mydb;

3.2 啟動 Spring Boot web application

mvn spring-boot:run
如果 Spring Boot web application 成功啟動,咁我地既 tbl_ordertbl_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}
解釋:
  • order1order2
    • 我地將 2placeOrder 既 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 getOrdergetOrderDetails 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 }
解釋:
  • orderorderDetails
    • 我地將 getOrder 以及 getOrderDetails2 個 GraphQL query 操作 consolidate 埋做 1 個 GraphQL API call。
    • Response 裡面會用呢 2 個名黎對應返 response objects。
  • $orderId
    • 呢度我地用左 1 個 variable。

3.5.1 用 Postman call getOrdergetOrderDetails 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 getOrdergetOrderDetails 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 參考資料