2023年12月26日 星期二

Java 日期操作筆記 - 使用 DateTimeFormatter, OffsetDateTime, LocalDateTime, TemporalAdjusters 等

紀錄下 Java 的各種日期時間操作

使用 JDK 11

package test;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.Calendar;
import java.util.Date;

public class DateTimeFormatterTest {

	public static void main(String[] args) {
		String timeStr = "2023-01-01T00:00:00+00:00";
		//使用 DateTimeFormatter
		DateTimeFormatter dtf_utc0 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxxx")
													  .withZone(ZoneId.of("+0000"));
		//上例可以簡單寫成 DateTimeFormatter dtf = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
		OffsetDateTime offsetDateTime = OffsetDateTime.parse(timeStr, dtf_utc0);
		//轉換成 Date
		Date date = Date.from(offsetDateTime.toInstant());
		//轉換成 Calendar
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        
        //將 Calendar 轉成 Date,再從 Date 轉成 OffsetDateTime
        //atOffset(Offset) 只是用來把 Date 轉成 OffsetDateTime,
        //Offset 設多少不重要,因為都可以再用 DateTimeFormatter.format() 將其 format 成其他時區的日期文字 
        OffsetDateTime offsetDateTime2 = calendar.getTime() // 將 Calendar 轉成 Date
        								 .toInstant()
        								 .atOffset(ZoneOffset.of("+0800"));
        
        //用不同時區和 format 印出日期,可以注意 xxx 和 xx 的不同,xxx會印出冒號而xx不會
        DateTimeFormatter dtf_utc3 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxx")
        							 				  .withZone(ZoneId.of("+0300"));
        System.out.println(dtf_utc3.format(offsetDateTime2)); //2023-01-01T03:00:00+0300
        
        ////////////////////////// - LocalDate, LocalTime 等串接練習 - //////////////////
        //只有日期沒有時間的 DateTimeFormatter
  		DateTimeFormatter dtf_utc0_dateOnly = DateTimeFormatter.ofPattern("yyyy-MM-dd")
  													           .withZone(ZoneId.of("+0000"));
  		//含有有日期及時間的 DateTimeFormatter
  		DateTimeFormatter dtf_losAngeles_withTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
  				                                                     .withZone(ZoneId.of("America/Los_Angeles"));
  		//只有日期不含時間、時區的 LocalDate
  		LocalDate localDate = LocalDate.parse("2024-01-01", dtf_utc0_dateOnly);
  		System.out.println(localDate); //2024-01-01
  		//只有時間不含日期、時區的 LocalTime
  		LocalTime localTime = LocalTime.of(0, 0, 0);
  		System.out.println(localTime); //00:00
  		//LocalDateTime 含有日期和時間,但不含時區,可以用 LocalDate 加 LocalTime 組合而成
  		LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);
  		System.out.println(localDateTime); //2024-01-01T00:00
  		//OffsetDateTime 含有日期、時間和時區,可以用 LocalDateTime 加 ZoneOffset 組合而成
  		OffsetDateTime offsetDateTime1 = OffsetDateTime.of(localDateTime, ZoneOffset.of("+0000"));
  		
  		System.out.println(dtf_losAngeles_withTime.format(offsetDateTime1)); //2023-12-31 16:00:00		
  		
  		//OffsetDateTime 也提供多種不同的建立 Method,
  		//例如其中的一個 OffsetDateTime.of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneOffset offset)
  		//它可以用以1來表示1月 (而不是用0來表示1月),如下例:
  		OffsetDateTime offsetDateTime_uct0 = OffsetDateTime.of(2024, 6, 6, 20, 0, 0, 0, ZoneOffset.of("+0000"));
  		System.out.println(offsetDateTime_uct0); //2024-06-06T20:00Z
  		//得到當日是星期幾,可能會依不同時區而不同
  		System.out.println(offsetDateTime_uct0.getDayOfWeek()); //THURSDAY
  		//可以把日期、時間提出來換上不同時區建立新的 OffsetDateTime,此時其 Instant 代表的毫秒數可能會改變 
  		System.out.println(OffsetDateTime.of(offsetDateTime_uct0.toLocalDateTime(), ZoneOffset.of("+0800"))); //2024-06-06T20:00+08:00
  		//可以用 OffsetDateTime.withOffsetSameInstant(ZoneOffset)在Instant代表毫秒數不變的情況下修改時區
  		OffsetDateTime offsetDateTime_uct8 = offsetDateTime_uct0.withOffsetSameInstant(ZoneOffset.of("+0800"));
  		System.out.println(offsetDateTime_uct8); //2024-06-07T04:00+08:00
  		//可以看到因為時區不同而顯示不同的星期
  		System.out.println(offsetDateTime_uct8.getDayOfWeek()); //FRIDAY
  		
  		//////////////////////////- TemporalAdjusters 練習 - //////////////////
  		//配合 OffsetDateTime.with() 和 TemporalAdjusters 可以得到特定條件的修改時間,
  		//例如此例使用 TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY)
  		//可以得到往前算的最近一次星期五(包含今天)
  		OffsetDateTime lastFirdayIncludeToday_utc0 = offsetDateTime_uct0.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY));
  		System.out.println(lastFirdayIncludeToday_utc0); //2024-05-31T20:00Z
  		
  		OffsetDateTime lastFirdayIncludeToday_utc8 = offsetDateTime_uct8.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY));
  		System.out.println(lastFirdayIncludeToday_utc8); //2024-06-07T04:00+08:00
	}
}

參考資料:

  1. DateTimeFormatter (Java Platform SE 8 )

2023年6月14日 星期三

Java GraphQL 實作練習 - 不使用 SpringBoot,只使用一般 Spring MVC

