[Spring Boot] Expense API 实现

[Spring Boot] Expense API 实现

项目地址:expense-api

项目简介

最近跟着视频做的一个 spring boot 的项目,包含了比较简单的记账功能的实现(只限 API 部分),具体实现的功能有:

  • 记账(expenses API)
  • 类型(categories API)
  • 用户(登录/注册/更新/删除 API)

整体的验证是通过 JWT Token 去实现的,没有实现管理员权限。

使用了:

  • java17
  • spring boot 3.4.1
  • json web token 0.12.6
  • map struct 1.6.3
  • lombok 1.18.36
  • lombok map struct binding 0.2.0
  • docker

运行方式可以直接跑根目录下的 start.sh,脚本会运行 mvnw clean package 指令打包 spring boot jar 文件。然后运行对应的 docker compose 文件生成对应的容器:

bash 复制代码
❯ docker ps
CONTAINER ID   IMAGE                                            COMMAND                  CREATED        STATUS                  PORTS                               NAMES
a9fd6ebf70ec   expense-tracker-springboot-expense-tracker-api   "java -jar /expense_..."   14 hours ago   Up 14 hours             0.0.0.0:8080->8080/tcp              springboot-expense-tracker-api
fc2f4f82f581   mysql:8.3.0                                      "docker-entrypoint.s..."   14 hours ago   Up 14 hours (healthy)   0.0.0.0:3306->3306/tcp, 33060/tcp   mysql-expense-tracker

然后可以通过 postman 导入存在根目录下的 Expense Manager API.postman_collection.json 进行 API 的测试:

总体来说这次是把之前断断续续学的 spring boot 3 通过做项目的方式进行了一个整合,并且简单的学习了一下 DTO 模式和基于 JWT 实现的用户验证。之前在 [spring] rest api security 中学习的是使用默认的 Spring Security 进行用户验证,这次使用了 CustomUserDetailsService,可以使用更加灵活的数据库结构。

项目的数据关系如下:

⚠️:task 是做的 demo 对象,和实际的项目没什么关系

基础结构

简单的梳理一下项目中用的各种项目结构和模式

MVC 结构

MVC 是一个非常传统的数据结构了,具体作用如下:

  • Model,数据和业务逻辑层

    model 层负责和数据库的交互,对数据的业务处理、数据的验证、数据的操作之类数据相关的部分

  • View,即 UI 部分

    前后端分离的话这部分选择很多,不仅仅单指网页端

    比如说手机 app、电脑 app,需要联网操作和 API 进行数据交互的,都是 view 层

    以网页来说,现在最流行的就是 React/View/Angular,如果前后端不分离的话,目前最流行的应该是 thymeleaf

  • Controller,负责 Model 和 View 层的交互

    controller 主要会接受请求,并且返回 response

这个项目里实现的是 Model 和 Controller 部分的内容。controller 部分的代码比较直接简单,以 category 为例:

java 复制代码
@RestController
@RequestMapping("/categories")
@RequiredArgsConstructor
public class CategoryController {
    private final CategoryService categoryService;
    private final CategoryMapper categoryMapper;

    @ResponseStatus(HttpStatus.CREATED)
    @PostMapping
    public CategoryResponse createCategory(@RequestBody CategoryRequest categoryRequest) {
        CategoryDTO categoryDTO = categoryMapper.mapToCategoryDTO(categoryRequest);
        categoryDTO = categoryService.saveCategory(categoryDTO);
        return categoryMapper.mapToCategoryResponse(categoryDTO);
    }

    @GetMapping
    public List<CategoryResponse> readCategories() {
        List<CategoryDTO> list = categoryService.getAllCategories();
        return list.stream().map(categoryMapper::mapToCategoryResponse).collect(Collectors.toList());
    }

    @ResponseStatus(HttpStatus.NO_CONTENT)
    @DeleteMapping("/{categoryId}")
    public void deleteCategory(@PathVariable String categoryId) {
        categoryService.deleteCategory(categoryId);
    }
}

其中 @PostMapping, @DeleteMapping 分别对应的是 HTTP 请求中的 method,即 CRUD 的操作。具体的操作则是通过调用 service 层中对应的方法去实现,controller 并不在乎。最终将 service 层中返回的数据,并通过 DTO 进行 mapping,作为 response 返回给用户。

而在比较新的 spring boot 项目中,Model 层的耦合度较高,因此也会使用其他的不同模式进行实现,这个项目中使用的就是 entity+service+repository 的实现去解决这个问题

