从这节课开始,我们来做一个实战练习,我的 B 站。我们从播放视频页面开始做,做了适当简化,说明一点,这个页面的所有图标,动画等都是自己做的,虽然丑了点,但不会侵权。先来看看页面效果:
整个程序分成前端页面部分和后端 java 代码两部分。页面部分你就当是前端妹妹给你做好了,我们是后端 java 程序员,需要用面向对象的思想和 Spring Boot 与前端对接。从哪儿开始呢?
之前讲过任何程序都分成数据和逻辑两部分,我们之前也讲了数据和逻辑的分离,数据部分对应 Java Bean,逻辑部分对应 Service,我们的代码开发,就可以从设计 Java Bean 和 Service 类开始。
先从 Java Bean 开始分析,通过这个页面,查看它的组成就可以看出来,将来 Java Bean 的组成,我们这个页面,主要展现的是视频信息,有哪些视频信息呢?
开始抽象,每一集抽象为 Play 类,整个视频抽象为 Video 类
public class Play {
private String id;
private String title;
private String url;
LocalTime duration;
// ...
}
public class Video {
private List<Play> playList;
private String bv;
private String type; // 类型: 自制、转载
private String category; // 分区: 生活、游戏、娱乐、知识、影视、音乐、动画、时尚、美食、汽车、运动、科技、动物圈、舞蹈、国创、鬼畜、纪录片、番剧、电视剧、电影
private String title; // 总标题, 最多 80 字
private String cover; // 封面
private String introduction; // 简介, 最多 250 字
private LocalDateTime publishTime; // 发布时间
private List<String> tagList; // 最多 10 个
// ...
}
有同学问,Video 和 Play 中这些属性名能不能改成其它的,答案是,可以改,但你这边改了,前端页面也得跟着改。因为前端页面中使用对象时,属性名与后端 JavaBean 代码的属性名是一一对应的
前端约定
http://localhost:8080/video/1
显示 1 号视频http://localhost:8080/video/2
显示 2 号视频页面上需要的是 Video 对象,我们先返回固定的 Video 对象试试
@Controller
public class VideoController {
@RequestMapping("/video/1")
@ResponseBody
public Video t1() {
List<Play> plays = List.of(
new Play("P1", "二分查找-演示", LocalTime.parse("00:05:46"), "1_1.mp4"),
new Play("P2", "二分查找-实现", LocalTime.parse("00:06:47"), "1_2.mp4")
);
return new Video("1", "面试专题-基础篇", LocalDateTime.now(), "1.png", "祝你面试游刃有余!",
List.of("面试", "Java", "计算机技术"), plays, "自制", "科技->计算机技术");
}
@RequestMapping("/video/2")
@ResponseBody
public Video t2() {
List<Play> plays = List.of(
new Play("P1", "Java中的线程状态", LocalTime.parse("00:09:45"), "2_1.mp4"),
new Play("P2", "代码演示1", LocalTime.parse("00:07:05"), "2_2.mp4"),
new Play("P3", "代码演示2", LocalTime.parse("00:05:01"), "2_3.mp4")
);
return new Video("2", "面试专题-并发篇", LocalDateTime.now(), "2.png", "祝你面试游刃有余!",
List.of("面试", "Java", "计算机技术"), plays, "自制", "科技->计算机技术");
}
}
还有一个未解决的问题,就是前端页面中不同的 URL 路径对应不同的视频
http://localhost:8080/video/1
显示 1 号视频http://localhost:8080/video/2
显示 2 号视频后端 Java 代码每个 URL 路径都用了一个方法来返回视频,但是不可能无限增加方法,得找一个办法把多个路径映射到一个方法,这就是接下来要介绍的路径参数
@RequestMapping("/video/{bv}")
这里 bv 就是一个路径参数,前端 URL 是 /video/1,bv 值就是1,前端 URL 是 /video/2,bv 值就是 2@Controller
public class VideoController {
// 路径参数
// 1. @RequestMapping("/video/{bv}")
// 2. @PathVariable String bv, @PathVariable 表示该方法参数要从路径中获取
@RequestMapping("/video/{bv}") // 1, 2, 3...
@ResponseBody
public Video t(@PathVariable String bv) {
if(bv.equals("1")) {
List<Play> plays = List.of(
new Play("P1", "二分查找-演示", LocalTime.parse("00:05:46"), "1_1.mp4"),
new Play("P2", "二分查找-实现", LocalTime.parse("00:06:47"), "1_2.mp4")
);
return new Video("1", "面试专题-基础篇", LocalDateTime.now(), "1.png", "祝你面试游刃有余!",
List.of("面试", "Java", "计算机技术"), plays, "自制", "科技->计算机技术");
}
if (bv.equals("2")) {
List<Play> plays = List.of(
new Play("P1", "Java中的线程状态", LocalTime.parse("00:09:45"), "2_1.mp4"),
new Play("P2", "代码演示1", LocalTime.parse("00:07:05"), "2_2.mp4"),
new Play("P3", "代码演示2", LocalTime.parse("00:05:01"), "2_3.mp4")
);
return new Video("2", "面试专题-并发篇", LocalDateTime.now(), "2.png", "祝你面试游刃有余!",
List.of("面试", "Java", "计算机技术"), plays, "自制", "科技->计算机技术");
}
return null;
}
现有代码的缺点是
这些视频内容不应当固定在代码里,而是存于 p.csv 文件中,读取方式如下
try {
List<String> data = Files.readAllLines(Path.of("data", "p.csv"));
// ...
} catch (IOException e) {
throw new RuntimeException(e);
}
设计 Service 如下:
@Service
public class VideoService1 {
// 查询方法,根据视频编号,查询 Video 对象
public Video find(String bv) { // bv 参数代表视频编号 1
try {
List<String> data = Files.readAllLines(Path.of("data", "p.csv")); // 1 ~ 7
// String line 就是读到的文件中的每一行数据
for (String line : data) {
String[] s = line.split(",");
if(s[0].equals(bv)) { // 找到了
String[] tags = s[7].split("_");
// playList 暂时用空集合
return new Video(s[0], s[3], LocalDateTime.parse(s[6]), s[4], s[5], List.of(tags), List.of(), s[1], s[2]);
}
}
// 没有找到
return null;
} catch (IOException e) {
// RuntimeException 运行时异常, 把编译时异常转换为运行时异常
throw new RuntimeException(e);
}
}
}
补充 playList
@Service
public class VideoService1 {
// 查询方法,根据视频编号,查询 Video 对象
public Video find(String bv) { // bv 参数代表视频编号 1
try {
List<String> data = Files.readAllLines(Path.of("data", "p.csv")); // 1 ~ 7
// String line 就是读到的文件中的每一行数据
for (String line : data) {
String[] s = line.split(",");
if(s[0].equals(bv)) { // 找到了
String[] tags = s[7].split("_");
return new Video(s[0], s[3], LocalDateTime.parse(s[6]), s[4], s[5],
List.of(tags), getPlayList(bv), s[1], s[2]);
}
}
// 没有找到
return null;
} catch (IOException e) {
// RuntimeException 运行时异常, 把编译时异常转换为运行时异常
throw new RuntimeException(e);
}
}
// 读取选集文件 v_1.csv
private List<Play> getPlayList(String bv) {
try {
List<String> vdata = Files.readAllLines(Path.of("data", "v_" + bv + ".csv"));
List<Play> list = new ArrayList<>();
for (String vline : vdata) {
String[] ss = vline.split(",");
list.add(new Play(ss[0], ss[1], LocalTime.parse(ss[3]), ss[2]));
}
return list;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
像图片、视频这样的文件,它们内容都不会轻易变动,所以有个叫法称为静态资源,另外由于它们占用的空间较大,不太适合与其它程序代码打包在一起。但如果它们不在这个位置,我还想通过 url 访问这些文件该怎么找到它们呢,前面我们说过,这些文件放在 static 目录下,就能通过 url 找到,static 就是这些文件的起点,比如
在浏览器中输入一个 url 地址:
这就需要让一个 url 地址与服务器的一个磁盘目录相关联,具体做法是
@SpringBootApplication // 支持 SpringBoot 功能的应用程序
public class Module5App implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(Module5App.class, args); // 运行 SpringBoot 程序
}
// 作用:把 url 路径和 磁盘路径做一个映射
// http://localhost:8080/play/xxx => static/play
// http://localhost:8080/play/xxx => d:/aaa
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// url 路径 磁盘路径
registry.addResourceHandler("/play/**").addResourceLocations("file:d:\\aaa\\");
}
}
每次查询都应当读取一次视频文件吗?
准备一个 Map<String, Video>
@Service
public class VideoService1 {
@PostConstruct // 这是一个初始化方法,在对象创建之后,只会调用一次
public void init() { // 初始化
try {
List<String> data = Files.readAllLines(Path.of("data", "p.csv")); // 1 ~ 7
for (String line : data) {
String[] s = line.split(",");
String[] tags = s[7].split("_");
Video video = new Video(s[0], s[3], LocalDateTime.parse(s[6]),
s[4], s[5], List.of(tags), getPlayList(s[0]), s[1], s[2]);
map.put(s[0], video);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// List, Map
/*
1 -> Video 1
2 -> Video 2
...
*/
Map<String, Video> map = new HashMap<>();
// 调用多次
// 查询方法,根据视频编号,查询 Video 对象
public Video find(String bv) {
return map.get(bv);
}
// 读取选集文件 v_1.csv
private List<Play> getPlayList(String bv) {
try {
List<String> vdata = Files.readAllLines(Path.of("data", "v_" + bv + ".csv"));
List<Play> list = new ArrayList<>();
for (String vline : vdata) {
String[] ss = vline.split(",");
list.add(new Play(ss[0], ss[1], LocalTime.parse(ss[3]), ss[2]));
}
return list;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
完整代码
@Service
public class VideoService2 {
// 将一行字符串变成 Video 对象
Video string2Video(String string) {
String[] s = string.split(",");
String[] tags = s[7].split("_");
return new Video(s[0], s[3], LocalDateTime.parse(s[6]), s[4], s[5], List.of(tags),
getPlayList(s[0]), s[1], s[2]);
}
// 读取 playList
List<Play> getPlayList(String bv) {
try (Stream<String> data = Files.lines(Path.of("data", "v_" + bv + ".csv"))) {
return data.map(this::string2Play).collect(Collectors.toList());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// 将一行字符串变成 Play 对象
Play string2Play(String string) {
String[] ss = string.split(",");
return new Play(ss[0], ss[1], LocalTime.parse(ss[3]), ss[2]);
}
// Video 对象中哪部分作为 map 的 key
String key(Video video) {
return video.getBv();
}
// Video 对象中哪部分作为 map 的 value
Video value(Video video) {
return video;
}
@PostConstruct
public void init() {
try (Stream<String> data = Files.lines(Path.of("data", "p.csv"))) {
map = data.map(this::string2Video).collect(Collectors.toMap(this::key, this::value));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
Map<String, Video> map = new HashMap<>();
public Video find(String bv) {
return map.get(bv);
}
}
Stream API 有两套方法
读取 csv 文件的数据,虽然看着也不难,但要新增、修改、删除,就比较麻烦了,而且即便是查询,我们现在这种一次性地把文件的所有行都读取,也只适合数据量较小的情况下,可以想象,如果数据量非常庞大,不说别的,这种做法很容易撑爆内存。
因此我们需要一个更专业的,能够对文件数据进行增删改查的软件,这就是数据库。数据库有很多种,这里介绍其中最为流行的数据库:MySQL
MySQL 相对于普通文件,对数据处理的特点如下
我们常说的 MySQL,其实主要是指 MySQL Server,将来要操作数据,需要通过 MySQL Client 客户端连接至数据库服务器,真正干活的是 Server,客户端负责发送命令,客户端可以有多个,发送的命令称为 SQL 语句,SQL 语句就能对数据进行增删改查
Java 代码当然也可以充当客户端,同样由 Java 代码执行 SQL 语句,对数据进行操作。但 SQL 语句查询到的数据,并不能自动封装为 Java Bean 对象,因此我们要借助一些框架来完成数据与 Java Bean 对象之间的转换操作,这就是后面要学习的 MyBatis 框架,它可以更方便实现数据和 Java Bean 对象之间的转换。
因此,我们接下来的学习顺序是 MySQL 服务器的安装使用、SQL 语句的语法,以及 MyBatis 框架
首先到 oracle 官网
进入 MySQL 下载页面
选择软件
选择平台
下载
解压缩,配置 PATH 环境变量,添加 MySQL解压目录\bin
到环境变量
注意
- 如果用 cmd,那么改完环境变量,只要打开新的 cmd 窗口,就可以立刻生效
- 如果用 Fluent Terminal,改完之后,需要注销当前用户才能生效
初始化需要执行
mysqld --initialize
会生成初始数据库,在 MySQL解压目录\data
目录下,这时候需要查看一个名为 *.err 的文件,内部含有临时密码,把它记录下来,如图
以命令行窗口方式运行服务器
mysqld --console
这种方式好处是
还有一种方式是把 MySQL 安装为系统服务(要以管理员权限启动 cmd)
mysqld --install
net start mysql
这样每次开机就会自动启动 MySQL 服务程序
打开一个新窗口,运行客户端,登录至服务器
mysql -uroot -p
Enter password: 临时密码
alter user 'root'@'localhost' identified by 'root';
mysql 分成库和表,库用来包含表,表用来存储数据,这里的表就和我们常见的二维表格类似
查看库
show databases;
创建库
create database 库名;
切换库
use 库名;
查看表
show tables;
创建表,语法
create table 表名 (
字段名1 类型 [约束],
字段名2 类型 [约束],
...
);
例
create table student(
id int primary key,
name varchar(10)
);
插入数据,语法 insert into 表名(字段1, 字段2 ...) values (值1, 值2 ...)
insert into student(id, name) values (1, '张三');
insert into student(id, name) values (2, '李四');
insert into student(id, name) values (3, '王五');
查询数据,语法 select 字段1, 字段2 ... from 表 where 条件
select id,name from student;
按编号查询数据
select id,name from student where id = 1;
修改数据,语法 update 表 set 字段1=值1, 字段2=值2 where 条件
update student set name='张小三' where id = 1;
删除数据,语法 delete from 表 where 条件
delete from student where id = 3;
可以用 JetBrain 出品的 datagrip,以可视化的方式管理库,表
用它的意义在于界面看起来更友好一些,前面讲的 insert、delete、update、select 还是需要熟练掌握
选择数据库的类型
配置界面
将文件的列与数据库表的列对应起来
导入数据,服务器和客户端运行时都要添加 --local_infile=1
参数
1.txt
1,张三
2,李四
4,王五
用下面的语句
load data local infile '1.txt' replace into table student fields terminated by ',' lines terminated by '\r\n';
pom.xml 中加入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
...
<dependencies>
...
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
...
</project>
application.properties 中配置数据库连接信息
spring.datasource.url=jdbc:mysql://localhost:3306/test
spring.datasource.username=root
spring.datasource.password=root
Java Bean 用来存数据
public class Student {
private int id;
private String name;
public Student() {
}
public Student(int id, String name) {
this.id = id;
this.name = name;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
Mapper 接口用来增删改查
@Mapper // 这是一个专用于增删改查的接口
// 实现类(mybatis 和 spring), 可以通过 @Autowired 依赖注入获取实现类对象
public interface StudentMapper {
@Select("""
select id, name
from student
""")
List<Student> findAll();
// 根据编号查询学生
@Select("""
select id, name
from student
where id=#{id}
""")
Student findById(int id); // id=1,2,3...
// 新增学生
/*@Insert("""
insert into student(id, name)
values (#{id}, #{name})
""")
void insert(@Param("id") int i, @Param("name") String n);*/
@Insert("""
insert into student(id, name)
values (#{id}, #{name})
""")
void insert(Student stu);
// 修改学生
@Update("""
update student set name=#{name}
where id=#{id}
""")
void update(Student stu);
@Delete("delete from student where id=#{id}")
void delete(int id);
}
注意事项
// 单元测试
@SpringBootTest
public class TestStudentMapper {
@Autowired
StudentMapper studentMapper;
@Test // 测试查询所有
public void test1() {
System.out.println(1);
List<Student> all = studentMapper.findAll();
for (Student stu : all) {
System.out.println(stu.getId() + " " + stu.getName());
}
}
@Test // 测试根据id查询
public void test2() {
System.out.println(2);
Student stu = studentMapper.findById(4);
System.out.println(stu);
// System.out.println(stu.getId() + " " + stu.getName());
}
@Test
public void test3() {
// studentMapper.insert(5, "钱七");
Student stu = new Student(6, "周八");
studentMapper.insert(stu);
}
@Test
public void test4() {
Student stu = new Student(1, "张小三");
studentMapper.update(stu);
}
@Test
public void test5() {
studentMapper.delete(5);
}
}
注意事项
@Mapper
public interface VideoMapper {
// 根据 bv 号查询视频
@Select("""
select bv,
type,
category,
title,
cover,
introduction,
publish_time,
tags
from video
where bv=#{bv}
""")
Video findByBv(String bv);
/*
数据库习惯 underscore 下划线分隔多个单词 如 :publish_time
Java 习惯 驼峰命名法 camel case 如 :publishTime
Java面试_求职_计算机技术_面试技巧 字符串
List
*/
}
要在查询时执行【下划线-驼峰命名转换】,需要在 application.properties 中加入配置
#...
mybatis.configuration.map-underscore-to-camel-case=true
@Mapper
public interface PlayMapper {
// 查询某个视频的选集
@Select("""
select id, title, duration, url
from play
where bv=#{bv}
""")
List<Play> findByBv(String bv);
}
Java Bean 需要添加 tags 字段,以便与数据库的 tags 列相对应,原本的 getTagList 方法用来把字符串转换为 List<String>
public class Video {
// ...
private String tags;
public String getTags() {
return tags;
}
public void setTags(String tags) {
this.tags = tags;
}
public List<String> getTagList() {
String tags = this.tags; // Java面试_求职_计算机技术_面试技巧
if (tags == null) {
return List.of();
}
String[] s = tags.split("_");
return List.of(s);
}
}
/**
* 从数据库中获取视频数据
*/
@Service
public class VideoService2 {
@Autowired
private VideoMapper videoMapper;
@Autowired
private PlayMapper playMapper;
// 根据 bv 号查询视频
public Video find(String bv) {
Video video = videoMapper.findByBv(bv);
if (video == null) {
return null;
}
List<Play> playList = playMapper.findByBv(bv);
video.setPlayList(playList);
return video;
}
}
@Controller
public class VideoController {
@RequestMapping("/video/{bv}")
@ResponseBody
public Video t(@PathVariable String bv) {
return videoService2.find(bv);
}
@Autowired
private VideoService2 videoService2;
}
原始发布功能预览
我的 B 站发布功能预览
功能1:
功能2:
功能3:
请求:/upload 路径
请求数据:
响应数据:
代码
@Controller
public class UploadController {
@Value("${video-path}")
private String videoPath;
@RequestMapping("/upload")
@ResponseBody
// MultipartFile 专用于上传二进制数据的类型
public Map<String, String> upload(int i, int chunks, MultipartFile data, String url)
throws IOException {
data.transferTo(Path.of(videoPath, url + ".part" + i));
return Map.of(url, (i * 100.0 / chunks) + "%");
}
// ...
}
@Value(“${video-path}”) 表示它标注的字段的值来自于 application.properties 配置文件
# ...
video-path=d:\\aaa\\
data.transferTo 作用是将上传的临时文件 MultipartFile 另存为一个新的文件
计算百分比时,要先乘 100.0 这个 double 值,把整个运算提升为小数运算,否则整数除法算不出带小数的百分比值
spring 上传单个文件的最大值上限为 1MB,要调整的话在 application.properties 中配置
# ...
spring.servlet.multipart.max-file-size=8MB
请求:/finish
请求数据:
响应数据:无
代码
@Controller
public class UploadController {
@RequestMapping("/finish")
@ResponseBody
public void finish(int chunks, String url) throws IOException {
try (FileOutputStream os = new FileOutputStream(videoPath + url)) {
// 写入内容
for (int i = 1; i <= chunks; i++) { // 1,2,3
Path part = Path.of(videoPath, url + ".part" + i);
Files.copy(part, os);
part.toFile().delete(); // 删除 part 文件
}
}
}
// ...
}
请求:/uploadCover
请求数据:
响应数据:
代码
@Controller
public class UploadController {
@Value("${img-path}")
private String imgPath;
@RequestMapping("/uploadCover")
@ResponseBody
public Map<String, String> uploadCover(MultipartFile data, String cover) throws IOException {
data.transferTo(Path.of(imgPath, cover));
return Map.of("cover", cover);
}
// ...
}
@Value(“${img-path}”) 表示它标注的字段的值来自于 application.properties 配置文件
img-path=d:\\img\\
请求:/publish
请求数据:json
{
"title":"反射",
"type":"自制",
"category":"科技->计算机",
"cover":"封面图片名.png",
"tags":"面试_java_反射",
"introduction":"简介...",
"playList": [
{"id":"P1","title":"标题1","url":"视频文件名.mp4","duration":"03:30"},
{"id":"P2","title":"标题2","url":"视频文件名.mp4","duration":"03:30"},
{"id":"P3","title":"标题3","url":"视频文件名.mp4","duration":"04:49"},
{"id":"P4","title":"标题4","url":"视频文件名.mp4","duration":"08:19"}
]
}
响应数据:
代码
@Mapper
public interface VideoMapper {
// ...
@Insert("""
insert into video(type, category, title, cover,
introduction, publish_time, tags)
VALUES (#{type}, #{category}, #{title}, #{cover},
#{introduction}, #{publishTime}, #{tags})
""")
void insert(Video video);
// 获取最近生成的自增主键值
@Select("select last_insert_id()")
int lastInsertId();
// 更新 bv 号
@Update("update video set bv=#{bv} where id=#{id}")
void updateBv(@Param("bv") String bv, @Param("id") int id);
}
PlayMapper
@Mapper
public interface PlayMapper {
// ...
@Insert("""
insert into play(id,title,duration,url,bv)
values (#{p.id},#{p.title},#{p.duration},#{p.url},#{bv})
""")
void insert(@Param("p") Play play, @Param("bv") String bv);
}
VideoService2
@Service
public class VideoService2 {
@Autowired
private VideoMapper videoMapper;
@Autowired
private PlayMapper playMapper;
// 发布视频
public String publish(Video video) {
video.setPublishTime(LocalDateTime.now()); // 设置发布事件
// 1. 向 video 表插入视频
videoMapper.insert(video);
// 2. 生成 bv 号
int id = videoMapper.lastInsertId();
String bv = Bv.get(id);
// 3. 更新 bv 号
videoMapper.updateBv(bv, id);
// 4. 向 play 表插入所有视频选集
for (Play play : video.getPlayList()) {
playMapper.insert(play, bv);
}
return bv;
}
// ...
}
VideoController
@Controller
public class VideoController {
// ...
@Autowired
private VideoService2 videoService2;
@RequestMapping("/publish")
@ResponseBody
public Map<String,String> publish(@RequestBody Video video) {
String bv = videoService2.publish(video);
return Map.of("bv", bv);
}
}