這篇文要來紀錄一下 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 的方式:

  1. HTTP GET:
    將參數直接串在網址後面,例如以下例子,不過要注意有些限制,像是無法使用 variables、mutation (雖然官方這樣說,不過其實如果 GraphQL server 是像這篇例子一樣自己實做的話,還是可以自己去設計取得 variables, mutation 實作行為,例如 query=mutation {...}) 等,可參考 Making a GraphQL requests using the GET method
    https://xxx/xxx?query=...&operationName=...
    
  2. 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();
	}
}

最後展示一下查詢結果:

  1. 查詢:
    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"
            }
        }
    }
    
  2. 查詢:
    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

參考資料:

  1. HTTP Methods, Headers, and Body
  2. Making a GraphQL requests using the GET method
  3. GraphQL x Spring WebMVC
  4. Defining a schema
  5. Think in GraphQL :: 2019 iT 邦幫忙鐵人賽

2023年6月7日 星期三

Java keytool 工具好用指令紀錄

Java keytool 工具好用指令紀錄 (keytool 通常放在 jdk 的 bin 資料夾裡)
想看指令參數說明,可以用 -h 指令,例如:
keytool -import -h

#匯入憑證到 keystore 檔
keytool –import –alias xxx –file certificationFile –keystore xxx

#將一個 .p12 keystore 檔併到另一個 .p12 keystore 檔
#如果用 -import 指令的話只能匯入憑證(trustedcertentry),沒有辦法匯入包含 private key 和憑證的一組資料 (PrivateKeyEntry)
keytool -importkeystore -srckeystore xxx -srcstoretype PKCS12 -destkeystore xxx

#查詢keystore的內容
keytool –list –v –keystore xxx

#刪除keystore內的其中一個憑證
keytool –delete –alias xxx –keystore xxx

#產生金錀對 (RSA為非對稱加密的演算法)
keytool -genkey -alias xxx -keyalg RSA -keystore xxx

#產生憑證申請檔
keytool -certreq -alias xxx -file xxx -keystore xxx

#修改 keystore 的密碼
keytool -storepasswd -new xxx -keystore xxx

2023年6月5日 星期一

Client Certificate 的 OCSP (Online Certificate Status Protocol) 設定

這篇是的
使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範
的 OCSP 設定補充。

如果要看 CRL 的設定可以看我的另一篇文章:

Client Certificate 的 CRL (Certificate Revocation List) 設定

OCSP 的全名是 Online Certificate Status Protocol,
跟 CRL 相同的地方是,它一樣可以用一個 ./db/index 來紀錄憑證的撤銷狀態。
跟 CRL 不同的地方是,它不是提供一個憑證撤銷清單,而是自行啟動一個伺服器,我們把它叫做 OCSP Responder,當想要對憑證狀態進行確認時,可以對 OCSP Responder 發起詢問的請求 (OCSP Request),OCSP Responder 再把憑證的狀態回傳回去 (OCSP Response)。

跟 CRL 比較起來,OCSP 有幾個優點:
  1. 不像 CRL 一次回傳多個憑證狀態,一次只回傳被詢問的憑證狀態,對於網路成本及詢問者的解析成本都比較低。
  2. 因為是由 OCSP Responder Server 即時回應,相較 CRL 可能一段時間才發出更新或才會被人詢問來說,能獲取比較即時的憑證狀態。
缺點也是有:
  1. OCSP Responder 的流量負擔可能比較大,可能可以用多台 Server 做 Revers Proxy 分流解決。
OCSP Responder 可以用任何技術實作,只要它能處理 OCSP Request 並回傳正確格式的 OCSP Response 即可,如何儲存憑證的撤銷狀態也可自行用任何方法實現,其實 CRL 也是一樣,只是上次我們就直接使用 OpenSSL 的 index 檔功能來幫助我們實現。

在這篇文中,我一樣會只使用 OpenSSL 來實現 OCSP Responder,OpenSSL 本身就可以開啟一個 OCSP Responder,並配合 OpenSSL index 檔來處理 OCSP Request。

---------------------------------------------------------------------------------------------
現在要來示範要做 Client Certificate 時,建立 OCSP Responder 和 Nginx 設定的方式。

Nginx 的設定非常簡單,接續這一篇文章的 Nginx 設定 使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範,只要再加上 ssl_ocsp ssl_ocsp_responder 設定即可。
server {
   ......
   ssl_client_certificate "ca.crt";
   ssl_verify_client optional;
   ssl_ocsp leaf; #值可以是 on, leaf, off,leaf 是指只對 client certificate 做 ocsp
   ssl_ocsp_responder http://127.0.0.1:9000; # 自行指定 OCSP Responder 的 url
   ......
}

為了之後管理方便,在這裡我先建了幾個資料夾和檔案 (./crlnumber 和 ./crl 資料夾就不用了):
  1. ./certs:存放憑證的地方。
  2. ./csr:存放 CSR 的地方。
  3. ./db/index:index 為我手動放置的一個空白檔案,作為一個紀錄憑證狀態的純文字檔,其實 CRL 可以不需要,主要給 OCSP 使用,但 CRL 也可從其中產生出 CRL 檔,也方便之後管理憑證狀態。
  4. ./keys:存放 Private Key 的地方。
  5. ./serial/serial:serial 為我手動放置的一個純文字檔案,其內容為一個單純的 serial number 序號,供憑證產生時的累加 Serial Key 使用,serial number 可視為對每一個 client certificate 的辨識唯一序號。
  6. ca.conf:供 openssl ca 指令用的配置檔,用以簡化指令參數長度及方便管理。
檔案內容例如可用以下指定 (自己用編輯器輸入也可以):
touch ./db/index
openssl rand -hex 16 > serial/serial
因為會用到
openssl ca 指令,為了方便我們先來建立一個 config 配置檔供 openssl ca 指令參考,
這樣 openssl ca 指令就可以少打一些參數。

ca.conf:
[default]
name                    = root-ca
default_ca              = ca_config

