全栈 Reddit 克隆应用 - Spring Boot、React、Electron - 第 9 部分
全栈 Reddit 克隆应用 - Spring Boot、React、Electron - 第 9 部分
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
全栈 Reddit 克隆应用 - Spring Boot、React、Electron - 第 9 部分
介绍
欢迎来到使用 Spring Boot 和 React 创建 Reddit 克隆版的第九部分。
我们在这个部分要建造什么?
- 分页支持
- 我们将更新后端以支持分页功能,随着数据库规模的扩大,这将减少客户端的加载时间。
- JWT失效
- JWT 清爽
在第 8 部分中,我们添加了用于创建和读取评论的 CREATE 和 READ 端点!
重要链接
- 后端源代码:https://github.com/MaxiCB/vox-nobis/tree/master/backend
- 前端源代码:https://github.com/MaxiCB/vox-nobis/tree/master/client
- 实时网址:进行中
第一部分:更新存储库🗄
接下来,我们将介绍如何更新所有代码仓库以实现分页和排序支持。在com.your-name.backend.repository中,我们将更新以下类。
- CommentRespository:我们将转换现有逻辑,并添加一个 findAllByPost 方法,该方法仍然返回一个列表,因为我们依赖该列表在 PostService 中返回评论总数。
package com.maxicb.backend.repository;
import com.maxicb.backend.model.Comment;
import com.maxicb.backend.model.Post;
import com.maxicb.backend.model.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
public interface CommentRepository extends PagingAndSortingRepository<Comment, Long> {
Page<Comment> findByPost(Post post, Pageable pageable);
List<Comment> findAllByPost(Post post);
Page<Comment> findAllByUser(User user, Pageable pageable);
}
- PostRepository:
package com.maxicb.backend.repository;
import com.maxicb.backend.model.Post;
import com.maxicb.backend.model.Subreddit;
import com.maxicb.backend.model.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
public interface PostRepository extends PagingAndSortingRepository<Post, Long> {
Page<Post> findAllBySubreddit(Subreddit subreddit, Pageable pageable);
Page<Post> findByUser(User user, Pageable pageable);
}
- 子版块存储库:
package com.maxicb.backend.repository;
import com.maxicb.backend.model.Subreddit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Optional;
public interface SubredditRepository extends PagingAndSortingRepository<Subreddit, Long> {
Optional<Subreddit> findByName(String subredditName);
Optional<Page<Subreddit>> findByNameLike(String subredditName, Pageable pageable);
}
第二部分:更新服务🌎
现在我们已经更新了代码仓库,接下来需要更新服务以反映这些更改。在`com.your-name.backend.service`文件中,我们将更新以下类。请注意,我不会在此部分显示整个类,而只会显示我们将要更新的具体方法。
- 评论服务:我们将更新 getCommentsForPost 和 getCommentsForUser 方法,以正确处理分页。
public Page<CommentResponse> getCommentsForPost(Long id, Integer page) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException("Post not found with id: " + id));
return commentRepository.findByPost(post, PageRequest.of(page, 100)).map(this::mapToResponse);
}
public Page<CommentResponse> getCommentsForUser(Long id, Integer page) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
return commentRepository.findAllByUser(user, PageRequest.of(page, 100)).map(this::mapToResponse);
}
- PostService:我们将更新 mapToResponse、getAllPosts、getPostsBySubreddit 和 getPostsByUsername 方法以实现分页,同时保留映射到 DTO 的现有逻辑。
private PostResponse mapToResponse(Post post) {
return PostResponse.builder()
.postId(post.getPostId())
.postTitle(post.getPostTitle())
.url(post.getUrl())
.description(post.getDescription())
.userName(post.getUser().getUsername())
.subredditName(post.getSubreddit().getName())
.voteCount(post.getVoteCount())
.commentCount(commentRepository.findAllByPost(post).size())
.duration(TimeAgo.using(post.getCreationDate().toEpochMilli()))
.upVote(checkVoteType(post, VoteType.UPVOTE))
.downVote(checkVoteType(post, VoteType.DOWNVOTE))
.build();
}
public Page<PostResponse> getAllPost(Integer page) {
return postRepository.findAll(PageRequest.of(page, 100)).map(this::mapToResponse);
}
public Page<PostResponse> getPostsBySubreddit(Integer page, Long id) {
Subreddit subreddit = subredditRepository.findById(id)
.orElseThrow(() -> new SubredditNotFoundException("Subreddit not found with id: " + id));
return postRepository
.findAllBySubreddit(subreddit, PageRequest.of(page, 100))
.map(this::mapToResponse);
}
public Page<PostResponse> getPostsByUsername(String username, Integer page) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UserNotFoundException("User not found with username: " + username));
return postRepository
.findByUser(user, PageRequest.of(page, 100))
.map(this::mapToResponse);
}
- SubredditService:我们将更新 getAll 方法
@Transactional(readOnly = true)
public Page<SubredditDTO> getAll(Integer page) {
return subredditRepository.findAll(PageRequest.of(page, 100))
.map(this::mapToDTO);
}
第三部分:更新控制器
现在我们已经更新了服务和存储库,接下来需要更新控制器,以允许客户端使用分页功能。在`com.your-name.backend.controller`文件中,我们将更新以下类。请注意,我不会在此部分显示整个类,而只会显示我们将要更新的特定方法。
- CommentController:我们将更新 getCommentsByPost 和 getCommentsByUser 方法,以正确处理分页。
@GetMapping("/post/{id}")
public ResponseEntity<Page<CommentResponse>> getCommentsByPost(@PathVariable("id") Long id, @RequestParam Optional<Integer> page) {
return new ResponseEntity<>(commentService.getCommentsForPost(id, page.orElse(0)), HttpStatus.OK);
}
@GetMapping("/user/{id}")
public ResponseEntity<Page<CommentResponse>> getCommentsByUser(@PathVariable("id") Long id,@RequestParam Optional<Integer> page) {
return new ResponseEntity<>(commentService.getCommentsForUser(id, page.orElse(0)), HttpStatus.OK);
}
- PostController:我们将首先更新 addPost 方法,以便在创建成功后将创建的帖子发送回客户端;然后更新 getAllPost、getPostsBySubreddit 和 getPostsByUsername 方法,以实现分页功能。
@PostMapping
public ResponseEntity<PostResponse> addPost(@RequestBody PostRequest postRequest) {
return new ResponseEntity<>(postService.save(postRequest), HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity<Page<PostResponse>> getAllPost(@RequestParam Optional<Integer> page) {
return new ResponseEntity<>(postService.getAllPost(page.orElse(0)), HttpStatus.OK);
}
@GetMapping("/sub/{id}")
public ResponseEntity<Page<PostResponse>> getPostsBySubreddit(@PathVariable Long id, @RequestParam Optional<Integer> page) {
return new ResponseEntity<>(postService.getPostsBySubreddit(page.orElse(0), id), HttpStatus.OK);
}
@GetMapping("/user/{name}")
public ResponseEntity<Page<PostResponse>> getPostsByUsername(@PathVariable("name") String username, @RequestParam Optional<Integer> page) {
return new ResponseEntity<>(postService.getPostsByUsername(username, page.orElse(0)), HttpStatus.OK);
}
- SubredditController:我们将更新所有方法,以实现发送 ResponseEntity 以及支持分页。
@GetMapping("/{page}")
public ResponseEntity<Page<SubredditDTO>> getAllSubreddits (@PathVariable("page") Integer page) {
return new ResponseEntity<>(subredditService.getAll(page), HttpStatus.OK);
}
@GetMapping("/sub/{id}")
public ResponseEntity<SubredditDTO> getSubreddit(@PathVariable("id") Long id) {
return new ResponseEntity<>(subredditService.getSubreddit(id), HttpStatus.OK);
}
@PostMapping
public ResponseEntity<SubredditDTO> addSubreddit(@RequestBody @Valid SubredditDTO subredditDTO) throws Exception{
try {
return new ResponseEntity<>(subredditService.save(subredditDTO), HttpStatus.OK);
} catch (Exception e) {
throw new Exception("Error Creating Subreddit");
}
}
现在,我们的应用程序完全支持分页,以减少资源增长导致前端应用程序加载速度变慢的问题!
第五部分:刷新令牌类 ⏳
现在我们需要创建 RefreshToken 类,该类将包含 ID、令牌和与之关联的创建日期,以便在设定的时间后使令牌失效。
- 刷新令牌:
package com.maxicb.backend.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.time.Instant;
@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String token;
private Instant creationDate;
}
第五部分:刷新令牌服务和 DTO🌎
现在我们有了刷新令牌,接下来我们将做好一切准备,开始更新身份验证系统。在项目中,我们将添加并更新以下类。
- RefreshTokenRepository:
package com.maxicb.backend.repository;
import com.maxicb.backend.model.RefreshToken;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends PagingAndSortingRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
void deleteByToken(String token);
}
- RefreshTokenService:此服务允许我们生成令牌、验证令牌和删除令牌。
package com.maxicb.backend.service;
import com.maxicb.backend.exception.VoxNobisException;
import com.maxicb.backend.model.RefreshToken;
import com.maxicb.backend.repository.RefreshTokenRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
@Service
@AllArgsConstructor
@Transactional
public class RefreshTokenService {
private RefreshTokenRepository refreshTokenRepository;
RefreshToken generateRefreshToken () {
RefreshToken refreshToken = new RefreshToken();
refreshToken.setToken(UUID.randomUUID().toString());
refreshToken.setCreationDate(Instant.now());
return refreshTokenRepository.save(refreshToken);
}
void validateToken(String token) {
refreshTokenRepository.findByToken(token)
.orElseThrow(() -> new VoxNobisException("Invalid Refresh Token"));
}
public void deleteRefreshToken(String token) {
refreshTokenRepository.deleteByToken(token);
}
}
- 更新后的 AuthResponse:我们将更新 AuthResponse,使其包含我们新生成的令牌。
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.Instant;
@Data
@AllArgsConstructor
public class AuthResponse {
private String authenticationToken;
private String refreshToken;
private Instant expiresAt;
private String username;
}
- RefreshTokenRequest:此 DTO 将处理客户端提出的刷新令牌的请求,以防止令牌在系统中过期。
package com.maxicb.backend.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RefreshTokenRequest {
@NotBlank
private String refreshToken;
private String username;
}
第六部分:JWTProvider 更新🔏
现在一切准备就绪,我们将开始更新 JWT 系统。在com.your-name.backend.service文件中,我们将更新以下类。请注意,我不会在此部分展示整个类,而只会展示我们将要更新的具体方法。
- JWTProvider:我们将更新我们的 JWT 实现,使其包含 issuedAt 日期,并在创建新令牌时设置过期日期。
@Service
public class JWTProvider {
private KeyStore keystore;
@Value("${jwt.expiration.time}")
private Long jwtExpirationMillis;
...
....
public String generateToken(Authentication authentication) {
org.springframework.security.core.userdetails.User princ = (User) authentication.getPrincipal();
return Jwts.builder()
.setSubject(princ.getUsername())
.setIssuedAt(from(Instant.now()))
.signWith(getPrivKey())
.setExpiration(from(Instant.now().plusMillis(jwtExpirationMillis)))
.compact();
}
public String generateTokenWithUsername(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(from(Instant.now()))
.signWith(getPrivKey())
.setExpiration(from(Instant.now().plusMillis(jwtExpirationMillis)))
.compact();
}
....
...
public Long getJwtExpirationMillis() {
return jwtExpirationMillis;
}
第七部分:更新的身份验证💂♀️
现在我们已经实现了分页功能,接下来将开始更新身份验证系统。在项目中,我们将更新以下类。请注意,我不会在此部分展示整个类,而只会展示我们将要更新的具体方法。
- AuthService:我们将更新我们的 AuthService 以处理发送 refreshTokens,并添加刷新现有令牌的逻辑。
public AuthResponse refreshToken(RefreshTokenRequest refreshTokenRequest) {
refreshTokenService.validateToken(refreshTokenRequest.getRefreshToken());
String token = jwtProvider.generateTokenWithUsername(refreshTokenRequest.getUsername());
return new AuthResponse(token, refreshTokenService.generateRefreshToken().getToken(), Instant.now().plusMillis(jwtProvider.getJwtExpirationMillis()), refreshTokenRequest.getUsername());
}
public AuthResponse login (LoginRequest loginRequest) {
Authentication authenticate = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authenticate);
String authToken = jwtProvider.generateToken(authenticate);
String refreshToken = refreshTokenService.generateRefreshToken().getToken();
return new AuthResponse(authToken, refreshToken, Instant.now().plusMillis(jwtProvider.getJwtExpirationMillis()), loginRequest.getUsername());
}
- AuthController:现在我们将实现新的端点,以允许客户端使用新添加的逻辑。
@PostMapping("/refresh/token")
public AuthResponse refreshToken(@Valid @RequestBody RefreshTokenRequest refreshTokenRequest) {
return authService.refreshToken(refreshTokenRequest);
}
@PostMapping("/logout")
public ResponseEntity<String> logout(@Valid @RequestBody RefreshTokenRequest refreshTokenRequest) {
refreshTokenService.deleteRefreshToken(refreshTokenRequest.getRefreshToken());
return ResponseEntity.status(HttpStatus.OK).body("Refresh Token Deleted");
}
第 8 部分:自定义例外 🚫
- VoxNobisException:我们将创建一个通用的自定义异常,以便在扩展应用程序时可以在整个应用程序中重复使用。
package com.maxicb.backend.exception;
public class VoxNobisException extends RuntimeException {
public VoxNobisException(String message) {super(message);}
}
第 9 部分:更新 application.properties
我们需要为应用程序生成令牌并相应地设置其过期日期时,添加我们希望使用的过期时间。我目前将其设置为 15 分钟,但将来会增加该时间。
# JWT Properties
jwt.expiration.time=900000
第十部分:实现 Swagger UI 📃
现在我们的MVP后端已经接近尾声,接下来我们将添加Swagger UI。如果您之前从未使用过Swagger,它是一种自动生成API文档的绝佳工具。您可以在这里了解更多信息!
- pom.xml:我们需要将 swagger 依赖项包含在项目的 pom.xml 文件中。
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
- SwaggerConfig:在com.your-name.backend.config文件中,我们将创建以下类。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket voxNobisAPI() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.any())
.build()
.apiInfo(getAPIInfo());
}
private ApiInfo getAPIInfo(){
return new ApiInfoBuilder()
.title("Vox-Nobis API")
.version("1.0")
.description("API for Vox-Nobis reddit clone")
.build();
}
}
- BackendApplication:在com.your-name.backend中,我们将注入 Swagger 配置。
@SpringBootApplication
@EnableAsync
@Import(SwaggerConfig.class)
public class BackendApplication {
...
}
- 安全提示:如果您现在运行应用程序,并尝试访问http://localhost:8080/swagger-ui.html#/,则可能会收到 403 禁止访问错误。我们需要在com.your-name.backend.config 文件中更新安全配置,通过在现有匹配器下方添加以下匹配器来允许未经授权的访问。
.antMatchers(HttpMethod.GET, "/api/subreddit")
.permitAll()
.antMatchers("/v2/api-docs",
"/configuration/ui",
"/swagger-resources/**",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**")
.permitAll()
结论🔍
- 为确保所有配置正确,您可以运行应用程序,并检查控制台中是否出现任何错误。在控制台底部,您应该会看到类似如下的输出。
-
如果控制台中没有错误,您可以通过向http://localhost:8080/api/auth/login发送带有正确数据的 POST 请求来测试新逻辑,登录成功后,您现在应该会收到 refreshToken 和用户名!
-
您还可以访问http://localhost:8080/swagger-ui.html#/,查看我们创建的所有端点的文档,以及它们需要和返回的信息。
-
本文新增了分页功能和令牌过期时间。