這篇文要來紀錄一下 Java 的 GraphQL 純 Spring MVC 實作練習,
因為網路上的許多範例都是使用
SpringBoot 來實作,
所以在這裡紀錄一下使用純 Spring MVC 、不使用
SpringBoot 的方法。
在這裡想要實作的情境是:
我們有 Author、和 Book 兩種資料,
Author 和
Book 都有 id、name 屬性,
Author 還有 bookIdList 屬性,是一個
Array,紀錄著此 Author 撰寫的 Book 的 id。
我們要實作兩個 query
和一個 mutation,分別是:
Author getAuthorById(id) :
用 Author id 查 Author。
回傳的 bookList 可以給一個 prefix
參數,它可以為回傳的 book name 前面加上 prefix
(這只是用來練習如何在 Java
GraphQL server 端取得 Query 的參數)。
Book getBookById(id) :
用 Book id 查 Book,
Author updateAuthorName(id, name):
修改特定 id 的 Author 的 name。
GraphQL 的 Schema 設計如下,其中 Date 是我們自訂的 type,之後會建立 GraphQLScalarType 類別來解析它:
scalar Date
type Book {
id: ID!
name: String
publishDate: Date
}
type Author {
id: ID!
name: String
bookList(prefix: String): [Book]
}
type Query {
getAuthorById(id: ID!): Author
getBookById(id: ID!): Book
}
type Mutation {
updateAuthorName(id: ID!, name: String!): Author
}
Query Request 的例子如下:
query {
getAuthorById(id: 3) {
id
name
bookList(prefix: "test") {
id
name
publishDate
}
}
getBookById(id: 33) {
id
name
publishDate
}
}
Mutatio Request 的例子如下:
mutation {
updateAuthorName(id: 3, name: "Author 333") {
id
name
}
}
以下開始實作,這裡使用 Opoen JDK 11,
先建立一個 archetype 為 maven-archetype-webapp
的 Java Dynamic Web Application Maven 專案,可以參考這裡,
首先先來看一下專案的結構設計,如下圖所示:
接著開始說明,
因為我們要使用 Spring MVC 和 GraphQL 的 library,所以在 pom.xml 加上以下
dependency:
pom.xml (部份內容):
<!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.1.3.RELEASE</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework/spring-context-support -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>5.1.3.RELEASE</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.0.1</version>
<scope>provided</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.6</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.graphql-java/graphql-java -->
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java</artifactId>
<version>20.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.graphql-java-kickstart/graphql-java-tools -->
<dependency>
<groupId>com.graphql-java-kickstart</groupId>
<artifactId>graphql-java-tools</artifactId>
<version>13.0.3</version>
</dependency>
接著在 web.xml 裡設定把流量導向 Spring MVC:
/src/main/webapp/WEB-INF/web.xml:
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd" >
<web-app>
<display-name>Archetype Created Web Application</display-name>
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/mvc-config.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-app>
設定 Spring MVC 的設定檔:
src/main/webapp/WEB-INF/mvc-config.xml:
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:security="http://www.springframework.org/schema/security" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:tx="http://www.springframework.org/schema/tx" xmlns:task="http://www.springframework.org/schema/task"
xmlns:aop="http://www.springframework.org/schema/aop" xmlns:cache="http://www.springframework.org/schema/cache" xmlns:util="http://www.springframework.org/schema/util"
xmlns:oxm="http://www.springframework.org/schema/oxm"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-3.0.xsd
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
http://www.springframework.org/schema/mvc
http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd
http://www.springframework.org/schema/task
http://www.springframework.org/schema/task/spring-task-3.0.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop-4.0.xsd
http://www.springframework.org/schema/cache
http://www.springframework.org/schema/cache/spring-cache.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util-3.0.xsd
http://www.springframework.org/schema/oxm
http://www.springframework.org/schema/oxm/spring-oxm-3.0.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security-4.2.xsd"
>
<context:component-scan base-package="com.*"/>
<aop:aspectj-autoproxy/>
<mvc:annotation-driven/>
</beans>
說明:把 GraphQL 的 Schema 設定在 schema.graphqls 中。
/src/main/resources/schema.graphqls:
scalar Date
type Book {
id: ID!
name: String
publishDate: Date
}
type Author {
id: ID!
name: String
bookList(prefix: String): [Book]
}
type Query {
getAuthorById(id: ID!): Author
}
type Mutation {
updateAuthorName(id: ID!, name: String!): Author
}
再來建好需要的 Bean 。
/src/java/com/bean/Author.java:
package com.bean;
import java.util.List;
public class Author {
private int id;
private String name;
private List<Integer> bookIdList;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<Integer> getBookIdList() {
return bookIdList;
}
public void setBookIdList(List<Integer> bookIdList) {
this.bookIdList = bookIdList;
}
}
/src/java/com/bean/Book.java:
package com.bean;
import java.util.Calendar;
public class Book {
private int id;
private String name;
Calendar publishDate;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Calendar getPublishDate() {
return publishDate;
}
public void setPublishDate(Calendar publishDate) {
this.publishDate = publishDate;
}
}
建好查詢資料和修改資料的 DAO
(這邊沒有使用真正的資料庫,只是建假的資料來模擬)。
/src/main/java/com/dao/AuthorDAO.java:
package com.dao;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Repository;
import com.bean.Author;
@Repository
public class AuthorDAO {
private static List<Author> authorList;
public AuthorDAO() {
authorList = new ArrayList<Author>();
//Set mock data to simulate data in database.
Author author = new Author();
author.setId(1);
author.setName("Author 1");
author.setBookIdList(List.of(1, 11));
authorList.add(author);
author = new Author();
author.setId(2);
author.setName("Author 2");
author.setBookIdList(List.of(2, 22));
authorList.add(author);
author = new Author();
author.setId(3);
author.setName("Author 3");
author.setBookIdList(List.of(3, 33));
authorList.add(author);
}
public Author getAuthorById(int id) {
return authorList.stream().filter((Author author) -> {
return author.getId() == id;
}).findAny().orElse(null);
}
public Author updateAuthorName(int id, String name) {
Author author = getAuthorById(id);
if (author != null) {
author.setName(name);
}
return author;
}
}
/src/main/java/com/dao/BookDAO.java:
package com.dao;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Repository;
import com.bean.Author;
import com.bean.Book;
@Repository
public class BookDAO {
private static List<Book> bookList;
public BookDAO() {
bookList = new ArrayList<Book>();
//Set mock data to simulate data in database.
Book book = new Book();
book.setId(1);
book.setName("Book 1");
book.setPublishDate(getCalendarByStr("2019-01-01T01:00:00+0800"));
bookList.add(book);
book = new Book();
book.setId(11);
book.setName("Book 11");
book.setPublishDate(getCalendarByStr("2019-01-01T01:01:00+0800"));
bookList.add(book);
book = new Book();
book.setId(2);
book.setName("Book 2");
book.setPublishDate(getCalendarByStr("2019-01-01T02:00:00+0800"));
bookList.add(book);
book = new Book();
book.setId(22);
book.setName("Book 22");
book.setPublishDate(getCalendarByStr("2019-01-01T02:02:00+0800"));
bookList.add(book);
book = new Book();
book.setId(3);
book.setName("Book 3");
book.setPublishDate(getCalendarByStr("2019-01-01T03:00:00+0800"));
bookList.add(book);
book = new Book();
book.setId(33);
book.setName("Book 33");
book.setPublishDate(getCalendarByStr("2019-01-01T03:03:00+0800"));
bookList.add(book);
}
public Book getBookById(int id) {
return bookList.stream().filter((Book book) -> {
return book.getId() == id;
}).findAny().orElse(null);
}
public List<Book> getBookListByAuthor(Author author) {
if (author == null || author.getBookIdList() == null || author.getBookIdList().size() == 0) {
return new ArrayList<Book>();
}
return author.getBookIdList().stream().map((Integer bookId) -> {
return bookList.stream().filter((Book book) -> {
return book.getId() == bookId;
}).findAny().orElse(null);
}).filter((Book book) -> {
return book != null;
})
.collect(Collectors.toList());
}
Calendar getCalendarByStr(String dateStr) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
Calendar date = Calendar.getInstance();
try {
date.setTime(sdf.parse(dateStr));
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}
我們這邊建一個 GraphqlController.java 來對應至GraphQL server 會收到的
request body JSON,
等一下在 Spring MVC 的 controller 可以直接把 GraphQL
request 的值用 GraphqlController.java 來接,
這邊說明一下,Client 端要向
GraphQL 發起請求的時候,可以使用 HTTP GET 及 HTTP POST 的方式:
-
HTTP GET:
將參數直接串在網址後面,例如以下例子,不過要注意有些限制,像是無法使用
variables、mutation (雖然官方這樣說,不過其實如果 GraphQL server 是像這篇例子一樣自己實做的話,還是可以自己去設計取得 variables, mutation 實作行為,例如 query=mutation {...}) 等,可參考 Making a GraphQL requests using the GET method:
https://xxx/xxx?query=...&operationName=...
-
HTTP POST:
將參數 (query、operationName, variables 等) 直接放在 HTTP
Body 中 (相當於 Postman 的 raw 格式),例如:
{
"query": "...",
"operationName": "...",
"variables": { "myVariable": "someValue", ... }
}
詳細可以參考這裡的說明: HTTP Methods, Headers, and Body。
而 GraphqlController.java 我們把它設計成可對應 HTTP POST Body 的 JSON
(有 query, operationName, variables 等屬性)。
/src/main/java/com/query/GraphqlRequestQuery.java:
package com.query;
import java.util.Map;
public class GraphqlRequestQuery {
String query;
String operationName;
Map<String, Object> variables = new HashMap<>();
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
public String getOperationName() {
return operationName;
}
public void setOperationName(String operationName) {
this.operationName = operationName;
}
public Map<String, Object> getVariables() {
return variables;
}
public void setVariables(Map<String, Object> variables) {
this.variables = variables;
}
}
建立各個 Resolver 和 Mutation,這會分別對應到 GraphQL 的 query 和
mutation,
說明都寫在注釋裡。
QueryResolver.java 對應到 GraphQL query,裡面會實作 getAuthorById()
和 getBookById() 方法
/src/main/java/com/resolver/QueryResolver.java:
package com.resolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.bean.Author;
import com.bean.Book;
import com.dao.AuthorDAO;
import com.dao.BookDAO;
import graphql.kickstart.tools.GraphQLQueryResolver;
@Component //對應至 GraphQL request 的 query
public class QueryResolver implements GraphQLQueryResolver {
@Autowired
AuthorDAO authorDAO;
@Autowired
BookDAO bookDAO;
//對應至 GraphQL request query 的 getAuthorById
public Author getAuthorById(int id) {
return authorDAO.getAuthorById(id);
}
//對應至 GraphQL request query 的 getBookById
public Book getBookById(int id) {
return bookDAO.getBookById(id);
}
}
AuthorResolver.java 對應到 GraphQL query 回傳 Author 的 bookList
屬性實作方法,
因為在 QueryResolver.java 中 getAuthorById() 回傳的 Author
裡面找不到 bookList 屬性 (Author.java 只有 bookIdList 和 getBookIdList() 的
getter ,並且 bookIdList 是 int[],也不是 Book[] ),
所以我們必須為
Author 的 bookList 屬性實作值的取得方法。
/src/main/java/com/resolver/AuthorResolver.java:
package com.resolver;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.bean.Author;
import com.bean.Book;
import com.dao.BookDAO;
import graphql.kickstart.tools.GraphQLResolver;
@Component //對應至 GraphQL request query 中,Author 屬性值的 Resolver 獲取方式
public class AuthorResolver implements GraphQLResolver<Author> {
@Autowired
BookDAO bookDAO;
//對應至 GraphQL request query 中,Author 屬性 bookList 值的 Resolver 獲取方式
List<Book> bookList(Author author, String prefix) { //可獲取 Parent (也就是 Author) 的資料和傳入參數 (此例為 prefix)
List<Book> bookList = bookDAO.getBookListByAuthor(author);
if (StringUtils.isNotBlank(prefix)) {
bookList = bookList.stream().map((Book book) -> {
Book renamedBook = new Book();
renamedBook.setId(book.getId());
renamedBook.setName(prefix + "-" + book.getName());
renamedBook.setPublishDate(book.getPublishDate());
return renamedBook;
}).collect(Collectors.toList());
}
return bookList;
}
}
MutationResolver.java 對應至 GraphQL mutation,裡面會實作 updateAuthorName
/src/main/java/com/resolver/MutationResolver.java:
package com.resolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.bean.Author;
import com.dao.AuthorDAO;
import graphql.kickstart.tools.GraphQLMutationResolver;
@Component //對應至 GraphQL request 的 mutation
public class MutationResolver implements GraphQLMutationResolver{
@Autowired
AuthorDAO authorDAO;
//對應至 GraphQL request mutation 的 updateAuthorName
public Author updateAuthorName(int id, String name) {
return authorDAO.updateAuthorName(id, name);
}
}
接下來我們要來處理自訂的 Date Scalar , 我們這邊建立一個 DateScalarBuilder.java 來回傳自訂實作的 GraphQLScalarType,關於 parseValue() 和 parseLiteral() 的差異可以參考官方這篇 Scalars in GraphQL 和 Stackoverflow 這篇 what's the difference between parseValue and parseLiteral in GraphQLScalarType 別人的回答
DateScalarBuilder.java:
package com.scalar;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import graphql.language.StringValue;
import graphql.schema.Coercing;
import graphql.schema.GraphQLScalarType;
public class DateScalarBuilder {
private DateScalarBuilder() {
}
public static GraphQLScalarType getDateScalar() {
String dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ";
return GraphQLScalarType.newScalar()
.name("Date")
.description("A custom scalar that handles date.")
.coercing(new Coercing<Calendar, String>() {
@Override
public String serialize(Object dataFetcherResult) {
//實作從 Java 物件 (此例是 Calendar) 轉成要回傳 graphql response 的 String
Calendar date = (Calendar) dataFetcherResult;
SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
return sdf.format(date.getTime()) ;
}
@Override
public Calendar parseValue(Object input) {
//實作從 Graphql 得到的 String 轉成 Java 物件 (此例是 Calendar)
//如果 input 使用了 inline input 的方式,就會使用 parseValue() 來解析
//例如:
/*
* Request:
* query myQuery { *
* getBookById(id: 1) {
* id
* name
* }
* }
*/
String dateStr = (String) input;
SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
Calendar date = Calendar.getInstance();
try {
date.setTime(sdf.parse(dateStr));
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
@Override
public Calendar parseLiteral(Object input) {
//實作從 Graphql 得到的 String 轉成 Java 物件 (此例是 Calendar)
//如果 input 使用了 variables 的方式,就會使用 parseLiteral() 來解析
//例如:
/*
* Request:
* query myQuery($id: ID) {
*
* getBookById(id: $id) {
* id
* name
* }
* }
*
* Variables:
* {
* "id": 1
* }
*/
String dateStr = ((StringValue) input).getValue();
SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
Calendar date = Calendar.getInstance();
try {
date.setTime(sdf.parse(dateStr));
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
})
.build();
}
}
GraphqlController.java 是 SpringMVC 的 Controller 設定,
這裡我們設定了
/graphql 為處理 GraphQL request 的 url,
限制只接收 HTTP POST 且 Mime
type 為 application/json 格式的 request。
接著讀取 GraphQL Schema 、設定要用的 Resolver 、 Scalar,並配合接收到的 GraphQL
request
產生並回傳查詢結果。
src/java/com/controller/GraphqlController.java:
package com.controller;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.kickstart.tools.SchemaParser;
import graphql.schema.GraphQLSchema;
import com.scalar.DateScalarBuilder;
import com.query.GraphqlRequestQuery;
import com.resolver.AuthorResolver;
import com.resolver.MutationResolver;
import com.resolver.QueryResolver;
@Controller
public class GraphqlController {
@Autowired
private QueryResolver queryResolver;
@Autowired
private AuthorResolver authorResolver;
@Autowired
private MutationResolver mutationResolver;
@RequestMapping(value="/graphql", method = {RequestMethod.POST}, produces = "application/json")
public @ResponseBody Map<String, Object> myGraphql(@RequestBody GraphqlRequestQuery requestQuery) {
//讀取 GraphQL Schema、設定各 Resolver
GraphQLSchema graphQLSchema = SchemaParser.newParser()
.file("schema.graphqls")
.scalars(DateScalarBuilder.getDateScalar())
.resolvers(queryResolver
, authorResolver
, mutationResolver)
.build()
.makeExecutableSchema();
GraphQL graphQL = GraphQL.newGraphQL(graphQLSchema).build();
//將 GraphQL request 的 query (也可視需要取出 operationName, variables) 值取出,準備進行查詢
ExecutionInput executionInput = ExecutionInput.newExecutionInput()
.query(requestQuery.getQuery())
.operationName(requestQuery.getOperationName())
.variables(requestQuery.getVariables())
.build();
ExecutionResult executionResult = graphQL.execute(executionInput);
//executionResult.toSpecification() 會回傳一個 Map,
//裡面包含了查詢結果 data 、 errors、 extensions 等資料
return executionResult.toSpecification();
}
}
最後展示一下查詢結果:
- 查詢:
query {
getAuthorById(id: 3) {
id
name
bookList(prefix: "test") {
id
name
publishDate
}
},
getBookById(id: 33) {
id
name
publishDate
}
}
結果:{
"data": {
"getAuthorById": {
"id": "3",
"name": "Author 3",
"bookList": [
{
"id": "3",
"name": "test-Book 3"
"publishDate": "2019-01-01T03:00:00+0800"
},
{
"id": "33",
"name": "test-Book 33"
"publishDate": "2019-01-01T03:03:00+0800"
}
]
},
"getBookById": {
"id": "33",
"name": "Book 33"
"publishDate": "2019-01-01T03:03:00+0800"
}
}
}
- 查詢:
mutation {
updateAuthorName(id: 3, name: "Author 3 - changed") {
id
name
bookList(prefix: "test") {
id
name
publishDate
}
}
}
結果:{
"data": {
"updateAuthorName": {
"id": "3",
"name": "Author 3 - changed",
"bookList": [
{
"id": "3",
"name": "test-Book 3"
"publishDate": "2019-01-01T03:00:00+0800"
},
{
"id": "33",
"name": "test-Book 33"
"publishDate": "2019-01-01T03:03:00+0800"
}
]
}
}
}
下載分享:
MyGraphQL.7z
參考資料:
-
HTTP Methods, Headers, and Body
-
Making a GraphQL requests using the GET method
-
GraphQL x Spring WebMVC
-
Defining a schema
-
Think in GraphQL :: 2019 iT 邦幫忙鐵人賽