[ca_config]
database                = db/index
serial                  = serial/serial
crlnumber               = crlnumber/crlnumber
default_crl_days        = 1
certificate             = certs/ca.crt
private_key             = keys/ca.key
new_certs_dir           = certs
default_md              = sha256
policy                  = ca_policy
unique_subject          = no

[ca_policy]
countryName             = match
stateOrProvinceName     = optional
organizationName        = match
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

[ocsp_ext]
basicConstraints        = critical,CA:false
extendedKeyUsage        = OCSPSigning
noCheck                 = yes
keyUsage                = critical,digitalSignature
subjectKeyIdentifier    = hash
接著各項指令如下,
#產生 CA private key
openssl genrsa -out ./keys/ca.key 4096

#產生 CA CSR
openssl req -new -key ./keys/ca.key -out ./csr/ca.csr

#產生 CA 憑證
openssl x509 -req -days 365 -in ./csr/ca.csr -signkey ./keys/ca.key -CAcreateserial -out ./certs/ca.crt

# OCSP Responder 也需要自己的憑證
#產生 OCSP Responder 的 private key
openssl genrsa -out ./keys/ocsp.key 4096

#產生 OCSP Responder 的 CSR
openssl req -new -key ./keys/ocsp.key -out ./csr/ocsp.csr

#產生 OCSP Responder Server 的 Certificate,這裡使用了 ocsp_ext 區塊
#不使用 config 檔裡的 ocsp_ext 區塊設定的話,之後向 OCSP Responder Server 驗證 Certificate 時會有 missing ocspsigning usage 錯誤訊息)
#此時 ./db/index 會被更新,可以看到有一筆 OCSP Certificate 的憑證資料被寫入
openssl ca -config ca.conf -in ./csr/ocsp.csr -out ./certs/ocsp.crt -extensions ocsp_ext -days 365

#啟動 OCSP Responder Server,這邊使用了 locahost 的 9000 port
#啟動成功後應該可以看到 console 顯示
#ocsp: waiting for OCSP client connections...
#的訊息
openssl ocsp -port 9000 -index ./db/index -rsigner ./certs/ocsp.crt -rkey ./keys/ocsp.key -CA ./certs/ca.crt -text

#產生 Client private key:
openssl genrsa -out ./keys/client.key 4096

#產生 Client CSR:
openssl req -new -key ./keys/client.key -out ./csr/client.csr

#產生 Client Certificate,
#此時 ./db/index 會被更新,可以看到有一筆 Client Certificate 的憑證資料被寫入
openssl ca -config ca.conf -in ./csr/client.csr -out ./certs/client.crt -days 365

#用 OpenSSL 對 OCSP Responder Server 發起 OCSP Request 進行憑證的驗證
#對 OCSP Certificate 做驗證
openssl ocsp -issuer ./certs/ca.crt -CAfile ./certs/ca.crt -cert ./certs/ocsp.crt -url http://127.0.0.1:9000
#對 Client Certificate 做驗證
openssl ocsp -issuer ./certs/ca.crt -CAfile ./certs/ca.crt -cert ./certs/client.crt -url http://127.0.0.1:9000
#如果驗證結果是合法 (Valid) 的話,應該會看到如下訊息:
Response verify OK
./certs/client.crt: good
        This Update: Jun
#被 Revoke 的憑證查詢結果會像這樣:
#(Reason 只會在 Revoke 時有加入 crl_reason 時才會出現,手動修改 ./db/index 內容也可以,就是在 Revoke date 欄位加上 Reason 並用逗號分隔)
Response verify OK
./certs/client.crt: revoked
        This Update: Jun  6 04:55:19 2023 GMT
		Reason: unspecified
        Revocation Time: Jun  6 04:53:37 2023 GMT

#撤銷 (Revoke) 一個 Certificate (可以看到跟 CRL 的 Revoke 指令相同,-crl_reason 參數可自選要不要加上)
openssl ca -config ca.conf -revoke ./certs/client.crt

#加進一個未撤銷 (Valid) 狀態的 Certificate 到 OCSP DB 中 (自己要先手動把 OCSP DB 中相同序號的 Certificate 紀錄刪掉),
#或是自己打開 OCSP DB 檔把 Certificate 的狀態從 R (Revoke 的意思) 改成 V (Valid 的意思) 也可以
openssl ca -valid ./certs/client.crt -config ca.conf

參考資料:

Client Certificate 的 CRL (Certificate Revocation List) 設定

 這篇是
使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範
這篇文章的 CRL 設定補充。

如果要看 OCSP 的設定可以看我的另一篇文章:

Client Certificate 的 OCSP (Online Certificate Status Protocol) 設定

CRL 的全名是 Certificate Revocation List,
顧名思意是一個憑證撤銷狀態的列表,
有時雖然憑證的日期沒有過期、簽名驗證也都正確,
但是因為一些其他因素,例如 Private Key 被駭客偷走的憑證安全性問題等,
憑證可能會被 CA 認為需要強制撤銷 (Revoke),
這時我們除了認證憑證合法以外,還需要有方法來確認憑證的 Revoke 狀態,
CRL 就是其中一種方法,其他還有像是 OCSP 的方法,會在我的另一篇文XXXXX 中做介紹。

在 CRL 方法中,CA 會定期更新憑證的撤銷列表到一個 CRL 檔案中,通常會以 pem 格式儲存,並且以 CA 來進行簽章 (Sign) 來證明其是由 CA 頒發的。

當我們要確認憑證的撤銷狀態時,通常會在憑證中找到 CRL 檔案的位置 (通常是一個網路 url 的位置),憑證中也有可能沒有寫上 CRL 資訊,這時有可能是 client, server 端已經彼些講好的位置,
拿到 CRL 檔案後,就可以在其中查找要確認的憑證撤銷狀態。

