Java的API初始化
这里用来初始化 API 接口项目, 具体用来做简单 JSON 请求响应, 初始化项目集成 MariaDB/Redis 做数据落地和缓存.
pom.xml 配置先配置下组件:
<!-- 主要的安装部件包 -->
<dependencies>
<!-- Web访问组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 提交数据传入验证 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JPA的ORM组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Redis组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MariaDB驱动 -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 快速Get/Set工具 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 主要配置启动方法入口, 这个最好配置防止打包之后找不到启动入口 -->
<mainClass>com.meteorcat.api.ApiApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</dependencies>
这里就是基础的配置依赖项, 之后就是 application.properties 启动配置项目:
# 请求访问入口
spring.application.name=MixApi
server.port=8080
spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false
# 数据库配置
spring.datasource.url=jdbc:mariadb://127.0.0.1:3306/game
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.hikari.maximum-pool-size=4
spring.datasource.tomcat.init-s-q-l=SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
# JPA配置, ddl-auto 正式环境注意切换 validate, 因为表最好手动修改操作
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
# Redis配置
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
#spring.data.redis.username=
#spring.data.redis.password=
spring.data.redis.database=1
spring.data.redis.timeout=5s
spring.data.redis.connect-timeout=5s
spring.data.redis.lettuce.pool.min-idle=0
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.max-active=4
spring.data.redis.lettuce.pool.max-wait=-1ms
# 日志等级,正式环境可以考虑切换 info 或者 error
logging.level.web=debug
logging.level.com.meteorcat=debug
开发这里的时候按照这配置即可, 正式模式按照需要需要修改配置.
编写部件
这里需要响应 JSON 格式工具 src/main/java/com/meteorcat/api/utils/JsonResponse.java:
package com.meteorcat.api.utils;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import java.io.Serializable;
import java.util.HashMap;
/**
* 响应工具类
*/
@Getter
public class JsonResponse implements Serializable {
private final Integer status;
private final String message;
private final Object data;
private static final HashMap<Object, Object> EMPTY = new HashMap<>(0);
private JsonResponse(Integer status, String message, Object data) {
this.status = status;
this.message = message;
this.data = data;
}
public static JsonResponse response(Integer status, String message, Object data) {
return new JsonResponse(status, message, data);
}
public static JsonResponse response(HttpStatus status, String message, Object data) {
return new JsonResponse(status.value(), message, data);
}
public static JsonResponse response(Integer status, String message) {
return JsonResponse.response(status, message, null);
}
public static JsonResponse response(HttpStatus status, String message) {
return JsonResponse.response(status.value(), message, null);
}
public static JsonResponse fail(String message) {
return JsonResponse.response(HttpStatus.BAD_REQUEST, message, null);
}
public static JsonResponse success(Object data) {
return JsonResponse.response(HttpStatus.OK, HttpStatus.OK.getReasonPhrase(), data);
}
public static JsonResponse success() {
return JsonResponse.success(EMPTY);
}
}
这里我采用 HTTP 响应码直接返回状态, 默认 success = 200, fail = 400 这种方式.
获取客户端的IP地址也需要额外封装工具来处理, 因为大部分 JAVA
代理转发所以需要直接识别转发的IP, src/main/java/com/meteorcat/api/utils/ClientIpAddress.java:
package com.meteorcat.api.utils;
import jakarta.servlet.http.HttpServletRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
public class ClientIpAddress {
private static final String UNKNOWN = "unknown";
private static final String LOCALHOST_IP = "127.0.0.1";
// 客户端与服务器同为一台机器,获取的 ip 有时候是 ipv6 格式
private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
private static final String SEPARATOR = ",";
/**
* 根据 HttpServletRequest 获取 IP
* @param request 请求句柄
* @return String
*/
public static String getIpAddress(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.isEmpty() || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Forwarded-For");
}
if (ip == null || ip.isEmpty() || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
if (LOCALHOST_IP.equalsIgnoreCase(ip) || LOCALHOST_IPV6.equalsIgnoreCase(ip)) {
// 根据网卡取本机配置的 IP
InetAddress iNet = null;
try {
iNet = InetAddress.getLocalHost();
} catch (UnknownHostException e) {
e.printStackTrace();
}
if (iNet != null)
ip = iNet.getHostAddress();
}
}
// 对于通过多个代理的情况,分割出第一个 IP
if (ip != null && ip.length() > 15) {
if (ip.indexOf(SEPARATOR) > 0) {
ip = ip.substring(0, ip.indexOf(SEPARATOR));
}
}
return LOCALHOST_IPV6.equals(ip) ? LOCALHOST_IP : ip;
}
}
之后还需要哈希加密工具, 用来验签或者哈希处理, src/main/java/com/meteorcat/api/utils/Hasher.java:
package com.meteorcat.api.utils;
import org.springframework.util.DigestUtils;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
/**
* 哈希工具
*/
public class Hasher implements Serializable {
@Serial
private static final long serialVersionUID = 391863799940276467L;
/**
* 密码加密
*
* @param password 源密码
* @return String
*/
public static String hashPassword(String password) {
String hash = String.format("%d-%s-%d", serialVersionUID, password, serialVersionUID);
return DigestUtils.md5DigestAsHex(hash.getBytes());
}
/**
* 随机生成MD5
*
* @return String
*/
public static String hashRandomToken() {
UUID uuid = UUID.randomUUID();
String hash = String.format("%d-%s-%d", serialVersionUID, uuid, System.currentTimeMillis());
return DigestUtils.md5DigestAsHex(hash.getBytes());
}
}
最后补个日期时间戳的工具, src/main/java/com/meteorcat/api/utils/Datetimes.java:
package com.meteorcat.api.utils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 日期事件处理
*/
public class Datetimes {
/**
* 获取时间戳
* @return Integer
*/
public static Integer timestamp() {
return Math.toIntExact(System.currentTimeMillis() / 1000L);
}
/**
* 获取 y.m.d 格式版本
* @return String
*/
public static String dateVersion() {
DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy.MM.dd");
return df.format(LocalDateTime.now());
}
}
这里基本上就完成所需的组件, 之后就是业务方向的编写.
数据库设计
这里采用 JPA 方向 ORM 设计, 直接先设计数据库模型, src/main/java/com/meteorcat/api/models/UserInfoModel.java:
package com.meteorcat.api.models;
import jakarta.persistence.*;
import lombok.Data;
import java.io.Serializable;
@Data
@Entity
@Table(name = "tbl_user_info")
public class UserInfoModel implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
@Column(nullable = false, length = 64, unique = true)
private String username;
@Column(nullable = false, length = 32)
private String password;
@Column(nullable = false)
private Integer createTime;
@Column(nullable = false, length = 64)
private String createIpAddress;
@Column(nullable = false)
private Integer updateTime = 0;
@Column(nullable = false, length = 64)
private String updateIpAddress = "";
@Column(nullable = false, length = 64)
private String token = "";
}
这里简单设计数据 ORM 表,
之后就是关联设计数据模型, src/main/java/com/meteorcat/api/repository/UserInfoRepository.java:
package com.meteorcat.api.repository;
import com.meteorcat.api.models.UserInfoModel;
import org.springframework.data.repository.CrudRepository;
/**
* 用户信息请求仓库
*/
public interface UserInfoRepository extends CrudRepository<UserInfoModel, Long> {
/**
* 检索玩家
* @param username 玩家名称
* @return UserInfoModel|null
*/
UserInfoModel findFirstByUsername(String username);
}
这里就完成数据库 ORM 关联对象, 这里还有自定义 Redis
映射保存, src/main/java/com/meteorcat/api/cache/UserInfoCache.java:
package com.meteorcat.api.cache;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.meteorcat.api.models.UserInfoModel;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Component;
import java.util.Set;
@Component
public class UserInfoCache extends RedisTemplate<String, UserInfoModel> {
public UserInfoCache(RedisConnectionFactory redisConnectionFactory) {
super.setConnectionFactory(redisConnectionFactory);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
serializer.configure(objectMapper -> {
objectMapper.registerModule(new JavaTimeModule());
});
// KV类型序列化
super.setKeySerializer(StringRedisSerializer.UTF_8);
super.setValueSerializer(serializer);
// Hash类型序列化
super.setHashKeySerializer(StringRedisSerializer.UTF_8);
super.setHashValueSerializer(serializer);
}
/**
* 启动的时候清空对应的Keys
*/
public void afterPropertiesSet() {
super.afterPropertiesSet();
Set<String> keys = this.keys("user:*");
if (keys != null) {
for (String key : keys) {
this.delete(key);
}
}
}
public String getKey(String token) {
return String.format("user:%s", token);
}
}
这里会在玩家登录时候在 Redis 中生成 user:xxxxxxxxx 的 agent 提供操作, 这里数据库相关应该基本完成.
控制器接收
之后就是最后挂载服务对客户端处理, src/main/java/com/meteorcat/api/controllers/user/BasicUserController.java:
package com.meteorcat.api.controllers.user;
import com.meteorcat.api.cache.UserInfoCache;
import com.meteorcat.api.models.UserInfoModel;
import com.meteorcat.api.repository.UserInfoRepository;
import com.meteorcat.api.utils.ClientIpAddress;
import com.meteorcat.api.utils.Datetimes;
import com.meteorcat.api.utils.Hasher;
import com.meteorcat.api.utils.JsonResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
@RestController
@RequestMapping("/user/basic")
public class BasicUserController {
static Logger logger = LoggerFactory.getLogger(BasicUserController.class);
final UserInfoRepository userInfoRepository;
final UserInfoCache userInfoCache;
public BasicUserController(UserInfoRepository userInfoRepository, UserInfoCache userInfoCache) {
this.userInfoRepository = userInfoRepository;
this.userInfoCache = userInfoCache;
}
/**
* 请求结构体, 这里依赖 validation 做请求数据认证
*/
@Data
public static class BasicUserForm {
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "账号名称只允许字母数字")
@NotBlank(message = "账号不能为空")
@Length(message = "账号必须在{min}-{max}这个字符之中", min = 5, max = 64)
public String username;
@NotBlank(message = "密码不能为空")
@Length(message = "账号必须在{min}-{max}这个字符之中", min = 5, max = 64)
public String password;
}
/**
* 注册方法, BindingResult 就是 validation 拦截的异常错误, 可以判断是否有错误才能继续跑后续业务
*
* @param userForm 请求 Form
* @param result 验证结果
* @return JSON
*/
@RequestMapping("/login")
public Object login(@Valid BasicUserForm userForm, BindingResult result, HttpServletRequest request) {
List<FieldError> fieldErrors = result.getFieldErrors();
if (!fieldErrors.isEmpty()) {
return JsonResponse.fail(fieldErrors.get(0).getDefaultMessage());
}
// 没有账号直接帮助注册
UserInfoModel userInfoModel = userInfoRepository.findFirstByUsername(userForm.username);
if (userInfoModel == null) {
logger.debug("register(basic): {}", userForm);
userInfoModel = new UserInfoModel();
userInfoModel.setUsername(userForm.username);
userInfoModel.setPassword(Hasher.hashPassword(userForm.password));
userInfoModel.setCreateTime(Datetimes.timestamp());
userInfoModel.setCreateIpAddress(ClientIpAddress.getIpAddress(request));
userInfoModel = userInfoRepository.save(userInfoModel);
}
// 清空之前的登录
if (!userInfoModel.getToken().isEmpty()) {
String key = userInfoCache.getKey(userInfoModel.getToken());
userInfoCache.delete(key);
}
// 登录请求
logger.info("login(basic): {}", userInfoModel);
String token = Hasher.hashRandomToken();
userInfoModel.setToken(token);
userInfoModel.setUpdateTime(Datetimes.timestamp());
userInfoModel.setUpdateIpAddress(ClientIpAddress.getIpAddress(request));
userInfoModel = userInfoRepository.save(userInfoModel);
// 写入Redis
String key = userInfoCache.getKey(userInfoModel.getToken());
userInfoCache.opsForValue().set(key, userInfoModel);
// 响应数据
return JsonResponse.response(HttpStatus.OK, "Ok", new HashMap<>() {{
put("username", userForm.username);
put("token", token);
}});
}
}
这里请求就能直接生成数据库 Agent 挂载 Redis:
curl -d "username=test1234&password=test1234" "http://127.0.0.1:8080/user/basic/login"
# 这里就会响应JSON类型数据:
# {"status":200,"message":"Ok","data":{"username":"test1234","token":"69b244f4e18ad65d829c7edb679cea84"}}
实际上这里已经完成大部分功能了, 之后就是全局错误拦截清空.
错误拦截
这里需要拦截系统所有错误之后按照我们自己设计的 JSON 规则响应, src/main/java/com/meteorcat/api/config/ControllerAdviceConfig.java:
package com.meteorcat.api.config;
import com.meteorcat.api.utils.JsonResponse;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import java.util.List;
import java.util.Set;
/**
* 全局错误拦截器
*/
@RestControllerAdvice
public class ControllerAdviceConfig {
Logger logger = LoggerFactory.getLogger(ControllerAdviceConfig.class);
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
public JsonResponse bindExceptionHandler(MethodArgumentNotValidException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<String> collect = fieldErrors.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.toList();
if (collect.isEmpty()) {
return JsonResponse.fail(HttpStatus.BAD_REQUEST.getReasonPhrase());
} else {
return JsonResponse.fail(collect.stream().findFirst().get());
}
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public JsonResponse methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<String> collect = fieldErrors.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.toList();
if (collect.isEmpty()) {
return JsonResponse.fail(HttpStatus.BAD_REQUEST.getReasonPhrase());
} else {
return JsonResponse.fail(collect.stream().findFirst().get());
}
}
@ExceptionHandler(ConstraintViolationException.class)
public JsonResponse constraintViolationExceptionHandler(ConstraintViolationException e) {
Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();
List<String> collect = constraintViolations.stream()
.map(ConstraintViolation::getMessage)
.toList();
if (collect.isEmpty()) {
return JsonResponse.fail(HttpStatus.BAD_REQUEST.getReasonPhrase());
} else {
return JsonResponse.fail(collect.stream().findFirst().get());
}
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public JsonResponse exceptionHandler(Exception e) {
e.printStackTrace();
return JsonResponse.response(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
}
@ResponseStatus(value = HttpStatus.NOT_FOUND)
@ExceptionHandler(NoHandlerFoundException.class)
public JsonResponse exceptionHandler(NoHandlerFoundException e) {
logger.error(e.getMessage());
return JsonResponse.response(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase());
}
}
现在随便请求个不存在 404 响应页面也会直接返回指定错误:
# 请求不存在的页面
curl "http://127.0.0.1:8080/not_found/error"
# 默认就会响应我们自定义的错误拦截器
# {"status":404,"message":"Not Found","data":null}
这里就是简单配置 Spring 的 API 基础设计, 之后就是扩展其框架功能.