首发于Enaium的集体博客


前言

前端咱们应用了SolidJS来进行开发,它是一个新出的前端框架,它的特点是应用React Hooks的形式来进行开发,并且它的APIReactAPI十分类似,所以如果你会React的话,那么你就会SolidJS.

后端咱们应用了SpringBoot来进行开发,数据库咱们应用了MySQL来进行开发,这里我应用的是MariaDB来进行开发,ORM 框架应用的是Jimmer,这个框架它反对JavaKotlin,这里为了简略起见就应用Java开发,但理论应用Koltin会更不便.

前端环境

首先呢,须要装置一下SolidJS的模板,这里我应用的是TypeScriptBootstrap的模板

npx degit solidjs/templates/ts-bootstrap websitecd websitepmpm install # or npm installpmpm 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,而不是zustandimport { 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,因为ktsgroovy更好用,剩下的本人看着改改,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的StarterannotationProcessor("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.Driverspring.datasource.username=rootspring.datasource.password=rootspring.datasource.url=jdbc:mariadb://192.168.56.101:3306/blog#Jimmer配置,这里配置了生成Typescript的门路,以及是否显示SQL语句,以及是否丑化SQL语句jimmer.client.ts.path=/typescript.zipjimmer.show-sql=truejimmer.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,TableTableEx之类的,找到我的项目下的build/generated/sources/annotationProcessor地位,你就会发现这些生成的代码,如果应用的是Kotlin生成的类会和这些有点不同,但本篇文章应用的是Java,所以就不多说了,因为尽管实质上一样,但还是有点不同的

如果你应用的是 Linux 环境,那么你能够像我一样为每个实体类加上一个Table注解,那是因为Linux的名称是辨别大小写的,而Windows的名称是不辨别大小写的,Jimmer默认是会将所有的名称转换为大写,你也能够间接应用配置来批改这个行为

@Beanpublic DatabaseNamingStrategy databaseNamingStrategy() {    return DefaultDatabaseNamingStrategy.LOWER_CASE;//这里应用的是小写}

定义 Repository

JimmerJPA一样,同样领有Repository,因为Jimmer同时反对JavaKotlin,所以这里的RepositoryJRepository,这个接口继承了JPAJpaRepository,所以它领有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

@Repositorypublic interface CategoryRepository extends JRepository<Category, UUID> {}
@Repositorypublic interface PostRepository extends JRepository<Post, UUID> {}
@Repositorypublic interface ReplyRepository extends JRepository<Reply, UUID> {}

当初最根本的代码就写完了,还有一部分没有写,之后一遍写Controller时再写,当初咱们须要编写一下Controller的代码

编写 Controller

SessionController

SessionController用来解决用户的登录和和退出登录

@RestControllerpublic class SessionController {    @PutMapping("/sessions/")    public void login() {    }    @DeleteMapping("/sessions/")    public void logout() {    }}

既然要登录,那么就须要一个登录的申请体,这里我应用的是JSON来进行传输,所以咱们须要定义一个UserInput类,这个类用于接管JSON数据

@Datapublic 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办法,这个办法用于依据用户名查问用户

@Repositorypublic interface UserRepository extends JRepository<User, UUID> {    User findByUsername(String username);//这里的办法名是有规定的,如果是依据用户名查问,那么办法名就是findByUsername,如果是依据用户名和明码查问,那么办法名就是findByUsernameAndPassword}

而后咱们须要在SessionController中编写login办法,简略的登录逻辑,如果用户不存在,那么就抛出一个NullPointerException,如果明码谬误,那么就抛出一个IllegalArgumentException,如果登录胜利,那么就返回一个LoginResponse对象,这个对象蕴含了tokenid

@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());}
@Datapublic class LoginResponse {    private final String token;    private final UUID id;}

而后咱们须要编写logout办法,这个办法用于退出登录,这个办法很简略,就是调用SaTokenlogout办法

@DeleteMapping("/sessions/")public void logout() {    StpUtil.logout();}

UserController

接着咱们须要编写UserController,这个类用于解决用户的注册

@RestController@AllArgsConstructorpublic 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@AllArgsConstructorpublic class CategoryController {    private final CategoryRepository categoryRepository;    @GetMapping("/categories/")    public List<Category> findCategories() {        return categoryRepository.findAll();    }}

PostController

而后咱们须要编写PostController,这个类用于解决文章的查问,这里会应用JimmerFetcher性能,这个性能用于查问关联表的数据,这里咱们须要查问文章的分类,所以须要应用Fetcher性能

首先定义PostInput类,这个类用于接管JSON数据

@Datapublic class PostInput {    @Nullable    private final UUID id;    @Nullable    private final String title;    @Nullable    private final String content;    @Nullable    private final UUID categoryId;}

接着设置表的关联关系,表的关联在表的实体类中进行设置,须要留神的是,关联建设后,之前生成的代码是不会更新的,所以须要手动更新一下,这里我应用的是IntelliJ IDEARebuild Project性能,这个性能会从新生成代码,或者应用Gradlebuild性能也能够,这里我应用的是IntelliJ IDEARebuild 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@AllArgsConstructorpublic 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中的代码

@Repositorypublic interface PostRepository extends JRepository<Post, UUID> {    //这个办法依据分类ID查问文章,具体的Fetcher在办法的参数中指定    Page<Post> findAllByCategoryId(Pageable pageable, UUID categoryId, Fetcher<Post> fetcher);}

ReplyController

而后咱们须要编写ReplyController,这个类用于解决评论的查问,这里会应用JimmerFetcher性能,这个性能用于查问关联表的数据,这里咱们须要查问评论的用户,所以须要应用Fetcher性能

首先定义ReplyInput类,这个类用于接管JSON数据

@Datapublic 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@AllArgsConstructorpublic 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

当初编写全局异样解决,这个异样解决用于解决NullPointerExceptionIllegalArgumentException等异样

@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类,这个类用于拦挡所有的申请,而后设置响应头

@Componentpublic 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@AllArgsConstructorpublic 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 QueryQueryClientProvider组件放到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 QuerySolidJS中的响应并不反对解构

不反对这样

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上看