CRL 檔案或是憑證中可能還會有其他資訊,例如有效期限、下一次 CRL 更新日期等,我們可以在期限到後主動查找新發佈版本的 CRL。

CRL 有一些優點:
  1. 其中一個優點就是架構簡單、實現方便,CA 只要定時的更新 CRL 即可。
  2.  CRL 檔案容易取得,可能只是一個網路上的檔案,使用 HTTP Request 即可取得,取得後甚至可以把它在本地端,不用每次都去網路上抓取,等下次更新日或 CA 發表重大更新要求時再抓新版的 CRL。
但相對地其反面就是它的缺點:
  1. CA 如果不及時更新 CRL 檔 或是 詢問者不即時取得最新的 CRL 的話,即意味著 CRL 可能不是最新的,如果有憑證有安全問題即時的被 CA 公告需要 Revoke,沒更新 CRL 可能會無法即时知道。
  2. 另一個缺點是,因為 CRL 可能包含許多其他可能用不到的憑證狀態資訊,如果 CRL 檔很大,網路的傳送成本就會提高,也會讓我們在其中查找要確認的憑證時花較多的時間。

---------------------------------------------------------------------------------------------
現在要來示範要做 Client Certificate 時,產生 CRL 檔案和 Nginx 設定的方式。

Nginx 的設定非常簡單,接續這一篇文章的 Nginx 設定 使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範,只要再加上 ssl_crl 設定即可。
server {
   ......
   ssl_client_certificate "ca.crt";
   ssl_verify_client optional;
   ssl_crl "D:\xxx\xxx\ca.crl"; # CRL 檔案的位置,也可以是網路上的 url
   ......
}

接下來要來產生 CRL 檔案,

為了之後管理方便,在這裡我先建了幾個資料夾和檔案:
  1. ./certs:存放憑證的地方。
  2. ./csr:存放 CSR 的地方。
  3. ./crlnumber:存放 CRL 檔版本序號的地方。
  4. ./crl:存放 CRL 檔的地方。
  5. ./db/index:index 為我手動放置的一個空白檔案,作為一個紀錄憑證狀態的純文字檔,其實 CRL 可以不需要,主要給 OCSP 使用,但 CRL 也可從其中產生出 CRL 檔,也方便之後管理憑證狀態。
  6. ./keys:存放 Private Key 的地方。
  7. ./serial/serial:serial 為我手動放置的一個純文字檔案,其內容為一個單純的 serial number 序號,供憑證產生時的累加 Serial Key 使用,serial number 可視為對每一個 client certificate 的辨識唯一序號。
  8. ca.conf:供 openssl ca 指令用的配置檔,用以簡化指令參數長度及方便管理。
檔案內容例如可用以下指定 (自己用編輯器輸入也可以):
touch ./db/index
openssl rand -hex 16 > serial/serial
echo 1001 > crlnumber/crlnumber
因為會用到
openssl ca 指令,為了方便我們先來建立一個 config 配置檔供 openssl ca 指令參考,
這樣 openssl ca 指令就可以少打一些參數。

ca.conf:
[default]
name                    = root-ca
default_ca              = ca_config

[ca_config]
database                = db/index
serial                  = serial/serial
crlnumber               = crlnumber/crlnumber
default_crl_days        = 1
certificate             = certs/ca.crt
private_key             = keys/ca.key
new_certs_dir           = certs
default_md              = sha256
policy                  = ca_policy
unique_subject          = no

[ca_policy]
countryName             = match
stateOrProvinceName     = optional
organizationName        = match
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional
接著各項指令如下,
#產生 CA private key
openssl genrsa -out ./keys/ca.key 4096

#產生 CA CSR
openssl req -new -key ./keys/ca.key -out ./csr/ca.csr

#產生 CA 憑證
openssl x509 -req -days 365 -in ./csr/ca.csr -signkey ./keys/ca.key -CAcreateserial -out ./certs/ca.crt

#產生 CRL
openssl ca -gencrl -config ./ca.conf -out ./crl/ca.crl

#觀看 CRL 檔中的資訊
openssl crl -in ./crl/ca.crl -text -noout

#產生 Client private key
openssl genrsa -out ./keys/client.key 4096

#產生 Client CSR
openssl req -new -key ./keys/client.key -out ./csr/client.csr

#使用 ca.conf 產生 CA 憑證,這時 client 憑證可得到一個可識別的唯一序號, serial 裡的 serial number 也會改變,
#此時也會發現 db/index 被加進了一條此 client 憑證的資料,db/index 有變動時可以手動下指令再製作一次新版本的 CRL
openssl ca -config ./ca.conf -in ./csr/client.csr -out ./certs/client.crt -days 365

#向 CRL 檢查憑證是否 Valid or Revoke
openssl verify -crl_check -CRLfile ./crl/ca.crl -CAfile ./certs/ca.crt ./certs/client.crt

#撤銷 (Revoke) Client 憑證 -crl_reason 參數是 Revoke reason
#撤銷後 db/index 會更新,憑證資訊欄位的 V (Valid 的意思) 會變成 R (Revoke) 的意思,並且會被標上 Revoke Date 訊息
#記得再下指令產生一下新版本的 CRL
openssl ca -config ca.conf -revoke ./certs/client.crt -crl_reason unspecified
# Revoke reason 可選列表如下:
unspecified
keyCompromise
CACompromise
affiliationChanged
superseded
cessationOfOperation
certificateHold
removeFromCRL

參考資料:

2023年5月28日 星期日

建立 Java Dynamic Web Application JavaEE 的 Maven 專案

在 Eclipse 新增 Java Dynamic Web Application,
對 Project 按右鍵,選 
Configure --> Convert to Maven Project
(會有 webContent 資料夾)


