共计 33693 个字符,预计需要花费 85 分钟才能阅读完成。
首发于 Enaium 的集体博客
前言
前端咱们应用了 SolidJS
来进行开发, 它是一个新出的前端框架, 它的特点是应用 React Hooks
的形式来进行开发, 并且它的 API
和React
的 API
十分类似, 所以如果你会 React
的话, 那么你就会SolidJS
.
后端咱们应用了 SpringBoot
来进行开发, 数据库咱们应用了 MySQL
来进行开发, 这里我应用的是 MariaDB
来进行开发,ORM 框架应用的是 Jimmer
, 这个框架它反对Java
和Kotlin
, 这里为了简略起见就应用 Java
开发, 但理论应用 Koltin
会更不便.
前端环境
首先呢, 须要装置一下 SolidJS
的模板, 这里我应用的是 TypeScript
和Bootstrap
的模板
npx degit solidjs/templates/ts-bootstrap website
cd website
pmpm install # or npm install
pmpm dev # or npm run dev
而后咱们须要装置一下 SolidJS
的路由, 这是一个官网的路由
pnpm install @solidjs/router
而后咱们须要装置一下 SolidJS
的状态治理, 这里我应用的是zustand
pnpm install zustand solid-zustand
而后咱们须要装置一下 Immer
用于批改不可变数据
pnpm install solid-immer
而后咱们须要装置solid-toast
, 这是一个用于显示提醒的库
pnpm install solid-toast
最初咱们须要装置 TanStack Query
, 这是一个用于进行网络申请的库, 同时反对React
,Vue
,Svelte
当然也反对SolidJS
pnpm install @tanstack/solid-query
编写前端根底代码
当初咱们开始编写前端代码
首先咱们须要删掉模板中自带的款式, 并且删除 App.tsx
中的内容, 而后咱们须要在 App.tsx
中引入 Router
组件, 并且应用 useRoutes
来进行路由的渲染, 这里咱们须要传入一个路由数组, 这个数组中蕴含了咱们的路由信息, 而后咱们须要在 App.tsx
中应用 Router
组件来进行路由的渲染
import type {Component} from "solid-js"
import {Router, useRoutes} from "@solidjs/router"
import routes from "./router" // 这里是路由数组
import {Toaster} from "solid-toast" // 这个组件用于显示提醒
const App: Component = () => {const Routes = useRoutes(routes)
return (
<>
<Toaster />
<Router>
<Routes />
</Router>
</>
)
}
之后新建 views
目录, 在其中新家一个 Login.tsx
文件, 这个文件用于渲染登录页面, 而后咱们须要在路由数组中增加这个路由, 路由文件就是在 src
目录下新建一个 router
目录, 而后在其中新建一个 index.ts
文件, 这个文件用于导出路由数组
import {RouteDefinition} from "@solidjs/router"
import Login from "../views/Login"
const routes: RouteDefinition[] = [
{
path: "/login",
component: () => <Login /> // 这里有个坑, 如果不加上() =>, 那么会报错, 也能够间接应用 Login 比方 component: Login
}
]
export default routes
好了当初来写用户的 session
状态的治理, 咱们须要在 src
目录下新建一个 store
目录, 而后在其中新建一个 index.ts
文件, 这个文件用于导出 session
状态
import create from "solid-zustand" // 须要留神的是这里应用的是 solid-zustand, 而不是 zustand
import {persist} from "zustand/middleware"
// 这里定义了 session 的类型
interface Session {
token?: string
id?: string
setToken: (token: string) => void
setId: (id: string) => void
}
// 这里应用 zustand 的 persist 中间件来进行长久化
export const useSessionStore = create(
persist<Session>((set, get) => ({setToken: (token: string) => set({token}),
setId: (id: string) => set({id})
}),
{
name: "session-store",
getStorage: () => localStorage}
)
)
后端环境
首先关上 IntelliJ IDEA
而后抉择 Spring Initializr
, 语言选择Java
, 构建工具抉择Gradle
, 脚本应用Kotlin
, 因为kts
比groovy
更好用, 剩下的本人看着改改,JDK
应用 17
, 因为SpringBoot3
只能应用 JDK17
, 而后点击Next
, 依赖抉择SpringWeb
,MariaDB Driver
,Lombok
, 下面这些步骤应用start.spring.io
也能够实现, 而后点击 Create
, 而后期待Gradle
下载依赖, 下载实现后就能够开始编写代码了
首先引入 Jimmer
的相干依赖
dependencies {implementation("org.babyfish.jimmer:jimmer-spring-boot-starter:0.7.67")// 这个是 Jimmer 的 SpringBoot 的 Starter
annotationProcessor("org.babyfish.jimmer:jimmer-apt:0.7.67")// 这个是 Jimmer 的 APT, 用于生成代码
}
接着引入 SaToken
的依赖
implementation("cn.dev33:sa-token-spring-boot3-starter:1.34.0")
而后咱们须要在 application.properties
中配置一下所须要的配置
# 数据库配置, 这里应用的是 MariaDB, 因为我的数据库在虚拟机中, 所以这里应用的是虚拟机的 IP 地址
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mariadb://192.168.56.101:3306/blog
#Jimmer 配置, 这里配置了生成 Typescript 的门路, 以及是否显示 SQL 语句, 以及是否丑化 SQL 语句
jimmer.client.ts.path=/typescript.zip
jimmer.show-sql=true
jimmer.pretty-sql=true
#SaToken 配置, 这里配置了 token 的名称
sa-token.token-name=token
须要留神一下,IntelliJ IDEA
默认对 properties
文件的编码是 ISO-8859-1
, 所以咱们须要批改一下编码, 批改办法是在File
->Settings
->Editor
->File Encodings
中批改 Default encoding for properties files
的编码为UTF-8
, 要不然中文会乱码
设计数据库
首先一个简略的集体博客包含了文章, 评论, 用户, 分类
create table blog.category
(
id uuid not null
primary key,
name varchar(32) not null
);
create table blog.user
(
id uuid not null
primary key,
username varchar(18) not null,
password varchar(18) not null
);
create table blog.post
(
id uuid not null
primary key,
user_id uuid not null,
category_id uuid not null,
title varchar(50) not null,
content text not null,
constraint post_category_id_fk
foreign key (category_id) references blog.category (id),
constraint post_user_id_fk
foreign key (user_id) references blog.user (id)
);
create table blog.reply
(
id uuid not null
primary key,
user_id uuid not null,
post_id uuid not null,
content varchar(1000) not null,
constraint reply_post_id_fk
foreign key (post_id) references blog.post (id),
constraint reply_user_id_fk
foreign key (user_id) references blog.user (id)
);
后端编写
定义实体类
在 Jimmer
中定义实体类须要应用接口, 如果你应用的是自增主键, 那么你须要应用@GeneratedValue(strategy = GenerationType.IDENTITY)
import org.babyfish.jimmer.sql.Entity;
import org.babyfish.jimmer.sql.GeneratedValue;
import org.babyfish.jimmer.sql.Id;
import org.babyfish.jimmer.sql.Table;
import org.babyfish.jimmer.sql.meta.UUIDIdGenerator;
import org.jetbrains.annotations.NotNull;
@Entity// 这个注解用于标记这个接口是一个实体类
@Table(name = "user")// 如果表名比拟非凡, 那么能够应用这个注解来指定表名
public interface User {
@Id// 这个注解用于标记这个属性是主键
@GeneratedValue(generatorType = UUIDIdGenerator.class)// 这个注解用于标记这个属性的值是主动生成的, 这里应用的是 UUID
UUID id();
@NotNull// 这个注解用于标记这个属性的值不可为空, 举荐应用 Jetbrains 的注解
String username();
@NotNull
String password();}
接着定义其余的实体类
@Entity
@Table(name = "category")
public interface Category {
@Id
@GeneratedValue(generatorType = UUIDIdGenerator.class)
UUID id();
String name();}
@Entity
@Table(name = "post")
public interface Post {
@Id
@GeneratedValue(generatorType = UUIDIdGenerator.class)
UUID id();
UUID userId();
UUID categoryId();
@NotNull
String title();
@NotNull
String content();}
@Entity
@Table(name = "reply")
public interface Reply {
@Id
@GeneratedValue(generatorType = UUIDIdGenerator.class)
UUID id();
UUID userId();
UUID postId();
@NotNull
String content();}
定义好实体类之后须要运行一下我的项目, 这样 Jimmer
就会生成代码,Draft
,Fetcher
,Props
,Table
和 TableEx
之类的, 找到我的项目下的 build/generated/sources/annotationProcessor
地位, 你就会发现这些生成的代码, 如果应用的是 Kotlin
生成的类会和这些有点不同, 但本篇文章应用的是Java
, 所以就不多说了, 因为尽管实质上一样, 但还是有点不同的
如果你应用的是 Linux 环境, 那么你能够像我一样为每个实体类加上一个 Table
注解, 那是因为 Linux
的名称是辨别大小写的, 而 Windows
的名称是不辨别大小写的,Jimmer
默认是会将所有的名称转换为大写, 你也能够间接应用配置来批改这个行为
@Bean
public DatabaseNamingStrategy databaseNamingStrategy() {return DefaultDatabaseNamingStrategy.LOWER_CASE;// 这里应用的是小写}
定义 Repository
Jimmer
和 JPA
一样, 同样领有 Repository
, 因为Jimmer
同时反对 Java
和Kotlin
, 所以这里的 Repository
是JRepository
, 这个接口继承了 JPA
的JpaRepository
, 所以它领有 JPA
的所有性能, 泛型的第一个参数是实体类的类型, 第二个参数是主键的类型, 这里我的主键应用的是UUID
, 所以这里的类型是UUID
import cn.enaium.blog.model.entity.User;
import org.babyfish.jimmer.spring.repository.JRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository// 这个注解用于标记这个接口是一个 Repository, 这个注解是 SpringData 提供的, 也能够不必这个注解,SpringBoot 会主动扫描这个接口
public interface UserRepository extends JRepository<User, UUID> {}
接着定义其余的Repository
@Repository
public interface CategoryRepository extends JRepository<Category, UUID> {}
@Repository
public interface PostRepository extends JRepository<Post, UUID> {}
@Repository
public interface ReplyRepository extends JRepository<Reply, UUID> {}
当初最根本的代码就写完了, 还有一部分没有写, 之后一遍写 Controller
时再写, 当初咱们须要编写一下 Controller
的代码
编写 Controller
SessionController
SessionController
用来解决用户的登录和和退出登录
@RestController
public class SessionController {@PutMapping("/sessions/")
public void login() {}
@DeleteMapping("/sessions/")
public void logout() {}
}
既然要登录, 那么就须要一个登录的申请体, 这里我应用的是 JSON
来进行传输, 所以咱们须要定义一个 UserInput
类, 这个类用于接管 JSON
数据
@Data
public class UserInput {
@Nullable
private final UUID id;
@Nullable
private final String username;
@Nullable
private final String password;
}
接着将 UserRepository
注入到 SessionController
中
@AllArgsConstructor// 这个注解用于标记这个类的构造函数是全参构造函数, 这个注解是 Lombok 提供的
public class SessionController {private final UserRepository userRepository;// 这里应用的是构造函数注入, 也能够应用字段注入, 然而不举荐应用字段注入}
首先要在 UserRepository
中编写一个 findByUsername
办法, 这个办法用于依据用户名查问用户
@Repository
public interface UserRepository extends JRepository<User, UUID> {User findByUsername(String username);// 这里的办法名是有规定的, 如果是依据用户名查问, 那么办法名就是 findByUsername, 如果是依据用户名和明码查问, 那么办法名就是 findByUsernameAndPassword
}
而后咱们须要在 SessionController
中编写 login
办法, 简略的登录逻辑, 如果用户不存在, 那么就抛出一个 NullPointerException
, 如果明码谬误, 那么就抛出一个IllegalArgumentException
, 如果登录胜利, 那么就返回一个LoginResponse
对象, 这个对象蕴含了 token
和id
@PutMapping("/sessions/")
public LoginResponse login(@RequestBody UserInput userInput) {final var user = userRepository.findByUsername(userInput.getUsername());
if (user == null) {throw new NullPointerException("User not found");
}
if (!user.password().equals(userInput.getPassword())) {throw new IllegalArgumentException("Password error");
}
return new LoginResponse(StpUtil.createLoginSession(user.id()), user.id());
}
@Data
public class LoginResponse {
private final String token;
private final UUID id;
}
而后咱们须要编写 logout
办法, 这个办法用于退出登录, 这个办法很简略, 就是调用 SaToken
的logout
办法
@DeleteMapping("/sessions/")
public void logout() {StpUtil.logout();
}
UserController
接着咱们须要编写UserController
, 这个类用于解决用户的注册
@RestController
@AllArgsConstructor
public class UserController {
private final UserRepository userRepository;
@PutMapping("/users/")
@ResponseStatus(HttpStatus.OK)// 这个注解用于标记这个办法的响应状态码, 这个注解是 Spring 提供的
public void register(@RequestBody UserInput userInput) {
// 如果用户曾经存在, 那么就抛出一个异样
if (userRepository.findByUsername(userInput.getUsername()) != null) {throw new IllegalArgumentException("User already exists");
}
// 插入用户, 这里应用的是 Jimmer 的 UserDraft, 这个类用于创立一个 User 对象, 这个对象是一个 Draft 对象, 这个对象能够批改, 因为在 Jimmer 中实体类是不可变的, 所以须要应用 Draft, 对于应用过 Immer 的人来说, 这个 Draft 就是一个 Immer 的 Draft, 这里应用的是 produce 办法, 这个办法用于创立一个 Draft 对象, 这个办法的参数是一个 Consumer, 这个 Consumer 用于批改 Draft 对象, 这里 Jimmer 借鉴了 Immer
userRepository.insert(UserDraft.$.produce(draft -> {draft.setUsername(userInput.getUsername());
draft.setPassword(userInput.getPassword());
}));
}
}
CategoryController
而后咱们须要编写CategoryController
, 这个类用于解决分类的查问
@RestController
@AllArgsConstructor
public class CategoryController {
private final CategoryRepository categoryRepository;
@GetMapping("/categories/")
public List<Category> findCategories() {return categoryRepository.findAll();
}
}
PostController
而后咱们须要编写 PostController
, 这个类用于解决文章的查问, 这里会应用Jimmer
的Fetcher
性能, 这个性能用于查问关联表的数据, 这里咱们须要查问文章的分类, 所以须要应用 Fetcher
性能
首先定义 PostInput
类, 这个类用于接管 JSON
数据
@Data
public class PostInput {
@Nullable
private final UUID id;
@Nullable
private final String title;
@Nullable
private final String content;
@Nullable
private final UUID categoryId;
}
接着设置表的关联关系, 表的关联在表的实体类中进行设置, 须要留神的是, 关联建设后, 之前生成的代码是不会更新的, 所以须要手动更新一下, 这里我应用的是 IntelliJ IDEA
的Rebuild Project
性能, 这个性能会从新生成代码, 或者应用 Gradle
的build
性能也能够, 这里我应用的是 IntelliJ IDEA
的Rebuild Project
性能
首先 Post
表和 Category
表是多对一的关系, 所以在 Post
表中设置一个 category
办法, 之后再 Category
实体类中定义一个 posts
办法, 这个办法示意这个分类下的所有文章
@Entity
@Table(name = "post")
public interface Post {
@Id
@GeneratedValue(generatorType = UUIDIdGenerator.class)
UUID id();
UUID userId();
UUID categoryId();
@ManyToOne// 这个注解用于标记这个属性是多对一的关系
Category category();
@NotNull
String title();
@NotNull
String content();}
@Entity
@Table(name = "category")
public interface Category {
@Id
@GeneratedValue(generatorType = UUIDIdGenerator.class)
UUID id();
@NotNull
String name();
@OneToMany(mappedBy = "category")// 这个注解用于标记这个属性是一对多的关系, 这里的 mappedBy 示意这个属性在另一个实体类中的名称
List<Post> posts();}
之后咱们开始写 PostController
的代码, 在编写之前响一下, 都须要写哪些办法, 这里我列出来
- 查问所有文章 (蕴含分类), 用于渲染首页, 但这里不须要文章的内容, 所以不须要查问文章的内容, 这里须要应用
Fetcher
性能 - 依据文章 ID 查问文章, 用于渲染文章详情页, 这里须要查问文章的内容, 所以须要应用
Fetcher
性能 - 依据分类 ID 查问文章, 用于渲染分类页, 但这里不须要文章的内容, 这里须要应用
Fetcher
性能
以上就是根本须要的办法, 所以这里咱们须要定义 2 个Fetcher
, 一个是不蕴含文章内容的Fetcher
, 一个是蕴含文章内容的Fetcher
public class PostController {
// 这个 Fetcher 用于查问所有文章, 但不蕴含文章内容
public static final PostFetcher DEFAULT_POST = PostFetcher.$
.allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段
.content(false)// 这个办法用于设置是否查问文章内容, 这里设置为 false, 示意不查问文章内容
.category(// 这个办法用于设置查问文章的分类, 这里应用的是一个 CategoryFetcher, 这个 Fetcher 用于查问分类
CategoryFetcher.$
.allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段);
// 这个 Fetcher 用于查问所有文章, 蕴含文章内容
public static final PostFetcher FULL_POST = PostFetcher.$
.allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段
.category(// 这个办法用于设置查问文章的分类, 这里应用的是一个 CategoryFetcher, 这个 Fetcher 用于查问分类
CategoryFetcher.$
.allScalarFields()// 这个办法用于查问所有的标量字段, 标量字段就是不是关联表的字段);
}
接着写接口
@RestController
@AllArgsConstructor
public class PostController {
private final PostRepository repository;
// 这个办法用于创立文章
@PutMapping("/posts/")
@ResponseStatus(HttpStatus.OK)
public void createPost(@RequestBody PostInput postInput) {
repository.insert(PostDraft.$.produce(draft -> {draft.setUserId(UUID.fromString(StpUtil.getLoginIdAsString()));
draft.setTitle(postInput.getTitle());
draft.setContent(postInput.getContent());
draft.setCategoryId(postInput.getCategoryId());
}));
}
// 这个办法用于查问所有文章, 但不蕴含文章内容,FetchBy 注解用于标记这个办法应用的是哪个 Fetcher, 其实就是为了生成 Typescript 代码时应用的, 这个注解是 Jimmer 提供的, 这个注解的参数是一个字符串, 这个字符串就是 Fetcher 的名称, 这个名称就是在哪个类中定义的, 这里是在 PostController 中定义的, 默认是在以后类中定义的, 如果不想在以后类中定义, 那么就须要应用 ownerType 参数来指定
@GetMapping("/posts/")
public Page<@FetchBy(value = "FULL_POST", ownerType = PostController.class) Post> findPosts(@RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) {return repository.findAll(PageRequest.of(page, size), DEFAULT_POST);// 这里应用的是 findAll 办法, 这个办法用于查问所有的实体类, 这个办法的第二个参数是一个 Fetcher, 这个 Fetcher 用于查问关联表的数据, 这里应用的是 DEFAULT_POST, 示意查问文章的分类, 但不查问文章的内容, 这个办法是 Jimmer 提供的
}
// 这个办法用于查问所有文章, 蕴含文章内容
@GetMapping("/posts/{id}")
public @FetchBy("FULL_POST") Post findPost(@PathVariable UUID id) {return repository.findNullable(id, FULL_POST);// 这里应用的是 findNullable 办法, 这个办法用于查问一个实体类, 如果查问不到, 那么就返回 null, 如果查问到了, 那么就返回这个实体类, 这个办法的第二个参数是一个 Fetcher, 这个 Fetcher 用于查问关联表的数据, 这里应用的是 FULL_POST, 示意查问文章的分类, 同时查问文章的内容, 这个办法是 Jimmer 提供的
}
// 这个办法用于查问分类下的所有文章, 但不蕴含文章内容
@GetMapping("/categories/{categoryId}/posts/")
public Page<@FetchBy("DEFAULT_POST") Post> findPostsByCategory(@PathVariable UUID categoryId, @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) {return repository.findAllByCategoryId(PageRequest.of(page, size), categoryId, DEFAULT_POST);
}
}
接着看下 PostRepository
中的代码
@Repository
public interface PostRepository extends JRepository<Post, UUID> {
// 这个办法依据分类 ID 查问文章, 具体的 Fetcher 在办法的参数中指定
Page<Post> findAllByCategoryId(Pageable pageable, UUID categoryId, Fetcher<Post> fetcher);
}
ReplyController
而后咱们须要编写 ReplyController
, 这个类用于解决评论的查问, 这里会应用Jimmer
的Fetcher
性能, 这个性能用于查问关联表的数据, 这里咱们须要查问评论的用户, 所以须要应用 Fetcher
性能
首先定义 ReplyInput
类, 这个类用于接管 JSON
数据
@Data
public class ReplyInput {
@Nullable
private final UUID id;
@Nullable
private final UUID userId;
@Nullable
private final UUID postId;
@Nullable
private final String content;
}
接着设置表的关联关系, 表的关联在表的实体类中进行设置
@Entity
@Table(name = "reply")
public interface Reply {
@Id
@GeneratedValue(generatorType = UUIDIdGenerator.class)
UUID id();
UUID userId();
@ManyToOne// 这个注解用于标记这个属性是多对一的关系
User user();
UUID postId();
@NotNull
String content();}
@Entity
@Table(name = "user")
public interface User {
@Id
@GeneratedValue(generatorType = UUIDIdGenerator.class)
UUID id();
@NotNull
String username();
@NotNull
String password();
@OneToMany(mappedBy = "user")// 这个注解用于标记这个属性是一对多的关系, 这里的 mappedBy 示意这个属性在另一个实体类中的名称
List<Reply> replies();}
接着写接口
@RestController
@AllArgsConstructor
public class ReplyController {
private final ReplyRepository replyRepository;
@PutMapping("/replies/")
public void createReply(@RequestBody ReplyInput replyInput) {
replyRepository.insert(ReplyDraft.$.produce(draft -> {draft.setUserId(UUID.fromString(StpUtil.getLoginIdAsString()));
draft.setContent(replyInput.getContent());
draft.setPostId(replyInput.getPostId());
}));
}
@GetMapping("/posts/{postId}/replies/")
public Page<@FetchBy("DEFAULT_REPLY") Reply> findRepliesByPost(@PathVariable UUID postId, @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) {return replyRepository.findAllByPostId(PageRequest.of(page, size), postId, DEFAULT_REPLY);
}
// 这个办法用于查问所有评论, 蕴含评论的用户, 但不蕴含用户的明码
public static final ReplyFetcher DEFAULT_REPLY = ReplyFetcher.$.allScalarFields().user(UserFetcher.$.username());
}
ExceptionController
当初编写全局异样解决, 这个异样解决用于解决 NullPointerException
和IllegalArgumentException
等异样
@ControllerAdvice// 这个注解用于标记这个类是一个全局异样解决类
public class ExceptionController {@ExceptionHandler(Exception.class)// 这个注解用于标记这个办法是一个异样解决办法, 这里的参数是异样的类型, 因为要解决所有的异样, 所以这里应用的是 Exception
public ResponseEntity<String> exception(Exception exception) {if (exception instanceof NullPointerException) {
// 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 404 状态码和异样信息
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
} else if (exception instanceof IllegalArgumentException) {
// 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 400 状态码和异样信息
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exception.getMessage());
} else if (exception instanceof NotLoginException) {
// 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 401 状态码和异样信息
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(exception.getMessage());
}
// 这里返回的是一个 ResponseEntity, 这个对象蕴含了状态码和响应体, 这里咱们返回的是 500 状态码和异样信息
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(exception.getMessage());
}
}
最初咱们要开启后端的 CORS
, 这个性能用于解决跨域问题, 这里我应用的是拦截器来实现的, 首先咱们须要编写一个CorsInterceptor
类, 这个类用于拦挡所有的申请, 而后设置响应头
@Component
public class CorsInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws Exception {response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "*");
response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*");
response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}
而后咱们须要在 WebMvcConfigurer
中注册这个拦截器
@Configuration
@AllArgsConstructor
public class WebMvcConfiguration implements WebMvcConfigurer {
private final CorsInterceptor corsInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(corsInterceptor);
}
}
前端编写
生成前端的代码
咱们在后端定义了那么多的实体类, 那么咱们须要将这些实体类生成 TypeScript
的代码, 这里咱们应用的是 Jimmer
自带的性能, 在之前的配置中咱们配置了 jimmer.client.ts.path=/typescript.zip
, 这个配置用于指定生成的TypeScript
代码的门路, 咱们能够手动拜访这个地址下载一个压缩包, 而后解压这个压缩包, 而后将其中的 typescript
文件夹复制到 src
目录下, 也能够写一个脚本来主动实现这些工作, 这里我应用了 PowerShell
来实现这些工作, 在和 src
的同级目录下新建一个 scripts
目录, 而后在其中新建一个 generated.ps1
文件, 这个文件用于生成 TypeScript
代码
$sourceUrl = "http://localhost:8080/typescript.zip"
$tempDir = Join-Path $(Get-Item -Path $env:TEMP).FullName (New-Guid).ToString()
$generatedPath = Join-Path (Split-Path $PSScriptRoot) "src/__generated"
# 下载源码并解压
if (-not (Test-Path $tempDir)) {New-Item -ItemType Directory -Path $tempDir | Out-Null}
Write-Host "Downloading $sourceUrl to $tempDir..."
Invoke-WebRequest -Uri $sourceUrl -OutFile "$tempDir/source.zip"
Write-Host "Extracting files from $tempDir/source.zip..."
Expand-Archive -Path "$tempDir/source.zip" -DestinationPath $tempDir
# 删除已有的生成门路
if (Test-Path $generatedPath) {
Write-Host "Deleting existing generated path $generatedPath..."
Remove-Item -Path $generatedPath -Recurse -Force
}
# 挪动文件
Write-Host "Moving files from $tempDir to $generatedPath..."
Move-Item -Path $tempDir -Destination $generatedPath
# 删除源码
Remove-Item -Path "$generatedPath/source.zip"
Write-Host "API code is refreshed successfully."
如果是对 Typescript
代码比拟相熟的话, 看这些生成的代码应该没什么问题, 但这里工夫无限, 所以我就不具体解说了
编写前端代码
在生成了代码之后, 咱们还须要为 fetch
创立一个拦截器, 这个拦截器用于在每次申请时增加 token
, 在common
目录中新建一个 ApiInstance.ts
文件, 这个文件用于创立 fetch
的实例
import {Api} from "../__generated"
import {useSessionStore} from "../store"
// 创立一个 Api 实例
export const api = new Api(async ({ uri, method, body}) => {
// 获取 token
const token = useSessionStore().token as string | undefined
// 发送申请
const response = await fetch(`http://localhost:8080${uri}`, {
method,
body: body !== undefined ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json;charset=UTF-8",
...(token !== undefined && token !== "" ? { token} : {})
}
})
// 如果响应状态码不是 200, 那么就抛出一个谬误
if (response.status !== 200) {throw await response.text()
}
// 获取响应体
const text = await response.text()
// 如果响应体为空, 那么就返回 null
if (text.length === 0) {return null}
// 返回响应体
return JSON.parse(text)
})
编写注册页面
在 views
目录下新建一个 Register.tsx
文件, 这个文件用于渲染注册页面
import {createImmerSignal} from "solid-immer"
import {UserInput} from "../__generated/model/static"
import {api} from "../common/ApiInstance"
import toast from "solid-toast"
import {Link, Route, useNavigate} from "@solidjs/router"
const Register = () => {const navigate = useNavigate() // 这个函数用于跳转路由
let formRef // 这个援用用于获取表单元素
const [form, setForm] = createImmerSignal<UserInput>({}) // 这个函数用于创立一个信号, 这个信号用于存储表单数据
const submit = (e) => {
// 阻止默认事件
e.preventDefault()
e.stopPropagation()
// 如果表单验证通过, 那么就发送申请
if (formRef.checkValidity()) {
api.userController
.register({body: form() })
.then(() => {
// 如果注册胜利, 那么就跳转到登录页面
toast.success("Registered successfully")
navigate("/login")
})
.catch((err) => {
// 如果注册失败, 那么就显示错误信息
toast.error(err)
})
}
// 这里须要手动调用表单的验证办法, 因为咱们应用的是原生的表单验证, 而不是应用第三方库
formRef.classList.add("was-validated")
}
return (
<div class="vh-100 d-flex justify-content-center align-items-center">
<div class="card p-5">
<form ref={formRef} class="needs-validation" style={{width: "18rem", height: "14rem"}} novalidate>
<div class="d-flex flex-column justify-content-between h-100">
<div>
<label for="validationCustom01" class="form-label">
Username
</label>
<input
type="text"
class="form-control"
id="validationCustom01"
value={form().username ?? ""}
required
onInput={(e) => setForm((draft) => (draft.username = e.currentTarget.value))}
/>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter your username.</div>
</div>
<div>
<label for="validationCustom02" class="form-label">
Password
</label>
<input
type="text"
class="form-control"
id="validationCustom02"
required
value={form().password ?? ""}
onInput={(e) => setForm((draft) => (draft.password = e.currentTarget.value))}
/>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter your password.</div>
</div>
<Link href="/login">Login</Link>
{/* 这里应用了 Link 组件, 这个组件用于跳转路由 */}
<button class="btn btn-primary" type="submit" onClick={submit}>
Register
</button>
</div>
</form>
</div>
</div>
)
}
export default Register
编写登录页面
在之前曾经创立好的 Login.tsx
中编写登录页面
import {createImmerSignal} from "solid-immer"
import {useSessionStore} from "../store"
import {UserInput} from "../__generated/model/static"
import {api} from "../common/ApiInstance"
import toast from "solid-toast"
import {Link, Route, useNavigate} from "@solidjs/router"
const Login = () => {const navigate = useNavigate()
const session = useSessionStore() // 这个函数用于获取 session 状态
let formRef
const [form, setForm] = createImmerSignal<UserInput>({})
const submit = (e) => {e.preventDefault()
e.stopPropagation()
if (formRef.checkValidity()) {
api.sessionController
.login({body: form() })
.then((data) => {
// 如果登录胜利, 那么就将 token 和 id 存储到 session 中, 而后跳转到首页
session.setToken(data.token)
session.setId(data.id)
navigate("/")
})
.catch((err) => {toast.error(err)
})
}
formRef.classList.add("was-validated")
}
return (
<div class="vh-100 d-flex justify-content-center align-items-center">
<div class="card p-5">
<form ref={formRef} class="needs-validation" style={{width: "18rem", height: "14rem"}} novalidate>
<div class="d-flex flex-column justify-content-between h-100">
<div>
<label for="validationCustom01" class="form-label">
Username
</label>
<input
type="text"
class="form-control"
id="validationCustom01"
value={form().username ?? ""}
required
onInput={(e) => setForm((draft) => (draft.username = e.currentTarget.value))}
/>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter your username.</div>
</div>
<div>
<label for="validationCustom02" class="form-label">
Password
</label>
<input
type="text"
class="form-control"
id="validationCustom02"
required
value={form().password ?? ""}
onInput={(e) => setForm((draft) => (draft.password = e.currentTarget.value))}
/>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter your password.</div>
</div>
<Link href="/register">Register</Link>
<button class="btn btn-primary" type="submit" onClick={submit}>
Login
</button>
</div>
</form>
</div>
</div>
)
}
export default Login
编写首页布局
在 src
目录下新建一个 layouts
目录, 之后在外面创立一个 HomeLayout.tsx
文件, 这就是首页的布局, 这个布局蕴含了一个导航栏
import {Outlet} from "@solidjs/router"
import Nav from "../components/Nav"
const HomeLayout = () => {
return (
<div>
<div class="container">
<Nav />
</div>
<div class="container">
{/* 这里应用了 Outlet 组件, 这个组件用于渲染子路由 */}
<Outlet />
</div>
</div>
)
}
export default HomeLayout
而后在 src
目录下新建一个 components
目录, 在其中新建一个 Nav.tsx
文件, 这个文件用于渲染导航栏,Logo
能够从 SolidJS
的官网下载, 而后放到 assets
目录下, 间接把官网上的扒下来就行
import {Link} from "@solidjs/router"
import Logo from "../assets/solid.svg"
const Nav = () => {
return (
<div>
<nav class="navbar navbar-expand-lg navbar-light">
<img src={Logo} alt=""width="30"height="24"class="d-inline-block align-text-top" />
<span class="navbar-brand mb-0 h1">Solid</span>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<Link class="nav-link" href="/">
Posts
</Link>
</li>
<li class="nav-item">
<Link class="nav-link" href="/category">
Category
</Link>
</li>
<li class="nav-item">
<Link class="nav-link" href="/write">
Write
</Link>
</li>
</ul>
</nav>
</div>
)
}
export default Nav
之后别离在 views
目录中新建 Posts.tsx
,Category.tsx
,Write.tsx
文件, 这些文件用于渲染文章列表, 分类列表, 写文章页面
const Posts = () => {return <div>Post</div>}
export default Posts
const Category = () => {return <div>Category</div>}
export default Category
const Write = () => {return () => <div>Write</div>
}
export default Write
而后咱们须要在 router
目录中编写路由数组
const routes: RouteDefinition[] = [
{
path: "/login",
component: () => <Login />},
{
path: "/register",
component: () => <Register />},
{
path: "/",
component: () => <HomeLayout />,
children: [
{
path: "/",
component: () => <Posts />},
{
path: "/category",
component: () => <Category />},
{
path: "/write",
component: () => <Write />}
]
}
]
编写公布文章页面
在编写页面之前, 须要将 TanStack Query
的QueryClientProvider
组件放到 App.tsx
中
import type {Component} from "solid-js"
import {Router, useRoutes} from "@solidjs/router"
import routes from "./router"
import {Toaster} from "solid-toast"
import {QueryClient, QueryClientProvider} from "@tanstack/solid-query"
const App: Component = () => {const queryClient = new QueryClient()
const Routes = useRoutes(routes)
return (
<>
<Toaster />
{/* 这里应用了 QueryClientProvider 组件, 这个组件用于提供 QueryClient, 这个组件必须放在 Router 组件的里面 */}
<QueryClientProvider client={queryClient}>
<Router>
<Routes />
</Router>
</QueryClientProvider>
</>
)
}
export default App
首先须要明确一点,TanStack Query
在 SolidJS
中的响应并不反对解构
不反对这样
const {isLoading, error, data} = useQuery(...)
只能这样
const query = createQuery(...)
而后在 Write.tsx
中编写公布文章页面
在开始之前, 须要向 Category
中插入 3 条数据
insert into category(id, name)
values (uuid(), 'Category 1');
insert into category(id, name)
values (uuid(), 'Category 2');
insert into category(id, name)
values (uuid(), 'Category 3');
发帖页面的代码如下
import {createQuery} from "@tanstack/solid-query"
import {api} from "../common/ApiInstance"
import {PostInput} from "../__generated/model/static"
import {createImmerSignal} from "solid-immer"
import {For, Match, Switch} from "solid-js"
import {useNavigate} from "@solidjs/router"
import toast from "solid-toast"
const Write = () => {const navigate = useNavigate()
const [form, setForm] = createImmerSignal<PostInput>({}) // 这个函数用于创立一个信号, 这个信号用于存储表单数据
let formRef
const categories = createQuery({queryKey: () => ["category"],
queryFn: () => api.categoryController.findCategories()
})
const submit = (e) => {e.preventDefault()
e.stopPropagation()
if (formRef.checkValidity()) {
api.postController
.createPost({body: form() })
.then(() => {toast.success("Publish success")
navigate("/")
})
.catch((err) => {toast.error(err)
})
}
formRef.classList.add("was-validated")
}
return () => (
<div>
<form ref={formRef} class="needs-validation" novalidate>
<div>
<label class="form-label">Title</label>
<input
type="text"
class="form-control"
required
onInput={(e) => setForm((draft) => (draft.title = e.currentTarget.value))}
/>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter title.</div>
</div>
<div>
<label class="form-label">Content</label>
<textarea
class="form-control"
style={{height: "16rem"}}
required
onInput={(e) => setForm((draft) => (draft.content = e.currentTarget.value))}
/>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter content.</div>
</div>
<div>
<label class="form-label">Category</label>
{/* 这里应用了 Switch 组件, 这个组件用于依据条件渲染不同的内容 */}
<Switch>
{/* 这里应用了 Match 组件, 这个组件用于匹配条件, 如果条件为 true, 那么就渲染加载 */}
<Match when={categories.isLoading}>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</Match>
<Match when={categories.isError}>Error</Match>
<Match when={categories.isSuccess}>
<select
class="form-select"
value={form().categoryId}
onInput={(e) =>
setForm((draft) => {draft.categoryId = e.currentTarget.value})
}
required
>
<For each={categories.data}>{(item) => <option value={item.id}>{item.name}</option>}</For>
</select>
</Match>
</Switch>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please select category.</div>
</div>
<button class="btn btn-primary" type="submit" onClick={submit}>
Publish
</button>
</form>
</div>
)
}
export default Write
编写文章详情页面
在 views
目录下创立 Post.tsx
文件
import {useParams} from "@solidjs/router"
import {createQuery} from "@tanstack/solid-query"
import {api} from "../common/ApiInstance"
import {Match, Switch} from "solid-js"
const Post = () => {
// 这里应用了 useParams 函数, 这个函数用于获取路由参数
const params = useParams()
const post = createQuery({queryKey: () => ["post", params.id],
queryFn: () => api.postController.findPost({ id: params.id})
})
return (
<div>
<Switch>
<Match when={post.isLoading}>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</Match>
<Match when={post.isError}>Error</Match>
<Match when={post.isSuccess}>
<div>{post.data.title}</div>
<div>{post.data.category.name}</div>
<div>{post.data.content}</div>
</Match>
</Switch>
</div>
)
}
export default Post
之后在 router
目录中增加一个路由, 布局是HomeLayout
, 门路是/post/:id
, 组件是Post
{
path: "/post/:id",
component: () => <Post />}
编写文章列表页面
在 Posts.tsx
中编写文章列表页面, 这里应用了 RequestOf
, 这个类型用于获取申请的参数类型, 这个类型是Jimmer
主动生成的, 比方 RequestOf<typeof api.postController.findPosts>
的类型就是{page?: number; size?: number;}
import {createImmerSignal} from "solid-immer"
import {RequestOf} from "../__generated"
import {api} from "../common/ApiInstance"
import {createQuery} from "@tanstack/solid-query"
import {For, Match, Switch} from "solid-js"
import {Link} from "@solidjs/router"
const Posts = () => {const [options, setOptions] = createImmerSignal<RequestOf<typeof api.postController.findPosts>>({})
const posts = createQuery({queryKey: () => ["posts", options()],
queryFn: () => api.postController.findPosts(options())
})
return (
<>
<Switch>
<Match when={posts.isLoading}>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</Match>
<Match when={posts.isError}>Error</Match>
<Match when={posts.isSuccess}>
<ul class="list-group">
<For each={posts.data.content}>
{(post) => (
<li class="list-group-item">
{/* 这里应用了 Link 组件, 这个组件用于跳转路由, 这里应用了 post.id 作为参数 */}
<Link href={`/post/${post.id}`}>{post.title}</Link>
</li>
)}
</For>
</ul>
{/* 分页 */}
<nav aria-label="Page navigation example">
<ul class="pagination">
<li class="page-item">
<div
class="page-link"
onClick={() => {if (options().page ?? 0 > 0)
setOptions((draft) => {draft.page = (options().page ?? 0) - 1
})
}}
>
Previous
</div>
</li>
{/* 这里应用了 For 组件, 一共有几页就渲染几页, 这里应用了 Array.from 办法, 这个办法用于将一个数字转换为数组, 比方 Array.from({length: 3})的后果就是[1,2,3] */}
<For each={Array.from({ length: posts.data.totalPages}, (_, i) => i + 1)}>
{(page, index) => (
<li class="page-item">
<div
class="page-link"
classList={{active: (options().page ?? 0) === index()}}
onClick={() =>
setOptions((draft) => {draft.page = index()
})
}
>
{page}
</div>
</li>
)}
</For>
<li class="page-item">
<div
class="page-link"
onClick={() => {if ((options().page ?? 0) < posts.data.totalPages - 1)
setOptions((draft) => {draft.page = (options().page ?? 0) + 1
})
}}
>
Next
</div>
</li>
</ul>
</nav>
</Match>
</Switch>
</>
)
}
export default Posts
编写分类列表页面
在 Category.tsx
中编写分类列表页面, 这里为了不便, 我间接将 Posts.tsx
中的代码复制过去了, 而后批改了一下申请参数
import {createImmerSignal} from "solid-immer"
import {RequestOf} from "../__generated"
import {createQuery} from "@tanstack/solid-query"
import {api} from "../common/ApiInstance"
import {Link, useParams} from "@solidjs/router"
import {Switch, Match, For} from "solid-js"
const CategoryPosts = () => {const params = useParams()
const [options, setOptions] = createImmerSignal<RequestOf<typeof api.postController.findPostsByCategory>>({categoryId: params.categoryId})
const posts = createQuery({queryKey: () => ["posts", options()],
queryFn: () => api.postController.findPostsByCategory(options())
})
return (
<Switch>
<Match when={posts.isLoading}>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</Match>
<Match when={posts.isError}>Error</Match>
<Match when={posts.isSuccess}>
<ul class="list-group">
<For each={posts.data.content}>
{(post) => (
<li class="list-group-item">
<Link href={`/post/${post.id}`}>{post.title}</Link>
</li>
)}
</For>
</ul>
<nav aria-label="Page navigation example">
<ul class="pagination">
<li class="page-item">
<div
class="page-link"
onClick={() => {if (options().page ?? 0 > 0)
setOptions((draft) => {draft.page = (options().page ?? 0) - 1
})
}}
>
Previous
</div>
</li>
<For each={Array.from({ length: posts.data.totalPages}, (_, i) => i + 1)}>
{(page, index) => (
<li class="page-item">
<div
class="page-link"
classList={{active: (options().page ?? 0) === index()}}
onClick={() =>
setOptions((draft) => {draft.page = index()
})
}
>
{page}
</div>
</li>
)}
</For>
<li class="page-item">
<div
class="page-link"
onClick={() => {if ((options().page ?? 0) < posts.data.totalPages - 1)
setOptions((draft) => {draft.page = (options().page ?? 0) + 1
})
}}
>
Next
</div>
</li>
</ul>
</nav>
</Match>
</Switch>
)
}
export default CategoryPosts
之后在 router
目录中增加一个路由, 布局是HomeLayout
, 门路是/category/:categoryId
, 组件是CategoryPosts
{
path: "/category/:categoryId",
component: () => <CategoryPosts />}
编写评论列表组件
在 components
目录下新建一个 Replys.tsx
文件, 这个文件用于渲染评论列表
import {createQuery} from "@tanstack/solid-query"
import {createImmerSignal} from "solid-immer"
import {Component, For, Match, Switch} from "solid-js"
import {RequestOf} from "../__generated"
import {api} from "../common/ApiInstance"
import {ReplyInput} from "../__generated/model/static"
import toast from "solid-toast"
const Replys: Component<{postId: string}> = ({postId}) => {const [options, setOptions] = createImmerSignal<RequestOf<typeof api.replyController.findRepliesByPost>>({postId})
const replies = createQuery({queryKey: () => ["replies", options()],
queryFn: () => api.replyController.findRepliesByPost(options())
})
let formRef
const [form, setForm] = createImmerSignal<ReplyInput>({postId})
const submit = (e) => {e.preventDefault()
e.stopPropagation()
if (formRef.checkValidity()) {
api.replyController
.createReply({body: form()
})
.then(() => {toast.success("Reply created")
})
.catch(() => {toast.error("Reply creation failed")
})
}
formRef.classList.add("was-validated")
}
return (
<div>
<div class="card">
<form ref={formRef} class="needs-validation">
<div>
<label class="form-label">Content</label>
<textarea
class="form-control"
value={form().content ?? ""}
required
onInput={(e) => setForm((draft) => (draft.content = e.currentTarget.value))}
/>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter your reply.</div>
</div>
<button class="btn btn-primary" type="submit" onClick={submit}>
Reply
</button>
</form>
</div>
<Switch>
<Match when={replies.isLoading}>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</Match>
<Match when={replies.isError}>Error</Match>
<Match when={replies.isSuccess}>
<ul class="list-group">
<For each={replies.data.content}>
{(reply) => (
<li class="list-group-item">
<div class="d-flex justify-content-between">
<div>User:{reply.user.username}</div>
<div>{reply.content}</div>
</div>
</li>
)}
</For>
</ul>
<nav aria-label="Page navigation example">
<ul class="pagination">
<li class="page-item">
<div
class="page-link"
onClick={() => {if (options().page ?? 0 > 0)
setOptions((draft) => {draft.page = (options().page ?? 0) - 1
})
}}
>
Previous
</div>
</li>
<For each={Array.from({ length: replies.data.totalPages}, (_, i) => i + 1)}>
{(page, index) => (
<li class="page-item">
<div
class="page-link"
classList={{active: (options().page ?? 0) === index()}}
onClick={() =>
setOptions((draft) => {draft.page = index()
})
}
>
{page}
</div>
</li>
)}
</For>
<li class="page-item">
<div
class="page-link"
onClick={() => {if ((options().page ?? 0) < replies.data.totalPages - 1)
setOptions((draft) => {draft.page = (options().page ?? 0) + 1
})
}}
>
Next
</div>
</li>
</ul>
</nav>
</Match>
</Switch>
</div>
)
}
export default Replys
最初将这个组件放到 Post.tsx
中
<Replys postId={params.id} />
好了, 到这里咱们的博客就实现了, 如果你想要看残缺的代码, 能够去 GitHub 上看