发布于 2026-01-06 3 阅读
0

全栈 Reddit 克隆 - Spring Boot、React、Electron 应用 - 第 9 部分 全栈 Reddit 克隆 - Spring Boot、React、Electron 应用 - 第 9 部分 由 Mux 呈现的 DEV 全球展示挑战赛:展示你的项目!

全栈 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 端点!

重要链接

第一部分:更新存储库🗄

接下来,我们将介绍如何更新所有代码仓库以实现分页和排序支持。在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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode
  • 子版块存储库:
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);
}
Enter fullscreen mode Exit fullscreen mode

第二部分:更新服务🌎

现在我们已经更新了代码仓库,接下来需要更新服务以反映这些更改。在`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);
    }
Enter fullscreen mode Exit fullscreen mode
  • 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);
    }
Enter fullscreen mode Exit fullscreen mode
  • SubredditService:我们将更新 getAll 方法
    @Transactional(readOnly = true)
    public Page<SubredditDTO> getAll(Integer page) {
        return subredditRepository.findAll(PageRequest.of(page, 100))
                .map(this::mapToDTO);
    }
Enter fullscreen mode Exit fullscreen mode

第三部分:更新控制器

现在我们已经更新了服务和存储库,接下来需要更新控制器,以允许客户端使用分页功能。在`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);
    }
Enter fullscreen mode Exit fullscreen mode
  • 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);
    }
Enter fullscreen mode Exit fullscreen mode
  • 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");
        }
    }
Enter fullscreen mode Exit fullscreen mode

现在,我们的应用程序完全支持分页,以减少资源增长导致前端应用程序加载速度变慢的问题!

第五部分:刷新令牌类 ⏳

现在我们需要创建 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;
}
Enter fullscreen mode Exit fullscreen mode

第五部分:刷新令牌服务和 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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 更新后的 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;
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

第六部分: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;
    }
Enter fullscreen mode Exit fullscreen mode

第七部分:更新的身份验证💂‍♀️

现在我们已经实现了分页功能,接下来将开始更新身份验证系统。在项目中,我们将更新以下类。请注意,我不会在此部分展示整个类,而只会展示我们将要更新的具体方法。

  • 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());
    }
Enter fullscreen mode Exit fullscreen mode
  • 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");
    }
Enter fullscreen mode Exit fullscreen mode

第 8 部分:自定义例外 🚫

  • VoxNobisException:我们将创建一个通用的自定义异常,以便在扩展应用程序时可以在整个应用程序中重复使用。
package com.maxicb.backend.exception;

public class VoxNobisException extends RuntimeException {
    public VoxNobisException(String message) {super(message);}
}
Enter fullscreen mode Exit fullscreen mode

第 9 部分:更新 application.properties

我们需要为应用程序生成令牌并相应地设置其过期日期时,添加我们希望使用的过期时间。我目前将其设置为 15 分钟,但将来会增加该时间。

# JWT Properties
jwt.expiration.time=900000
Enter fullscreen mode Exit fullscreen mode

第十部分:实现 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>
Enter fullscreen mode Exit fullscreen mode
  • 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();
    }
}
Enter fullscreen mode Exit fullscreen mode
  • BackendApplication:在com.your-name.backend中,我们将注入 Swagger 配置。
@SpringBootApplication
@EnableAsync
@Import(SwaggerConfig.class)
public class BackendApplication {
    ...
}
Enter fullscreen mode Exit fullscreen mode
  • 安全提示:如果您现在运行应用程序,并尝试访问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()
Enter fullscreen mode Exit fullscreen mode

结论🔍

  • 为确保所有配置正确,您可以运行应用程序,并检查控制台中是否出现任何错误。在控制台底部,您应该会看到类似如下的输出。

替代文字

  • 如果控制台中没有错误,您可以通过向http://localhost:8080/api/auth/login发送带有正确数据的 POST 请求来测试新逻辑,登录成功后,您现在应该会收到 refreshToken 和用户名!

  • 您还可以访问http://localhost:8080/swagger-ui.html#/,查看我们创建的所有端点的文档,以及它们需要和返回的信息。

  • 本文新增了分页功能和令牌过期时间。

下一个

关注我们,即可在第十部分发布时收到通知,届时我们将开始开发应用程序的前端!

文章来源:https://dev.to/maxicb/full-stack-reddit-clone-spring-boot-react-electron-app-part-9-3pj5