entity

entity 表现了数据结构

这个数据结构即使 Java 数据的结构,也是数据库中的对应结构,如:

java 复制代码
@Entity
@Table(name = "tbl_categories")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CategoryEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "category_id", unique = true)
    private String categoryId;

    @Column(unique = true)
    @NotBlank(message = "Category name must not be empty.")
    @Size(min = 3, message = "Category name must be at least 3 characters.")
    private String name;
    private String description;

    @Column(name = "category_icon")
    private String categoryIcon;

    @Column(name = "created_at", nullable = false, updatable = false)
    @CreationTimestamp
    private Timestamp createdAt;

    @Column(name = "updated_at")
    @UpdateTimestamp
    private Timestamp updatedAt;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private User user;
}

entity 完成了 POJO 与数据库的 table 数据的映射

除此之外,需要注意的几个点有:

  • entity 本身不应该包含任何的业务逻辑

  • entity 最好不要直接暴露给 API

    考虑到 user 作为使用情况,用户在登录/注册的情况下可以选择使用用户名+密码的搭配,但是在获取用户信息的时候显然是不需要获得这个信息的,因此密码这个信息就不应该包含在在 json 中传给调用 API 的用户

    的确可以使用 @JsonIgnore 将密码从 parse json 中这个过程中去除掉,这个的问题是,在使用 registerlogin 这样的 endpoint 也会把密码去掉,那么登陆的功能也就无法实现

    现在一个比较流行的模式是使用 DTO 去实现

Service 层模式

service 层主要负责负责业务逻辑的处理,包括但不限于:

  • 将获取的 DTO 转换成对应的 entity

    这也包含一些关联数据的映射,以 expense 为例,它是有一个对 user 的关联的。因此在 service 层时,就可以讲关联数据进行映射

  • 数据验证及转换

    数据验证简单的可以通过 @Entity 去实现,稍微复杂的还是需要手动验证,比如根据不同的市场判断最大最小值、时区的转换,将数据转成 big decimal 进行存储------我们项目里也有一个类似的逻辑,目前对于数据的精度和范围要求比较高,直接使用 JavaScript 的整数类型会造成 overflow,所以最后采取了 string 转换+使用 decimal.js 进行计算的方法去解决。这时候后端就需要将前端传来的字符串转化成 big decimal 进行存储

  • 处理异常

    这个就不多赘述了

service 的实现大体如下:

java 复制代码
@Service
@RequiredArgsConstructor
public class ExpenseServiceImpl implements ExpenseService {
    private final ExpenseRepository expenseRepo;
    private final UserService userService;
    private final CategoryRepository categoryRepository;
    private final ExpenseMapper expenseMapper;

    @Override
    public List<ExpenseDTO> getAllExpenses(Pageable page) {
        List<ExpenseEntity> expenseList = expenseRepo.findByUserId(userService.getLoggedInUser().getId(), page).toList();
        return expenseList.stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
    }

    @Override
    public ExpenseDTO getExpenseById(String expenseId) {
        ExpenseEntity existingExpense = getExpenseEntity(expenseId);
        return expenseMapper.mapToExpenseDTO(existingExpense);
    }

    private ExpenseEntity getExpenseEntity(String expenseId) {
        Optional<ExpenseEntity> expense = expenseRepo.findByUserIdAndExpenseId(userService.getLoggedInUser().getId(), expenseId);

        if (expense.isEmpty()) {
            throw new ResourceNotFoundException("Expense is not found for the id " + expenseId);
        }
        return expense.get();
    }

    @Override
    public void deleteExpenseById(String expenseId) {
        ExpenseEntity expense = getExpenseEntity(expenseId);
        expenseRepo.delete(expense);
    }

    @Override
    public ExpenseDTO saveExpenseDetails(ExpenseDTO expenseDTO) {
        // check the existence of category
        Optional<CategoryEntity> optionalCategory = categoryRepository.findByUserIdAndCategoryId(userService.getLoggedInUser()
                .getId(), expenseDTO.getCategoryId());
        if (optionalCategory.isEmpty()) {
            throw new ResourceNotFoundException("Category not found for the id " + expenseDTO.getCategoryId());
        }

        expenseDTO.setExpenseId(UUID.randomUUID().toString());

        // map to entity object
        ExpenseEntity newExpense = expenseMapper.mapToExpenseEntity(expenseDTO);
        newExpense.setCategory(optionalCategory.get());
        newExpense.setUser(userService.getLoggedInUser());
        newExpense = expenseRepo.save(newExpense);

        return expenseMapper.mapToExpenseDTO(newExpense);
    }

