MeteorCat / Java的API初始化

Created Thu, 09 Nov 2023 23:13:12 +0800 Modified Wed, 29 Oct 2025 23:25:00 +0800
2288 Words

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:xxxxxxxxxagent 提供操作, 这里数据库相关应该基本完成.

控制器接收

之后就是最后挂载服务对客户端处理, 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}

这里就是简单配置 SpringAPI 基础设计, 之后就是扩展其框架功能.