直接用 Maven 指令產生專案 
(我要加雙引號包住參數,不然會有
The goal you specified requires a project to execute but there is no POM in this directory
的錯誤訊息):
mvn archetype:generate "-DarchetypeGroupId=org.apache.maven.archetypes" "-DarchetypeArtifactId=maven-archetype-webapp" "-DarchetypeVersion=1.4" "-DgroupId=你專案要取的groupId" "-DartifactId=你專案要取的artifactId" "-Dversion=0.1" "-DinteractiveMode=false"


在 Eclipse 新增 Maven project,
Archetype 選 maven-archetype-webapp
(不會有 webContent 資料夾)

要 build 成 war 檔時,下載 maven 後,
到專案目錄下 (有 pom.xml 的位置),
執行命令列模式 (command line) 的
mvn clean install。 
如果 consle印出 "Build Success",
應可在專案裡的 "target" 資料夾找到 build 出來的程式,
應有完整已 compile 的 web 專案 (包括 lib),
即簡單佈署用的 war 檔。

如果想在 Eclipse 裡面 build ,可以使用 plugin,
在 pom.xml 加上

<plugin>
        <artifactId>maven-war-plugin</artifactId>
        <version>2.4</version>
 </plugin>

要 build 時,在 Eclipse 對專案按右鍵,選
Run Configuratoins --> 選 Maven,
Goals 欄位填 "clean install" ,再按 Run 。


參考:

2023年5月18日 星期四

使用 OpenSSL 製做 Client Certificate 做客戶端 TLS 認證 - 配合 Nginx 和 Java 做示範

在這裡我要來記錄一下使用 OpenSSL 製做 Client Certificate 做 客戶端 TLS 認證
(可參考 Mutual TLS (mTLS 或雙向認證) ) 認證的一些步驟事項。

首先第一件事是要安裝 OpenSSL 這個 tool,
使用以下指令安裝:
安裝 OpenSSL:

apt install openssl

在這裡我們為了方便演示及測試,
我們把自己當成憑證頒發機構 (Certificate authority,或稱 CA),
首先先產生 CA 的 private key,
取名為 my-ca-key.pem (或 my-ca.key,檔名可自取,副檔名一般會用 .pem 或 .key 來代表是一個 private key):

openssl genrsa -out my-ca-key.pem 4096

接下來產生自用的 Certificate Signing Request (CSR),在這邊命名為 my-ca.csr,
執行指令後被要求填入一些資訊,
要注意的是 Common Name 這個欄位不可以和等下 CA Certificate 要簽名的 Client CSR 一樣,
就是在 Certificate 的簽署鍵中各 Certificate 的 Common Name 不能一樣:

openssl req -new -key my-ca-key.pem -out my-ca.csr

再來我們執行以下指令用 CA 的 private key 對 CA CSR 來進行簽名,產生 CA 用的憑證 CA Certificate,檔名為 my-ca.crt,期限設為 365 天:

openssl x509 -req -days 365 -in my-ca.csr -signkey my-ca-key.pem -CAcreateserial -out my-ca.crt

這樣 CA 的東西都準備好了,我們現在應該會有三個檔案:

  1. my-ca-key.pem:CA 的 private key
  2. my-ca.csr:CA 的 CSR
  3. my-ca.crt:CA 的 certificate 憑證

CA 都準備好了以後,
接下來就是要來產生 Client 端的 Client private key、Client CSR、Client certificate,
基本上做法跟 CA 差不多,
差在是在要做 Client Certificate 時,Client CSR 要用 CA private key 和 CA Certificate 來簽名產生,不用給 Client private key。

首先是產生 Client private key,命名為 client-key.pem:

openssl genrsa -out client-key.pem 4096

再來是產生 Client CSR,命名為 client.csr,記得要填資訊欄位時,Common Name 不可以和 CA CSR 的一樣:

openssl req -new -key client-key.pem -out client.csr

最後我們使用 CA private key 和 CA Certificate 對  Client CSR 進行簽名產生 Client Certificate,
命名為 client.crt,期限設定 365 天:

openssl x509 -req -days 365 -in client.csr -CA my-ca.crt -CAkey my-ca-key.pem -CAcreateserial -out client.crt

在這邊我們可以用以下指令來檢查 Client Certificate 是不是由 CA Certificate 簽名的:

openssl verify -CAfile my-ca.crt client.crt

如果顯示 OK 的話,就代表正確。

基本上這樣 CA 和 Client 端的東西都準備好了,
不過有一些環境可能還會需要特別的 KeyStore 檔,KeyStore 裡會存放 private key 和 certificate 等資訊,例如 Java 就可能會需要 PKCS12 格式的 .p12 KeyStore 檔,
因為等下會用 Java 做 Client Certificate TLS 示範,
所以我們在這用以下指令將 Client private key 和 Client Certificate 打包成一個 .p12 的 KeyStore 檔案,這裡命名為 client-keystore.p12,
注意這裡會要求為 KeyStore 設定一個密碼,
等下 Java 程式會用到:

openssl pkcs12 -export -inkey client-key.pem -in client.crt -out client-keystore.p12

到這裡 Client 端的部份我們應該會有以下四個檔案:

  1. client-key.pem:Client private key
  2. client.csr:Client CSR
  3. client.crt:由 CA Certificate 和 CA private 簽名的 Client Certificate
  4. client-keystore.p12:裝了 Client private key 和 Client Certificate 的 KeyStore

先來示範在 Server 的 Nginx 設定要求 Client Server 要提供 Client Certifcate,
在 Nginx 的設定檔中,server 區塊的設定可以加上
ssl_client_certificate 和
ssl_verify_client
的 Client Certificate 設定:

server {
   ......
   ssl_client_certificate "CA 憑證路徑,例如上述產生的 my-ca.crt";
   ssl_verify_client on; #強制開啟 Client Certificate 要求
   ......
}