    @Override
    public ExpenseDTO updateExpenseDetails(String expenseId, ExpenseDTO expenseDTO) {
        ExpenseEntity existingExpense = getExpenseEntity(expenseId);

        if (expenseDTO.getCategoryId() != null) {
            String categoryId = expenseDTO.getCategoryId();
            Optional<CategoryEntity> optionalCategory = categoryRepository.findByUserIdAndCategoryId(userService.getLoggedInUser()
                    .getId(), categoryId);
            if (optionalCategory.isEmpty()) {
                throw new ResourceNotFoundException("Category not found for the id" + categoryId);
            }
            existingExpense.setCategory(optionalCategory.get());
        }

        Optional.ofNullable(expenseDTO.getName()).ifPresent(existingExpense::setName);
        Optional.ofNullable(expenseDTO.getDescription()).ifPresent(existingExpense::setDescription);
        Optional.ofNullable(expenseDTO.getAmount()).ifPresent(existingExpense::setAmount);
        Optional.ofNullable(expenseDTO.getDate()).ifPresent(existingExpense::setDate);
        existingExpense = expenseRepo.save(existingExpense);

        return expenseMapper.mapToExpenseDTO(existingExpense);
    }

    @Override
    public List<ExpenseDTO> readByCategory(String category, Pageable page) {
        Optional<CategoryEntity> optionalCategory = categoryRepository.findByNameAndUserId(category, userService.getLoggedInUser()
                .getId());
        if (optionalCategory.isEmpty()) {
            throw new ResourceNotFoundException("Category not found for the name " + category);
        }

        return expenseRepo.findByUserIdAndCategoryId(userService.getLoggedInUser().getId(), optionalCategory.get()
                .getId(), page).toList().stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
    }

    @Override
    public List<ExpenseDTO> readByName(String name, Pageable page) {
        List <ExpenseEntity> list = expenseRepo.findByUserIdAndNameContaining(userService.getLoggedInUser().getId(), name, page).toList();
        return list.stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
    }

    @Override
    public List<ExpenseDTO> readByDate(Date startDate, Date endDate, Pageable page) {
        if (startDate == null) {
            startDate = new Date(0);
        }

        if (endDate == null) {
            endDate = new Date(System.currentTimeMillis());
        }

        return expenseRepo.findByUserIdAndDateBetween(userService.getLoggedInUser().getId(), startDate, endDate, page)
                .toList().stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
    }
}

大多数情况下 service 在实现的时候会采取新建一个 interface 定义大多数需要的方法,然后再实现 impl,主要的原因也是因为一个 service 可以有不同的实现,可以根据具体的业务调用对应的实现------让 spring 自己去判断调用合适的实现

Repository 模式

repository 则主要负责对数据库进行管理、交流

大多数情况下使用默认的方法就够了,偶尔也会需要重写一下 query

实现大体如下:

java 复制代码
// for more details: https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
@Repository
public interface ExpenseRepository extends JpaRepository<ExpenseEntity, Long> {
    // // SELECT * FROM tbl_expenses WHERE category=?
    // Page<Expense> findByCategory(String category, Pageable page);

    // // SELECT * FROM tbl_expenses WHERE name LIKE '%keyword%'
    // Page<Expense> findByNameContaining(String keyword, Pageable page);

    // // SELECT * FROM tbl_expenses WHERE date BETWEEN 'startDate AND 'endDate'
    // Page<Expense> findByDateBetween(Date startDate, Date endDate, Pageable page);

    // SELECT * FROM tbl_expenses WHERE user_id=? AND category=?
    Page<ExpenseEntity> findByUserIdAndCategory(Long userId, String category, Pageable page);

    Page<ExpenseEntity> findByUserIdAndCategoryId(Long userId, Long categoryId, Pageable page);

    // SELECT * FROM tbl_expenses WHERE user_id=? AND name LIKE '%keyword%'
    Page<ExpenseEntity> findByUserIdAndNameContaining(Long userId, String keyword, Pageable page);

    // SELECT * FROM tbl_expenses WHERE user_id=? AND date BETWEEN 'startDate AND 'endDate'
    Page<ExpenseEntity> findByUserIdAndDateBetween(Long userId, Date startDate, Date endDate, Pageable page);

