论如何用七天的时间打造一款(并不)爆款的匿名树洞网站
人一旦闲下来,是十分可怕的,就比如我,自从上了大学,每年国庆都能整出点骚活来:去年国庆,用 Jetpack Compose 搓了一个课程表 Android App,而到了今年,我直接搓了一个网站前后端出来……
起因
其实很早以前我就想开发一套面向我校学生的匿名树洞网站了,早在半个月前,我就已经开始研究如何将自己的服务接入学校的 CAS 统一认证系统里,正好十一闲着没事儿干,遂说干就干,就开始了开发。
开发过程
开发框架选型
因为先前有过相关的学习和开发经验,因此我毫不犹豫地选择了前后端分离的开发模式:前端采用 Vue 3 作为 JavaScript 框架,Vuetify 作为 UI 框架;后端采用 Java 开发 Spring Boot 框架应用。
虽然说是毫不犹豫,但其实前端和后端选型的时候,我还是有一些调整和妥协。前端方面,其实直到现在,Vuetify 的 Vue 3 适配版本 Vuetify Titan 仍处于 Beta Live 状态,RC 版本可能仍需要几个月的时间才会产生,但是因为 Vuetify 提供的组件和其他 API 相比其他 UI 框架实在不知道高到哪里去了,又因为个人也非常喜欢 Material Design,遂仍旧采用了 Vuetify。
而后端方面,作为一个 Kotlin 爱好者,刚开始我其实是打算用 Kotlin 开发后端的,但是又考虑到这套代码可能可以供学校的学生在入门 Java 或是 Spring Boot 开发的时候能作为参考学习(当然,这是我的一厢情愿),遂决定改用 Java 开发。
接下来,让我们谈谈详细的实际开发内容部分:
开发内容(前端)
先来谈谈前端。前端开发上,我采用了 vite 作为构建工具,使用 yarn 作为包管理器,除了 vue 和 vuetify 以外,我还主要引入了这些依赖:
vue-router
(Vue 官方开发的路由系统)vue-showdown
(一套对 Markdown 解析库 showdown.js 的 Vue 封装)typescript
(由于 Vuetify 的引导式命令行新建项目向导默认初始化的项目没有 typescript,因此我手动引入了,但是不知道是不是我的配置问题,这导致 IDE 导入在 ts 文件中声明的函数时,导入的文件雷静总是错误的变为 js 而不是 ts)
我想得到的一个成品是:
- 一个主页,可以以卡片流的方式显示最新的树洞(帖子)预览
- 一个详细页,可以显示详细的树洞内容和评论
- 一个发布树洞界面,可以输入树洞内容,选择标签
- 一个回复树洞界面,可以回复指定的树洞
- 一个登录界面,可以通过学校的 CAS 统一认证系统登录
最后,我大差不差的把这些页面的原型都开发了出来,在后端开发完成后,我又成功完成了与后端的对接,不过,与期望不同的是一些小问题导致的差异:
- 本来想做一个收藏功能,但是懒得做(即使后端已经声明好了对应的数据结构),所以没做
- 举报功能也没做
- 回复功能本来是想允许分别对主帖和评论回复的,但是最后没想好怎么表现,所以只做了对主贴回复
- 使用 CAS 统一认证系统登录的方案废了,因为学校的 CAS 统一认证系统的服务校验路由(/p3/serviceVaildate)无法通过外网访问,必须通过校园网(很显然我没有)或是 WebVPN 访问。本来我已经设计了一套通过要求用户提交 WebVPN Cookies 并且及时验证有效性后即可登录的模式,结果在线上测试的时候才发现这个 Cookies 只要换了个 IP 地址就会自动失效,因此使用用户提交的 Cookie 是不可能的,遂只能放弃,并改用一套通过向教育邮箱发送验证码来验证身份的注册方式。
开发前端期间,还遇到了许多疑难问题,比如组件中使用 this
作用域在开发环境可以工作,但是在生产环境无法工作的问题,又比如 Vue 3 新的组合式 API 和 setup 函数与先前使用方式不同导致差异的问题,又比如使用异步 fetch API 的问题。不过好在这些问题最后都有惊无险的化解了。不过在这里,必须特别感谢 GitHub 上 这位老兄的 Gist 提供了一套在 Vue 上使用异步 computed 属性的方式,简直是救了我的命(我在这个一年前的 Gist 下面回复,作者竟然还回我了,在交谈中,他建议我在现在最好使用 VueUse 提供的 computedAsync 功能,不过因为我懒得调整了所以最后没用)。
开发内容(后端)
前端部分的原型和主要框架开发大概花费了 5 天时间(9/30/2022 —— 10/4/2022),之后,我便开始着手开发后端。比起略显生涩的前端,早已驾轻就熟的后端才是我的大本营,因此开发时间也很快。
后端主要引入的开发依赖有:
org.springframework.boot:spring-boot-starter-data-jpa
,org.springframework.boot:spring-boot-starter-data-jdbc
,mysql:mysql-connector-java
ORM,数据库连接桥和数据库驱动;org.springframework.boot:spring-boot-starter-web
Spring Boot Web 开发 Starter;org.springframework.boot:spring-boot-starter-cache
,org.springframework.boot:spring-boot-starter-data-redis
,org.springframework.session:spring-session-data-redis
, Spring Boot 数据和会话 Redis 缓存 Starter;org.springframework.boot:spring-boot-starter-mail
Spring Boot 邮件管理 Startercn.dev33:sa-token-spring-boot-starter
SA Token 的 Spring Boot Starter 封装com.google.code.gson:gson
Google 的 Json 解析库com.squareup.okhttp3:okhttp
一个 Kotlin 开发的 HTTP 客户端com.fasterxml.jackson.dataformat:jackson-dataformat-xml
Jackson 的 XML 解析模块(引入这个本来是为了识别 CAS 统一认证系统返回的 XML 信息)cn.hutool:hutool-all
一个功能及其丰富和强大的 Utils 库com.ramostear:Happy-Captcha
一个使用简单,功能强大的验证码模块org.projectlombok:lombok
Lombok(其实我是不想用 Lombok 的,但是奈何 Getter 和 Setter 太多了看得我眼花缭乱,不得已还是得把 Lombok 请回来)
采用了这些数据结构:
代码语言:javascript复制@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "users")
public class UserEntity {
@Id
@Column(nullable = false)
private long id;
private String email;
private String password;
@OneToMany(mappedBy = "poster")
private List<PostEntity> createdPosts;
@OneToMany(mappedBy = "poster")
private List<CommentEntity> createdComments;
@ManyToMany
@JoinTable(name = "STARRED_POSTS")
private List<PostEntity> starredPosts;
}
代码语言:javascript复制@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "posts")
public class PostEntity {
@Id
@Column(nullable = false)
@SequenceGenerator(name = "post_id_seq", sequenceName = "post_id_seq", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "post_id_seq")
private long id;
@ManyToOne
private UserEntity poster;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "post")
private List<CommentEntity> comments;
@ManyToMany(mappedBy = "starredPosts")
private List<UserEntity> starredUsers;
@Column(nullable = false)
private Date postTime;
@Column(nullable = false, length = 65535, columnDefinition = "Text")
private String content;
@ElementCollection
private List<String> attributes;
@ElementCollection
private List<String> tags;
}
代码语言:javascript复制@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "comments")
public class CommentEntity {
@Id
@Column(nullable = false)
@SequenceGenerator(name = "comment_id_seq", sequenceName = "comment_id_seq", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "comment_id_seq")
private long id;
@ManyToOne
private UserEntity poster;
@ManyToOne
private PostEntity post;
@Column(nullable = false)
private Date postTime;
@Column(nullable = false)
private String content;
}
添加了这些 Controller:
AuthController
登录和注册相关接口CaptchaController
验证码相关接口PostController
树洞发布和回复相关接口UserController
用户信息相关接口WebVpnController
WebVPN 相关接口(不过最后没用上)
这期间也遇到了一些新的坑:
- 引入
jackson-dataformat-xml
导致 RestController 默认返回 XML 数据而不是 Gson(通过在 Spring Application 配置文件设置spring.mvc.converters.preferred-json-mapper
,且在前端请求时显式指定 Content-Type 解决) - 经典跨域问题(通过添加 @CrossOgirin 注解解决)
(另外,HuTool 真是太好用了,我已经无法想象没有 HuTool 的 Java 开发了)
成果展示
生产站点: XAUFEHole – 西财树洞 (minecraft.kim)
其实可能用手机看起来效果会更好些:
最后
个人感觉还是做了个很棒的工作的,并且最后的效果也很符合我的预期(除了人流量以外)。
这些代码也开源到了 GitHub 上(还没来得及设定一个开源许可证),有兴趣的可以参考看看:
shaokeyibb/XAUFEHoleFrontend: 西财树洞前端程序,Made by Vue3 && Vuetify (github.com)
shaokeyibb/XAUFEHoleBackend: 西财树洞后端程序,Made by SpringBoot (github.com)
那么,就这样吧。