或是 ssl_verify_client 也可以不要設定 on,改設定
optional,
也就是不強制要求 Client Certificate,
但還是會檢查 Client 端有無帶上 Client Certificate,
 ,然後用 $ssl_client_verify 的值是不是 SUCCESS 來判斷有沒有認證成功,
範例如下:

server {
   ......
   ssl_client_certificate "CA 憑證路徑,例如上述產生的 my-ca.crt";
   ssl_verify_client optional; #不強制要求 Client Certificate
   ......
   #只有 /xx/xx/xx1 的 url 請求才檢查 Client Certificate
   location /xx/xx/xx1 { 
      ......
      if ($ssl_client_verify != SUCCESS) { #用 $ssl_client_verify 判斷 Client 端有無帶上 Client Certificate
         return 401;
      }
      ......
   }

   # /xx/xx/xx2 的 url 請求不檢查 Client Certificate
   location /xx/xx/xx2 {
      ......
   }
}

在 Client 端的部份,我們使用 Java 帶上 Client Certificate 來對 Server 端發出 Https 請求,
如果請求成功,應該會收到 Server 端的 200 HTTP response status:

Maven Dependency 配置:

 <!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
 <dependency>
     <groupId>org.apache.httpcomponents</groupId>
     <artifactId>httpclient</artifactId>
     <version>4.5.13</version>
 </dependency>

Java 程式碼:

package test;

import java.io.FileInputStream;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import javax.net.ssl.SSLContext;

import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContexts;

public class ClientCertificateSSLTest {

	public static void main(String[] args) throws ClientProtocolException, IOException, NoSuchAlgorithmException, CertificateException, KeyManagementException, UnrecoverableKeyException, KeyStoreException {
		String keyStorePassphrase = "12345"; // Client KeyStore 的密碼
		String keyStorePath = "D:\\xxx\\client-keystore.p12"; // Client KeyStore 的路徑

		KeyStore keyStore = KeyStore.getInstance("PKCS12");
		keyStore.load(new FileInputStream(keyStorePath), keyStorePassphrase.toCharArray());
		
		SSLContext sslContext = SSLContexts.custom()
		        .loadKeyMaterial(keyStore, keyStorePassphrase.toCharArray())
                //如果 keystore 中存有多個憑證,可自行用 Alias 選擇特定憑證
//		        .loadKeyMaterial(keyStore, keyPassphrase.toCharArray(), (aliases, socket) -> {return "chosenAlias";})
		        .build();

//		CloseableHttpClient httpClient = HttpClients.createDefault(); // 如果不帶上 Client Certificate,Server 端就會拒絕連線
		CloseableHttpClient httpClient = HttpClients.custom().setSSLContext(sslContext).build();
		
//		HttpPost httpPost = new HttpPost("https://xxx.xxx.xxx/xx/xx/xx2"); // 這條 url 可以不用帶上 Client Certificate
		HttpPost httpPost = new HttpPost("https://xxx.xxx.xxx/xx/xx/xx1"); // 這條 url 需要帶上 Client Certificate
		HttpResponse response = httpClient.execute(httpPost);
		System.out.println(response.getStatusLine()); // 成功連線應該要回 200 HTTP response status
	}

}


參考資料:

  1. OpenSSL create client certificate & server certificate with example

2023年5月8日 星期一

設計長寬等比例隨視窗大小縮放的 DOM 元素(使用 aspect-ratio 或 padding)

當要設計一個長寬等比例隨視窗大小縮放的 DOM 元素時,
可以使用最新的 CSS 屬性:

aspect-ratio

來達成,
例如現在想要放入一個 Youtube 的 iframe,

<iframe src="https://www.youtube.com/embed/Cu8NnGwYZp0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen=""></iframe>

寬度要是外層元素的 80%,寬高比要是 16:9,
可以這樣設定:

HTML:

<iframe class="aspect-ratio"  src="https://www.youtube.com/embed/Cu8NnGwYZp0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen=""></iframe>

CSS:

.aspect-ratio {
  aspect-ratio: 16 / 9;
  width: 80%;
}


不過像在以前還沒有 aspect-ratio 的時代,
有一個蠻常用的、利用了 padding 的特性來實現的方法,
特別在這邊記錄一下。

首先先上 HTML 和 CSS:

HTML:

<div class="wrapper">
  <div class="box">
      <iframe class="box-in" src="https://www.youtube.com/embed/Cu8NnGwYZp0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen=""></iframe>
  </div>
</div>

CSS:

.wrapper {
  width: 80%;
}

.box {
  width: 100%;
  padding-top: 56.25%;
  height: 0;
  position: relative;
}

.box-in {
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
}

接著把上述兩種方法放上在 JsFiddle 的成品來做為比較:

說明:
使用 padding 可以達成等比例 DOM 的原理是,
當元素的 height 是 0 ,並且沒有設定 border 和 margin 等時,
此時示素的高度就由它的 padding-top 或 padding-bottom 決定,
而 padding-top 此時會由它的父元素之寬來做為參考計算,
在上述的例子中,父示素就是 class="wrapper",width 是 80%,在這裡也就是 <body> 寬的 80%,
而 class="box" 設定了 padding-top=56.25%,也就是 9/ 16,
這樣它的高度就會是父元素的寬乘上 9 / 16,然後它的寬會是父元素的寬。

在 class="box" 裡我們還要再設定一個 class="box-in" 元素,
因為 class="box" 有設定 padding-top 的關係,所以位置會被往下推,
要解決這個,我們可以先將 class="box" 設為 position=relative 做為子元素位置 left, top 的參考點,
然後在 class="box-in" 上設定
position=absolute
left = 0
top = 0
來將 class="box-in" 往上放至正確的位置,
接著在 class="box-in" 中就可以放上想要放的 Youtube iframe 了。

2023年4月25日 星期二