    // SELECT * FROM tbl_expenses WHERE user_id=?
    Page<ExpenseEntity> findByUserId(Long userId, Pageable page);

    // SELECT * FROM tbl_expenses WHERE user_id=? AND id=?
    Optional<ExpenseEntity> findByUserIdAndExpenseId(Long userId, String expenseId);
}

PS:需要使用 interface 去 extend 其他的 repository,这里是 JpaRepository,不同的数据库 extend 不同的 repository,mongo 的则是 extend MongoRepository

DTO 模式

DTO 全称 Data Transfer Object,顾名思义是在不同层级中对数据进行转换,因此它在 Model 和 Controller 中用的都比较频繁

它主要的作用在 Entity 中提到了,就是为了将数据处理/转换成合适的格式

之前 DTO 用的是 Lombok 提供的 @Builder 的工厂模式实现的,官方文档下的使用方式为:

java 复制代码
Person.builder()
.name("Adam Savage")
.city("San Francisco")
.job("Mythbusters")
.job("Unchained Reaction")
.build();

不过相对而言这种转换方式还是比较麻烦的,最终跟着教程熟悉了一下 mapstruct 的使用方式,具体的 mapper 实现为:

java 复制代码
@Mapper(componentModel = "spring")
public interface CategoryMapper {
    CategoryMapper INSTANCE = Mappers.getMapper(CategoryMapper.class);

    CategoryEntity mapToCategoryEntity(CategoryDTO categoryDTO);

    CategoryDTO mapToCategoryDTO(CategoryEntity categoryEntity);

    @Mapping(target = "categoryIcon", source = "categoryRequest.icon")
    CategoryDTO mapToCategoryDTO(CategoryRequest categoryRequest);

    CategoryResponse mapToCategoryResponse(CategoryDTO categoryDTO);
}

需要注意的是,每次修改完代码需要重新 build 一下 maven 项目,这样才能够重新生成对应的 CategoryMapper

需要注意的是,这里的 targetCategoryDTO,这也是转换结果中的属性。与之相对的 source 就是 CategoryRequest

用户验证

使用的是 jwt 验证方式,基于 jwt 的验证因为是无状态的,所以从 token 中获取用户名后就会添加到 userDetails 中,具体的认证是通过 signature 实现的,如下:

用户名之类的信息其实是公开的,具体验证 signature 的方法,则是需要获取 secret,通过加密/解密进行对比验证。因此对于使用 jwt token 进行验证的 app 来说,这个 secret/private key 是非常重要的

一旦 secret/private key 泄露,那么就可以通过它去获取对应的 signature,从而绕过验证

util

util 主要实现的就是 jwt 相关的部分,主要包活生成 jwt token 和验证 jwt token

java 复制代码
@Component
public class JwtTokenUtil {

    private static final int JWT_TOKEN_VALIDITY = 5 * 60 * 60;

    @Value("${jwt.secret}")
    private String secret;

    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();

        return Jwts.builder()
                .claims(claims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
                .signWith(getSignInKey(), Jwts.SIG.HS256)
                .compact();
    }

    public String getUsernameFromToken(String jwtToken) {
        return getClaimFromToken(jwtToken, Claims::getSubject);
    }

    private <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        if (token.startsWith("Bearer ")) {
            token = token.substring(7).trim(); // Remove 'Bearer ' and trim any extra spaces
        }

        final Claims claims = Jwts.parser()
                .verifyWith(getSignInKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();

        return claimsResolver.apply(claims);
    }

    public boolean validateToken(String jwtToken, UserDetails userDetails) {
        final String username = getUsernameFromToken(jwtToken);

        return username.equals(userDetails.getUsername()) && !isTokenExpired(jwtToken);
    }

    private boolean isTokenExpired(String jwtToken) {
        final Date expiration = getExpirationDateFromToken(jwtToken);

        return expiration.before(new Date());
    }

    private Date getExpirationDateFromToken(String jwtToken) {
        return getClaimFromToken(jwtToken, Claims::getExpiration);
    }
}

授权

本项目里没有使用 spring boot 自带的 userDetails,而是使用自定义的实现,需要注意的是,自定义的类也需要实现 UserDetailsService,具体地说是 loadUserByUsername 这个方法:

