主页 > 人工智能  > 

[SpringBoot]ExpenseAPI实现

[SpringBoot]ExpenseAPI实现
[Spring Boot] Expense API 实现

项目地址:expense-api

项目简介

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

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

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

使用了:

java17spring boot 3.4.1json web token 0.12.6map struct 1.6.3lombok 1.18.36lombok map struct binding 0.2.0docker

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

❯ 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 为例:

@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 数据的结构,也是数据库中的对应结构,如:

@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 中这个过程中去除掉,这个的问题是,在使用 register 和 login 这样的 endpoint 也会把密码去掉,那么登陆的功能也就无法实现

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

Service 层模式

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

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

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

数据验证及转换

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

处理异常

这个就不多赘述了

service 的实现大体如下:

@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

实现大体如下:

// for more details: 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 的工厂模式实现的,官方文档下的使用方式为:

Person.builder() .name("Adam Savage") .city("San Francisco") .job("Mythbusters") .job("Unchained Reaction") .build();

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

@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

需要注意的是,这里的 target 是 CategoryDTO,这也是转换结果中的属性。与之相对的 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

@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) pact(); } 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 这个方法:

@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 中:

@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,具体的实现通过以下两个部分:

实现 jwt token 的 filter

这一步负责:

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

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

代码实现如下:

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); } }

添加 jwt filter

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

@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(); }
标签:

[SpringBoot]ExpenseAPI实现由讯客互联人工智能栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“[SpringBoot]ExpenseAPI实现