Java EE 使用 Spring 將非根目錄的 url pattern 送給 DispatcherServlet 時要注意的事情 - 使用 RequestMappingHandlerMapping 的 alwaysUseFullPath 設定

在 Java EE 使用 Spring 時,雖然大部份情況是在專案的 web.xml 
裡設定

<url-pattern>/</url-pattern>

把所有的 url request 送進 DispatcherServlet 去處理,
但有時我們只想把某個 url pattern 之下的 url 送給 DispatcherServlet 處理 (例如你可能有多個 DispatcherServlet,或是想在沒使用 Spring 的專案加進有使用 Spring 的 API url),
這時我們可能會這樣設,例如:

<url-pattern>/api/*</url-pattern>

此例代表符合 /api/* 的 url 才會交給 DispatcherServlet 處理,如下範例:

<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>/api/*</url-pattern>
	</servlet-mapping>
這時你可能會發現 /api/test 的 url request 無法進入到以下 Controller 中而回應 404 No mapping for GET /api/test:
package test;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class SpringControllerTest {
	
	@RequestMapping(value="/api/test" , method = {RequestMethod.GET})
	public @ResponseBody String login(HttpServletRequest request, HttpServletResponse httpResp) throws Exception{
		System.out.println("Hello");
		return "Hello";
	}
}

這是因為根據 Spring MVC 的 Handler mappings 規則

alwaysUseFullPath
if this property is set to true, Spring will use the full path within the current servlet context to find an appropriate handler. If this property is set to false (the default), the path within the current servlet mapping will be used. For example, if a servlet is mapped using /testing/* and the alwaysUseFullPath property is set to true, /testing/viewPage.html would be used, whereas if the property is set to false, /viewPage.html would be used.

,當 alwaysUseFullPath 參數值是預設的 false 時,
DispatcherServlet 會將 url request 扣掉 web.xml 的 <url-pattern> 所設值 (此例就是 /api,/api/test 扣掉 /api 後是 /test),
然後才會跟 @RequestMapping 所設的 value 值做比對,
也就是此時 @RequestMapping 的 value 需要設成 /test 才會比對成功。

如果要比對 @RequestMapping value="/api/test" 成功的話, url request 需要是
/api/api/test 才行。

上例是 alwaysUseFullPath = false (default) 的情況,
如果這次我們把 alwaysUseFullPath 設成 true 的話,
DispatcherServlet 就不會將 url request 扣掉 web.xml 設定的 <url-pattern> 值,
這時 /api/test 的 url request 就可以被比對到而進入 Controller 中了,
設定 alwaysUseFullPath=true 的方式為在 spring config 檔中加下如下設定:

<bean id="requestMappingHandlerMapping" class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping">
       <property name="alwaysUseFullPath" value="true"/>
    </bean>  

參考資料:

  1. 16. Web MVC framework - 16.4 Handler mappings - alwaysUseFullPath

2023年2月9日 星期四

使用 docker-compose 建立 Redis Cluster

 先建立如下的資料結構:

專案目錄名為 "Redis Cluster Test",
裡面放了 docker-compose 所需的 docker-compose.yml 檔和一個名為 redis 的資料夾,
在 redis 的資料夾中放置 Redis node 所需的 redis.conf 客制設定檔
和 docker service 的 Dockerfile 設定檔。

以下是各檔的內容 (以 Redis Cluster Test 為專案根目錄):

/redis/Dockerfile:
FROM redis
COPY redis.conf /usr/local/etc/redis/redis.conf 
CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ]
裡面把 redis.conf 放至 Docker container 中,並指定了使用 redis.conf 執行 redis-server 啟動 Redis 服務。

/redis/redis.conf:
cluster-enabled yes
裡面的內容很簡單,只有一行,使用 cluster-enabled yes 開啟 Cluster 功能。

/docker-compose.yml:
version: '3.4'
services:
    redis-node-1:
        build: ./redis 
        container_name: redis-node-1
        ports: 
            - 6379:6379 
            - 16379:16379 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6379", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-2:
        build: ./redis 
        container_name: redis-node-2
        ports: 
            - 6380:6380 
            - 16380:16380
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6380", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-3:
        build: ./redis 
        container_name: redis-node-3
        ports: 
            - 6381:6381 
            - 16381:16381 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6381", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-4:
        build: ./redis 
        container_name: redis-node-4
        ports: 
            - 6382:6382 
            - 16382:16382 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6382", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-5:
        build: ./redis  
        container_name: redis-node-5
        ports: 
            - 6383:6383 
            - 16383:16383 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6383", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-6:
        build: ./redis  
        container_name: redis-node-6
        ports: 
            - 6384:6384 
            - 16384:16384 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6384", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-cluster-creator:
        image: redis
        container_name: redis-cluster-creator
        entrypoint: [/bin/sh, -c, 'echo "yes" | redis-cli --cluster create host.docker.internal:6379 host.docker.internal:6380 host.docker.internal:6381 host.docker.internal:6382 host.docker.internal:6383 host.docker.internal:6384 --cluster-replicas 1']
        depends_on: 
          - redis-node-1 
          - redis-node-2
          - redis-node-3 
          - redis-node-4
          - redis-node-5 
          - redis-node-6
在 docker-compose.yml 中,設置了 6 個 Redis node 作為 Redis Cluster 之用,
6 個 Redis node 全部都是使用同一個 Dockerfile 和 redis.conf 所建構出來的,
這邊要注意的是 ports 需要向外開放兩個 port,
第一個 port 是用來跟 client 端通信的,例如 6379、6380 等,這是我們平常要登入 Redis 時會用到的 port,暫且稱做 client port 好了。
第二個 port 官方名字叫做 Cluster bus port, 是用來讓 Redis node 跟 node 之間通信用的,預設為 client port 再加上 10000,不過也可以在 redis.conf 檔裡用 cluster-port 參數來修改,
可參考官方的說明: Scaling with Redis Cluster 。

<your server IP or hostname> 請用 docker 所在的 server host IP 或 hostname 來取代,
測試時可用 host.docker.internal (在 docker container 中可用此 hostname 連上 server host) 來測試 (實務上請用真正的 IP 或 hostname)。

redis-cluster-creator 只是一個用來執行完 Redis Cluster 設置建立 Cluster 後就關掉的 docker service,
使用 depends_on 等待各 Redis node 啟動完成後,便會執行以下命令:
echo "yes" | redis-cli --cluster create host.docker.internal:6379 host.docker.internal:6380 host.docker.internal:6381 host.docker.internal:6382 host.docker.internal:6383 host.docker.internal:6384 --cluster-replicas 1

以上命令相當於是先執行了
redis-cli --cluster create host.docker.internal:6379 host.docker.internal:6380 host.docker.internal:6381 host.docker.internal:6382 host.docker.internal:6383 host.docker.internal:6384 --cluster-replicas 1

再執行手動輸入的 yes,因為 redis-cli --cluster create 執行中會詢問
Can I set the above configuration? (type 'yes' to accept): 
需要手動輸入 yes 才能繼續下去。

redis-cli --cluster create 是建立 cluster 的指令,它將各 Redis node 連結起來成為 Cluster,
參數意義如下:
redis-cli --cluster create <node-1 IP or hostname:<port> <node-2 IP or hostname:<port> ....<可多個 Redis node>...... --cluster-replicas <一個 Redis master node 要有幾個依附的 Redis slave node>

它會自己選擇哪些要做為 Redis master node,哪些要做為 Redis slave node。

參考資料:

  1. Redis - Official Image | Docker Hub
  2. Redis configuration file example | Redis
  3. Scaling with Redis Cluster
  4. Redis|用 Docker 架設 Redis Cluster

2023年2月8日 星期三

Redis 好用指令紀錄

#檢查 Cluster 情況 (未登入 Redis node 時):

redis-cli --cluster check <redis node domain or IP>:<port>

範例:

redis-cli --cluster check xxx.xxx.xxx:6379

修復 Redis Server Cluster Slot 分配不正常等 node 問題:

redis-cli --cluster fix xxx.xxx.xxx:6379

回應範例(以下為三組 Master node 和 Slave node,實際上可能會有更多組):

xxx.xxx.xxx:6379 (1ba418d4...) -> 4 keys | 5461 slots | 1 slaves.
xxx.xxx.xxx:6381 (4131a66e...) -> 4 keys | 5461 slots | 1 slaves.
xxx.xxx.xxx:6380 (ba5a4554...) -> 4 keys | 5462 slots | 1 slaves.
[OK] 12 keys in 3 masters.
0.00 keys per slot on average.
>>> Performing Cluster Check (using node xxx.xxx.xxx:6379)
M: 1ba418d421ac322943423091360c909a549b3d39 xxx.xxx.xxx:6379
   slots:[0-5460] (5461 slots) master
   1 additional replica(s)
S: 431b199172871c4c61f4e2f48ed1b9117dd3e801 xxx.xxx.xxx:6384
   slots: (0 slots) slave
   replicates 1ba418d421ac322943423091360c909a549b3d39
S: 2fb427cf8acb3521c67c45fc2cfe1ac2a669a3c0 xxx.xxx.xxx:6383
   slots: (0 slots) slave
   replicates 4131a66e5cadfd91c48b824d5288ddb1445d8bd1
M: 4131a66e5cadfd91c48b824d5288ddb1445d8bd1 xxx.xxx.xxx:6381
   slots:[10923-16383] (5461 slots) master
   1 additional replica(s)
M: ba5a4554bc90d750de37a5da39b93dca896bab72 xxx.xxx.xxx:6380
   slots:[5461-10922] (5462 slots) master
   1 additional replica(s)
S: 70712afa54623608772bafd1926305e895d7d076 xxx.xxx.xxx:6382
   slots: (0 slots) slave
   replicates ba5a4554bc90d750de37a5da39b93dca896bab72
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
在回應中, "M" 代表 Master node、"S" 代表依附於 Master node 的 "Slave node",
Slave node 的 replicates 值指出了它依附的是哪一個 Master node

#登入 Redis node (會進入 Redis 互動 command line 模式,"-c" 代表 Cluster Mode):

redis-cli -h xxx.xxx.xxx -p 6379 -c

#查看 Cluster 狀態(登入 Redis node 後):

cluster info

回應範例:

cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:3649
cluster_stats_messages_pong_sent:3775
cluster_stats_messages_sent:7424
cluster_stats_messages_ping_received:3770
cluster_stats_messages_pong_received:3646
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:7421
total_cluster_links_buffer_limit_exceeded:0
#啟動 Redis node:
redis-server <Redis config 檔的位置> --port <Redis node 的 port> --cluster-announce-ip "<Redis node 的 IP 或 hostname>"
範例:
redis-server /usr/local/etc/redis/redis.conf --port "6379" --cluster-announce-ip "xxx.xxx.xxx"
#建立 Redis Cluster:
redis-cli --cluster create <Redis node-1 IP or hostname:<port> <Redis node-2 IP or hostname:<port> ...<可多個 Redis node>....... --cluster-replicas <一個 Redis master node 要有幾個依附的 Redis slave node>
範例:
redis-cli --cluster create xxx.xxx.xxx:6379 xxx.xxx.xxx:6380 xxx.xxx.xxx:6381 xxx.xxx.xxx:6382 --cluster-replicas 1

#清除cluster中所有 node 的 key資料 (等同於對每個 cluster 中的所有 node 執行 flushall)
redis-cli --cluster call <cluster 中的任一node ip 或 hostname>:<Redis node 的 port> flushall
範例:
redis-cli --cluster call xxx.xxx.xxx:6379 flushall