java 复制代码
@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        System.out.println("email: " + email);
        User existingUser = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("User not found for the email: " + email));
        return new org.springframework.security.core.userdetails.User(existingUser.getEmail(), existingUser.getPassword(), new ArrayList<>());
    }
}

之后在使用原本的 userDetails 的地方用 CustomUserDetailsService 即可

需要注意的点是:

  • loadUserByUsername 方法实际上是通过用户名获取数据库中的用户,包括用户密码
  • 这个实现是基于 jwt 验证实现,用的是 stateful/session-based 验证,则会导致别人只需要知道用户名,就能够顺利通过验证

完成用户登录验证后,则需要返回一个 jwt token,具体实现在 controller 中:

java 复制代码
    @PostMapping("/login")
    public ResponseEntity<JwtResponse> login(@RequestBody AuthModel authModel) throws Exception {
        authenticate(authModel.getEmail(), authModel.getPassword());

        // used in stateful authentication
        // SecurityContextHolder.getContext().setAuthentication(authentication);

        // generate the jwt token
        final UserDetails userDetails = userDetailsService.loadUserByUsername(authModel.getEmail());
        final String token = jwtTokenUtil.generateToken(userDetails);

        return new ResponseEntity<>(new JwtResponse(token), HttpStatus.OK);
    }

验证

验证部分使用的是 jwt,具体的实现通过以下两个部分:

  1. 实现 jwt token 的 filter

    这一步负责:

    • 从 header 中获取 jwt token,并且对其进行数据处理------移除 Bearer 这个前缀

    • 从获取的 jwt token 中获取用户名和过期时间(expiration date),如果用户名与当前 userDetails 中获取的用户名不符,那么用户便授权失败;如果用户当前登陆时间已过期,那么用户验证失败

    代码实现如下:

    java 复制代码
    public class JwtRequestFilter extends OncePerRequestFilter {
        @Autowired
        private JwtTokenUtil jwtTokenUtil;
    
        @Autowired
        private CustomUserDetailsService userDetailsService;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            final String requestTokenHeader = request.getHeader("Authorization");
            String jwtToken = null;
            String username = null;
    
            if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
                jwtToken = requestTokenHeader.substring(7);
    
                try {
                    username = jwtTokenUtil.getUsernameFromToken(jwtToken);
                } catch (IllegalArgumentException e) {
                    throw new RuntimeException("Unable to get JWT Token.");
                } catch (ExpiredJwtException e) {
                    throw new RuntimeException("Jwt token has expired.");
                }
            }
    
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    
                if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
    
            filterChain.doFilter(request, response);
        }
    }
  2. 添加 jwt filter

    这一步是在 Security Config 中添加,主要添加在 SecurityFilterChain 中,具体代码如下:

    java 复制代码
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http.csrf(AbstractHttpConfigurer::disable)
                    .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
                            authorizationManagerRequestMatcherRegistry
                                    .requestMatchers("/login/**", "/register/**").permitAll()
                                    .anyRequest().authenticated())
                    // ---- 注意这里 ----
                    .sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                    // ---- 注意这里 ----
                    .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class)
                    .httpBasic(Customizer.withDefaults());
    
            return http.build();
        }
相关推荐
山猪打不过家猪1 小时前
ASP.NET Core Clean Architecture
java·数据库·asp.net
AllowM2 小时前
【LeetCode Hot100】除自身以外数组的乘积|左右乘积列表,Java实现!图解+代码,小白也能秒懂!
java·算法·leetcode
不会Hello World的小苗2 小时前
Java——列表(List)
java·python·list
二十七剑3 小时前
jvm中各个参数的理解
java·jvm
东阳马生架构4 小时前
JUC并发—9.并发安全集合四
java·juc并发·并发安全的集合
计算机小白一个5 小时前
蓝桥杯 Java B 组之岛屿数量、二叉树路径和(区分DFS与回溯)
java·数据结构·算法·蓝桥杯
孤雪心殇5 小时前
简单易懂,解析Go语言中的Map
开发语言·数据结构·后端·golang·go
White graces5 小时前
正则表达式效验邮箱格式, 手机号格式, 密码长度
前端·spring boot·spring·正则表达式·java-ee·maven·intellij-idea
菠菠萝宝5 小时前
【Java八股文】10-数据结构与算法面试篇
java·开发语言·面试·红黑树·跳表·排序·lru
不会Hello World的小苗5 小时前
Java——链表(LinkedList)
java·开发语言·链表