Springboot+ajax+mybatis实现简易网页

趁着周末又浅浅复习了一下结对编程用到的技术,重新制作了一个简单的demo练手,分享一下自己的理解~

Springboot框架

上学期,我在接触springboot框架之前没有接触过spring框架和Javaweb等知识,所以刚刚上手的时候还是非常的困惑不理解的。经过了一个学期断断续续的学习,加上假期(四舍五入也算)自己完成的博客,对springboot的结构和编写时的流程有了一些自己的理解:

img

img

  1. pojo层(entity层):用于定义数据库对象对应的属性,是存放实体类的文件夹,例如:user类等。可以使用lombok来自动生成构造方法和getter setter
1
2
3
4
5
6
7
8
9
10
11
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
private String username;
private String password;
}
  1. mapper层(dao层):是持久层,用于与数据库进行数据交互,mybatis就是一种应用广泛的持久层框架。在mapper层中,先设计接口,然后通过配置文件或者注解来实现crud操作。由于玩不明白配置文件,所以俺就选择了使用@Select注解(菜)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import com.example.mybatis1008.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Repository;

import java.util.List;

@Mapper
@Repository
public interface UserMapper {

@Select("select * from user")
List<User> queryAll();

@Select("update user set password = #{password} where username = #{username}")
void changePassword(String username, String password);
}
  1. service层:是业务逻辑层,完成功能的设计。也是先设计接口,再创建要实现的类。在service层中,我们可以调用mapper层中的接口来进行业务逻辑应用的处理。service的impl是对service接口进行实现,把mapper和service进行整合的文件。封装Service层的业务逻辑有利于业务逻辑的独立性和重复利用性。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//UserService
public interface UserService {
public List<User> queryAll();
public void changePassword(String username,String password);
}
//UserServiceImpl
import com.example.mybatis1008.mapper.UserMapper;
import com.example.mybatis1008.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserServiceImpl implements UserService{
@Autowired
UserMapper userMapper;
@Override
public List<User> queryAll(){
return userMapper.queryAll();
}

@Override
public void changePassword(String username,String password) {
userMapper.changePassword(username,password);
System.out.println("修改成功");
}
}
  1. controller层:是控制层,调用service层中实现的功能来实现业务,控制请求和响应,进行前后端交互
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import com.example.mybatis1008.service.UserServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Controller
public class UserController {
@Autowired UserServiceImpl userService;
@RequestMapping("get")
public void get(HttpServletResponse response, HttpServletRequest request) throws IOException {
System.out.println(userService.queryAll());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(String.valueOf(userService.queryAll()));
}

@RequestMapping("changePassword")
public void changePassword(HttpServletRequest request,HttpServletResponse response) throws IOException{
String username = request.getParameter("username");
String password = request.getParameter("password");
System.out.println(username+" "+password);
userService.changePassword(username,password);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(String.valueOf(userService.queryAll()));
}
}

借用一张网图来清晰明了的总结一下结构~

img

mybatis

mybatis使用之前需要对项目进行一些配置,首先就是在maven中引入mybatis(具体maven文件在最后给出~),然后还需要在resources文件夹下新建一个mybatis-config.xml文件,其中的具体内容在mybatis的官网上可以找到,这里也给大家列出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/exam?useSSL=false&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=UTC"/>
<property name="username" value="root"/>
<property name="password" value="1234"/>
</dataSource>
</environment>
</environments>
<mappers>
<!-- 这里是引入mapper的配置文件,由于我直接用了select接口,所以这个就没有用上-->
<!-- <mapper resource="/mapper/UserMapper.xml"/>-->
</mappers>
</configuration>

这样我的mybatis就可以使用了(希望你滴也可以,这东西挺玄学的,第一次配的时候搞了半天),我采用的是直接在持久层使用@Select注解来写sql语句(见mapper层),自我感觉也挺方便清晰的,不过主流方案好像是在对应的配置文件中写,可能维护的时候更方便吧。

在使用时,直接创建mapper对象进行方法调用,即可实现其所对应的sql语句。

ajax

我更习惯用jQuery封装好的ajax,写法更简洁一点,原生的ajax区别也不算大。详见jQuery的ajax的简单应用

简单展示

  • 初始状态

img

  • 点击获取,获取现有的用户信息

img

  • 填写用户名和密码,点击修改,修改信息

img

界面没有做什么美化,项目结构也很简单,但麻雀虽小五脏俱全,使用这些技术就可以实现一个小小的web项目了。(本文源码下载)

由于缺少研究,作者目前只是简单知道一些注解的作用,对于xml文件的编写并不是很熟悉,很多地方暂时也不是很清楚。但是作者认为,不理解的时候也不需深究,熟练的使用对理解原理有着重要作用。当能做到熟练使用时,也许我们对自己未知的知识会产生一些新的理解。

希望本文能帮助大家对web项目的开发有一些新的认识!

最后附上maven配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<?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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>Mybatis1008</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Mybatis1008</name>
<description>Mybatis1008</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.1</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

Springboot+ajax+mybatis实现简易网页

趁着周末又浅浅复习了一下结对编程用到的技术,重新制作了一个简单的demo练手,分享一下自己的理解~

Springboot框架

上学期,我在接触springboot框架之前没有接触过spring框架和Javaweb等知识,所以刚刚上手的时候还是非常的困惑不理解的。经过了一个学期断断续续的学习,加上假期(四舍五入也算)自己完成的博客,对springboot的结构和编写时的流程有了一些自己的理解:

img

img

  1. pojo层(entity层):用于定义数据库对象对应的属性,是存放实体类的文件夹,例如:user类等。可以使用lombok来自动生成构造方法和getter setter
1
2
3
4
5
6
7
8
9
10
11
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
private String username;
private String password;
}
  1. mapper层(dao层):是持久层,用于与数据库进行数据交互,mybatis就是一种应用广泛的持久层框架。在mapper层中,先设计接口,然后通过配置文件或者注解来实现crud操作。由于玩不明白配置文件,所以俺就选择了使用@Select注解(菜)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import com.example.mybatis1008.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Repository;

import java.util.List;

@Mapper
@Repository
public interface UserMapper {

@Select("select * from user")
List<User> queryAll();

@Select("update user set password = #{password} where username = #{username}")
void changePassword(String username, String password);
}
  1. service层:是业务逻辑层,完成功能的设计。也是先设计接口,再创建要实现的类。在service层中,我们可以调用mapper层中的接口来进行业务逻辑应用的处理。service的impl是对service接口进行实现,把mapper和service进行整合的文件。封装Service层的业务逻辑有利于业务逻辑的独立性和重复利用性。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//UserService
public interface UserService {
public List<User> queryAll();
public void changePassword(String username,String password);
}
//UserServiceImpl
import com.example.mybatis1008.mapper.UserMapper;
import com.example.mybatis1008.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserServiceImpl implements UserService{
@Autowired
UserMapper userMapper;
@Override
public List<User> queryAll(){
return userMapper.queryAll();
}

@Override
public void changePassword(String username,String password) {
userMapper.changePassword(username,password);
System.out.println("修改成功");
}
}
  1. controller层:是控制层,调用service层中实现的功能来实现业务,控制请求和响应,进行前后端交互
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import com.example.mybatis1008.service.UserServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Controller
public class UserController {
@Autowired UserServiceImpl userService;
@RequestMapping("get")
public void get(HttpServletResponse response, HttpServletRequest request) throws IOException {
System.out.println(userService.queryAll());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(String.valueOf(userService.queryAll()));
}

@RequestMapping("changePassword")
public void changePassword(HttpServletRequest request,HttpServletResponse response) throws IOException{
String username = request.getParameter("username");
String password = request.getParameter("password");
System.out.println(username+" "+password);
userService.changePassword(username,password);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(String.valueOf(userService.queryAll()));
}
}

借用一张网图来清晰明了的总结一下结构~

img

mybatis

mybatis使用之前需要对项目进行一些配置,首先就是在maven中引入mybatis(具体maven文件在最后给出~),然后还需要在resources文件夹下新建一个mybatis-config.xml文件,其中的具体内容在mybatis的官网上可以找到,这里也给大家列出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/exam?useSSL=false&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=UTC"/>
<property name="username" value="root"/>
<property name="password" value="1234"/>
</dataSource>
</environment>
</environments>
<mappers>
<!-- 这里是引入mapper的配置文件,由于我直接用了select接口,所以这个就没有用上-->
<!-- <mapper resource="/mapper/UserMapper.xml"/>-->
</mappers>
</configuration>

这样我的mybatis就可以使用了(希望你滴也可以,这东西挺玄学的,第一次配的时候搞了半天),我采用的是直接在持久层使用@Select注解来写sql语句(见mapper层),自我感觉也挺方便清晰的,不过主流方案好像是在对应的配置文件中写,可能维护的时候更方便吧。

在使用时,直接创建mapper对象进行方法调用,即可实现其所对应的sql语句。

ajax

我更习惯用jQuery封装好的ajax,写法更简洁一点,原生的ajax区别也不算大。详见jQuery的ajax的简单应用

简单展示

  • 初始状态

img

  • 点击获取,获取现有的用户信息

img

  • 填写用户名和密码,点击修改,修改信息

img

界面没有做什么美化,项目结构也很简单,但麻雀虽小五脏俱全,使用这些技术就可以实现一个小小的web项目了。(本文源码下载)

由于缺少研究,作者目前只是简单知道一些注解的作用,对于xml文件的编写并不是很熟悉,很多地方暂时也不是很清楚。但是作者认为,不理解的时候也不需深究,熟练的使用对理解原理有着重要作用。当能做到熟练使用时,也许我们对自己未知的知识会产生一些新的理解。

希望本文能帮助大家对web项目的开发有一些新的认识!

最后附上maven配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<?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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>Mybatis1008</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Mybatis1008</name>
<description>Mybatis1008</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.1</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

Java的JNI机制

Java还有一个JNI机制,它的全称:Java Native Interface,即Java本地接口。它允许在Java虚拟机内运行的Java代码与其他编程语言(如C/C++和汇编语言)编写的程序和库进行交互(在Android开发中用得比较多)

简单举例:我们现在想要让C++语言程序帮助我们的Java程序实现a+b的运算,首先我们需要创建一个本地方法:

1
2
3
4
5
6
7
8
9
10
package com.test;

public class Main {

public static void main(String[] args) {
System.out.println(sum(1,2));
}
//native 是本地方法关键字,无需在Java中实现本方法
public static native int sum(int a,int b);
}

创建好后,接着点击构建按钮,会出现一个out文件夹,也就是生成的class文件在其中

img

接着我们直接生成对应的C头文件:

(注意,这条指令基于jdk1.8实现,jdk版本过高 请使用javac -h指令)

1
javah -classpath out/production/pro02 -d ./jni com.test.Main

生成的头文件位于jni文件夹下:

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_test_Main */

#ifndef _Included_com_test_Main
#define _Included_com_test_Main
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: com_test_Main
* Method: sum
* Signature: (II)V
*/
JNIEXPORT void JNICALL Java_com_test_Main_sum
//这里的两个jint就是传入的a,b两个参数
(JNIEnv *, jclass, jint, jint);

#ifdef __cplusplus
}
#endif
#endif

接着我们在CLion中新建一个C++项目,引入刚刚生成的头文件并导入jni相关头文件(在JDK文件夹中)

首先修改CMake文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cmake_minimum_required(VERSION 3.20)
project(pro02)

#和使用的jdk的路径有关
include_directories(E:/Users/19318/.jdks/corretto-1.8.0_322-1/include)
include_directories(E:/Users/19318/.jdks/corretto-1.8.0_322-1/include/win32)
include_directories(E:/Users/19318/.jdks/corretto-1.8.0_322-1/include/win32/bridge)

set(CMAKE_CXX_STANDARD 14)

#add_executable:生成一个可执行文件 不添加则无法再clion中运行
add_executable(pro02 com_test_Main.cpp com_test_Main.h)
#add_library:生成一个库
#sum:是生成共享库的名字,前面会自动加上lib前缀,如这里windows生成的是 libsum.dll
#SHARED:库的类型为动态,windows上生成.dll,而STATIC 则是生成静态库,windows生成.a文件
add_library(sum SHARED com_test_Main.cpp)

再将jni下的头文件复制到c++项目下:

img

认识一下引用类型对照表:

img

接下来可以开始编写程序了:

1
2
3
4
5
6
7
8
9
#include <jni.h>
#include "com_test_Main.h"


JNIEXPORT jint JNICALL Java_com_test_Main_sum
(JNIEnv *, jclass, jint a, jint b)
{
return a+b;
}

接着我们就可以将cpp编译为动态链接库,在Windows下应该生成.dll文件

直接运行项目,我们可以在cmake-build-debug文件夹下找到生成的dll文件

img

注意这里运行时会报错undefined reference to ‘WinMain’,因为我们的项目没有main函数,但报错并不影响dll文件的生成和使用。

将生成的dll文件加载到Java程序中,运行即可得到结果。

1
2
3
4
5
6
7
8
9
10
11
public class Main {
static {
//输入dll文件的路径
System.load("D:\\C++\\pro02\\cmake-build-debug\\libsum.dll");
}
public static void main(String[] args) {
System.out.println(sum(1,2));
}

public static native int sum(int a,int b);
}

Java - PriorityQueue 优先队列

最近遇到一道算法题,说难其实也不难,但是用之前的思路和知识总感觉很麻烦很耗时。在看高手解析时发现了一种新思路,即使用PriorityQueue来取最大值和最小值。在此记录。

题目描述

题目链接-牛客网

数据流中的中位数
描述
如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。我们使用Insert()方法读取数据流,使用GetMedian()方法获取当前读取数据的中位数。
示例1
输入:
[5,2,3,4,1,6,7,0,8]
返回值:
"5.00 3.50 3.00 3.50 3.00 3.50 4.00 3.50 4.00 "
说明:
数据流里面不断吐出的是5,2,3…,则得到的平均数分别为5,(5+2)/2,3…
示例2
输入:
[1,1,1]
返回值:
"1.00 1.00 1.00 "

解题思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 大顶堆,存储左半边元素 */
private PriorityQueue<Integer> left =new PriorityQueue<>((o1, o2)-> o2 - o1);
/* 小顶堆,存储右半边元素,并且右半边元素都大于左半边 */
private PriorityQueue<Integer> right =new PriorityQueue<>();
/* 当前数据流读入的元素个数 */
private int N=0;
public void Insert(Integer val){
/* 插入要保证两个堆存于平衡状态 */
if(N%2==0){
/* N 为偶数的情况下插入到右半边。
* 因为右半边元素都要大于左半边,但是新插入的元素不一定比左半边元素来的大,
* 因此需要先将元素插入左半边,然后利用左半边为大顶堆的特点,取出堆顶元素即为最大元素,此时插入右半边 */
left.add(val);
right.add(left.poll());
}else{
right.add(val);
left.add(right.poll());
}
N++;
}
public Double GetMedian(){
if( N % 2 == 0 ) return(left.peek()+ right.peek())/2.0;
else return(double) right.peek();
}

PriorityQueue介绍

PriorityQueue(优先队列)是一种特殊的队列,它可以保证每次取出的堆顶元素都是最小值(或者最大值)。其通过堆实现,具体说是通过完全二叉树(complete binary tree)实现的小顶堆(任意一个非叶子节点的权值,都不大于其左右子节点的权值),也就意味着可以通过数组来作为PriorityQueue的底层实现。

特点:

  • PriorityQueue是一个无限制的队列,并且动态增长。
  • 它不允许null对象。
  • 添加到PriorityQueue的对象必须具有可比性。
  • *默认情况下,优先级队列的对象按自然顺序排序
  • 比较器可用于队列中对象的自定义排序。
  • 优先级队列的头部是基于自然排序或基于比较器的排序的最小元素。当我们轮询队列时,它从队列中返回头对象。
  • 如果存在多个具有相同优先级的对象,则它可以随机轮询其中任何一个。
  • PriorityQueue 不是线程安全的。PriorityBlockingQueue在并发环境中使用。
  • 它为add和poll方法提供了**O(log(n))**时间。

方法:

  • add()和offer()

add(E e)和offer(E e)的语义相同,都是向优先队列中插入元素,只是Queue接口规定二者对插入失败时的处理不同,前者在插入失败时抛出异常,后则则会返回false。对于PriorityQueue这两个方法其实没什么差别。

img

  • element()和peek()

element()和peek()的语义完全相同,都是获取但不删除队首元素,也就是队列中权值最小的那个元素,二者唯一的区别是当方法失败时前者抛出异常,后者返回null。根据小顶堆的性质,堆顶那个元素就是全局最小的那个;由于堆用数组表示,根据下标关系,0下标处的那个元素既是堆顶元素。所以直接返回数组0下标处的那个元素即可

img

  • remove()和poll()

remove()和poll()方法的语义也完全相同,都是获取并删除队首元素,区别是当方法失败时前者抛出异常,后者返回null。由于删除操作会改变队列的结构,为维护小顶堆的性质,需要进行必要的调整。

img

  • remove(Object object)

remove(Object o)方法用于删除队列中跟o相等的某一个元素(如果有多个相等,只删除一个),该方法不是Queue接口内的方法,而是Collection接口的方法。由于删除操作会改变队列结构,所以要进行调整;又由于删除元素的位置可能是任意的,所以调整过程比其它函数稍加繁琐。具体来说,remove(Object o)可以分为2种情况:1. 删除的是最后一个元素。直接删除即可,不需要调整。2. 删除的不是最后一个元素,从删除点开始以最后一个元素为参照调用一次siftDown()即可。此处不再赘述。

img

代码实现

参考文献:

[41.1 数据流中的中位数 | CS-Notes (cyc2018.xyz)](http://www.cyc2018.xyz/算法/剑指 Offer 题解/41.1 数据流中的中位数.html)

JCFInternals/8-PriorityQueue.md at master · CarpenterLee/JCFInternals (github.com)

软件工程导论-个人项目

题目要求

用户:

小学、初中和高中数学老师。

功能:

1、命令行输入用户名和密码,两者之间用空格隔开(程序预设小学、初中和高中各三个账号,具体见附表),如果用户名和密码都正确,将根据账户类型显示“当前选择为XX出题”,XX为小学、初中和高中三个选项中的一个。否则提示“请输入正确的用户名、密码”,重新输入用户名、密码; while循环

2、登录后,系统提示“准备生成XX数学题目,请输入生成题目数量(输入-1将退出当前用户,重新登录):”,XX为小学、初中和高中三个选项中的一个,用户输入所需出的卷子的题目数量,系统默认将根据账号类型进行出题。每道题目的操作数在1-5个之间,操作数取值范围为1-100;

3、题目数量的有效输入范围是“10-30”(含10,30,或-1退出登录),程序根据输入的题目数量生成符合小学、初中和高中难度的题目的卷子(具体要求见附表)。同一个老师的卷子中的题目不能与以前的已生成的卷子中的题目重复(以指定文件夹下存在的文件为准,见5);

4、在登录状态下,如果用户需要切换类型选项,命令行输入“切换为XX”,XX为小学、初中和高中三个选项中的一个,输入项不符合要求时,程序控制台提示“请输入小学、初中和高中三个选项中的一个”;输入正确后,显示“”系统提示“准备生成XX数学题目,请输入生成题目数量”,用户输入所需出的卷子的题目数量,系统新设置的类型进行出题;

5、生成的题目将以“年-月-日-时-分-秒.txt”的形式保存,每个账号一个文件夹。每道题目有题号,每题之间空一行;

6、个人项目9月11日晚上10点以前提交至创新课程管理系统。提交方式:工程文件打包,压缩包名为“几班+姓名.rar”。迟交2天及以内者扣分,每天扣20%。迟交2天及以上者0分。

img

代码分析

1. 用户类-User

根据题目需求,用户类应该主要拥有三个属性:用户名、密码和用户所属的学校种类。设置好User类的构造函数和getter、setter便于后续对其进行新增和修改操作。

定义makePaper方法用于生成对应类型的试卷。

注意:在判断字符相同时使用equals()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class User {
private String username;
private String password;
private String type;

public User(String username, String password, String type) {
this.username = username;
this.password = password;
this.type = type;
}

public String getUsername() {
return username;
}

public String getPassword() {
return password;
}

public String getType() {
return type;
}

public void setType(String type) {
this.type = type;
}

//开始制作试卷
public void makePaper(User user, int num) {
PaperMaker paperMaker = new PaperMaker();
if (this.type.equals("小学")) {
paperMaker.makePrimary(user, num);
} else if (this.type.equals("初中")) {
paperMaker.makeJunior(user, num);
} else if (this.type.equals("高中")) {
paperMaker.makeSenior(user, num);
}
}
}
2. 试卷生成类-PaperMaker

因为题目分为三个难度,所以创建三个链表分别存储对应难度的符号,在构造函数中进行数据的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//小学专用符号
private LinkedList<String> primary = new LinkedList<>();
//初中专用符号
private LinkedList<String> junior = new LinkedList<>();
//高中专用符号
private LinkedList<String> senior = new LinkedList<>();

public PaperMaker() {
primary.add("+");
primary.add("-");
primary.add("×");
primary.add("÷");
junior.add("²");
junior.add("√");
senior.add("");
senior.add("sin");
senior.add("cos");
senior.add("tan");
}

makePrimary是生成小学题目的方法:传入对象user和所需生成的题目数量,即可进行生成。

实现操作数随机,设置cnt计数器,固定最大数量为5;设置breakOrNot参数,该参数在每次添加数据后随机更新决定是否退出,来实现随机个操作数的功能。

实现随机括号功能,设置bracket参数记录未闭合的括号数量,设置bracketOrNot参数每轮取随机数来决定是否加入左括号,设置rightBracket参数每轮取随机数来决定是否加入右括号,算式生成结束后,根据bracket的值在式子末尾补全右括号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
//生成小学难度题目 生成数量是number

public void makePrimary(User user, int number) {
//生成多少道题目就执行多少次循环
for (int i = 0; i < number; i++) {
Random random = new Random();
StringBuffer stringBuffer = new StringBuffer();
//判断括号配对
int bracket = 0;
//判断退出
int breakOrNot = 0;
//操作数最多个数
int cnt = 5;
//保证操作数不超过5个
while (cnt-- > 0) {
//判断是否本轮退出
breakOrNot = random.nextInt(2);
//随机加入 (
int bracketOrNot = random.nextInt(3);
if (bracketOrNot == 1 && breakOrNot != 1 && cnt != 0) {
bracket++;
stringBuffer.append("(");
}

//随机生成1-100之间的操作数
int ran = random.nextInt(100) + 1;
stringBuffer.append(ran);


//判断 加入 )
int rightBracket = random.nextInt(2);
if (bracketOrNot != 1 && bracket > 0 && rightBracket == 1) {
stringBuffer.append(")");
bracket--;
}
//随机结束算式
if (cnt != 4) {
if (breakOrNot == 1 || cnt == 0) {
break;
}
}
//随机生成符号
int symbolSelect = random.nextInt(4);
String symbol = primary.get(symbolSelect);
stringBuffer.append(symbol);
}
//补全 )
for (int j = 0; j < bracket; j++) {
stringBuffer.append(")");
}
//输出 =
stringBuffer.append("=");
//生成试卷
IOController ioController = new IOController();
//生成试卷文件
File newFile = ioController.makeFile(user);
//存储题目
String title = stringBuffer.toString();
//题目查重
if (ioController.check(newFile, title) == true) {
i--;
continue;
}
//写入试卷
ioController.writeIn(newFile, i + 1, title);
}
}

makeJunior是生成初中题目的方法,逻辑基本与上文类似,新增随机加入根号和平方的方法。由于要确保每个算式至少有一个根号或平方,故在while循环外定义squareOrNot = 1来确保每个式子都有平方。

1
2
3
4
5
6
7
8
9
10
11
12
13
int squareOrNot = 1;

//随机加根号
int evolutionOrNot = random.nextInt(2);
if (evolutionOrNot == 1) {
stringBuffer.append(junior.get(1));
}

//随机加平方
if (squareOrNot == 1) {
stringBuffer.append(junior.get(0));
}
squareOrNot = random.nextInt(2);

makeSenior是生成高中题目的方法,逻辑基本与上文类似,新增随即加入三角函数的方法,不再做详细解释。

1
2
3
4
5
int tFunction = random.nextInt(3) + 1;                

//随机生成三角函数
stringBuffer.append(senior.get(tFunction));
tFunction = random.nextInt(4);
3.文件输出类-IOController

文件输出主要分三部分:

  1. 找到或生成对应的文件夹和txt文件

首先确定文件夹的路径,若之前该文件夹不存在则新创建这个文件夹。再获取当前时间,生成txt 文件的题目,创建这个txt文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//生成专用文件夹 和 txt文件
public File makeFile(User user) {
String mkPath = "D:\\JavaProject\\AutomationPaper\\paper\\" + user.getUsername();
File file = new File(mkPath);
Date date = new Date();
//时间处理 用于txt文件的名称
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss");
String title = simpleDateFormat.format(date) + ".txt";
if (!file.exists()) {
//单例模式 如果没有对应文件夹则生成
file.mkdirs();
}
File paper = new File(mkPath, title);
try {
paper.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
return paper;
}
  1. 文件的写入

没什么说的,直接看代码吧。

写完后记得调用flush()函数刷新文件,否则可能出现写入失败的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//写入文件
public void writeIn(File file, Integer n, String title) {
try {
//传入文件 确定类型为续写
FileWriter fileWriter = new FileWriter(file, true);
//序号
String num = n.toString() + ".";
fileWriter.append(num + " " + title);
//空一行
fileWriter.append("\r\n");
fileWriter.append("\r\n");
//刷新文件,关闭writer
fileWriter.flush();
fileWriter.close();
} catch (IOException e) {
e.printStackTrace();
}

}
  1. 算式查重

遍历该txt文件的上一级目录,来获取这个用户所获取的所有试题,再对每一套试题进行遍历对比,若重复则返回true,否则返回false。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public boolean check(File file, String title) {
//定义变量 是否重复
boolean repetition = false;
//获取txt文件上一级目录的路径
String parentPath = file.getParent();
File parent = new File(parentPath);
//遍历上一级目录,获得其下的所有文件
File[] files = parent.listFiles();
//一个一个文件进行查重对比
for (int i = 0; i < files.length; i++) {
try {
FileReader fileReader = new FileReader(files[i]);
BufferedReader bufferedReader = new BufferedReader(fileReader);
while (true) {
//读取一行数据
String usedTitle = bufferedReader.readLine();
//如果是空行就跳过
if (usedTitle == null) {
break;
}
//以" "为分隔符," "后面的就是算式
String[] uT = usedTitle.split(" ");
if (uT.length > 1) {
if (uT[1].equals(title)) {
System.out.println("重复");
//如果重复改变变量的值
repetition = true;
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return repetition;
}
4. 登录类-Login

登陆类需存储当前已有用户的信息,负责用户的登录判断、操作判断和改变用户的类型。

信息初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
HashMap<String, User> userMap = new HashMap<>();

public Login() {
userMap.put("张三1", new User("张三1", "123", "小学"));
userMap.put("张三2", new User("张三2", "123", "小学"));
userMap.put("张三3", new User("张三3", "123", "小学"));
userMap.put("李四1", new User("李四1", "123", "初中"));
userMap.put("李四2", new User("李四2", "123", "初中"));
userMap.put("李四3", new User("李四3", "123", "初中"));
userMap.put("王五1", new User("王五1", "123", "高中"));
userMap.put("王五2", new User("王五2", "123", "高中"));
userMap.put("王五3", new User("王五3", "123", "高中"));
}

用户登录与操作判断

根据表内信息,由用户名获取密码来与输入的密码进行比较来判断是登陆成功。登陆成功后根据输入的数据长度来判断用户想进行切换操作、生成题目操作还是退出操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//用户登录
public void userLogin(String username, String password) {
User user = userMap.get(username);
System.out.println("当前选择为" + user.getType() + "出题");
//判断密码是否正确
if (user.getPassword().equals(password)) {
while (true) {
System.out.println("准备生成" + user.getType()
+ "数学题目,请输入生成题目数量(输入-1将退出当前用户,重新登录):");
String inStr = new Scanner(System.in).next();
//当输入的是 切换为XX 时
if (inStr.length() > 4) {
String sub = inStr.substring(0, 3);
if (sub.equals("切换为")) {
setUserType(username, inStr.substring(3, 5));
}
} else {//输入的是数字时
int num = Integer.parseInt(inStr);
if (num == -1) {
break;
} else if (num >= 10 && num <= 30) {
user.makePaper(user, num);
} else {
System.out.println("请输入10-30之间的自然数 或 输入-1退出");
}
}
}
} else {
System.out.println("请输入正确的用户名、密码");
}
}

改变用户类型

没什么好说的,传入用户名和要变成的类型即可。

1
2
3
4
5
6
7
8
9
10
11
12
//用于改变用户的类型
public void setUserType(String username, String type) {
if (type.equals("小学")) {
userMap.get(username).setType(type);
} else if (type.equals("初中")) {
userMap.get(username).setType(type);
} else if (type.equals("高中")) {
userMap.get(username).setType(type);
} else {
System.out.println("请输入小学、初中和高中三个选项中的一个");
}
}
5. 主类-Main

通过死循环来实现退出登录后不会结束程序,而是再次让用户输入用户名密码。用户名和密码通过空格分隔,故对密码进行去除空格(trim)处理。

1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Login login = new Login();
while (true) {
//识别用户名和密码
System.out.println("请输入用户名、密码");
login.userLogin(in.next(), in.nextLine().trim());
}
}
}

说实话程序逻辑总体还是有点乱的,后续想在进行优化调整。

团队项目复盘

项目名称:健康码管理系统

小组人数:6人

本人负责:web后端开发

项目成果:小程序用户端、web管理端、项目文档(项目前景与范围文档、原型界面、用例文档、需求功能说明书、数据库设计文档、uml设计文档、测试报告、用户手册)

接下来按照项目过程中的时间顺序来进行复盘~

原型界面开发

原型界面开始是一种帮助我们确认需求、提高项目可用性的重要方式。我们在设计原型界面的过程中,使用了 “墨刀” 网页版,选择了一款素材库中和我们内容风格相似的原型作为基础,并在其上进行改造(最后我们也确实按照原型实现的)。在验收的过程中,我注意到有很多组并不是设计原型(指生成前端的图片),而是直接使用了前端模板,直接改前端代码并截图来作为原型界面。直接使用前端模板的方式在后续的工作中省去了大量从0到1的编码过程,在实现过程中我注意到其他组前端的工期明显要比我们短很多,界面也相对来说更加美观。所有下次再让我们从0到1自己实现前端可以说是不可能了~

老师在验收过程中不仅注意了功能的完备合理,还着重关注了细节的问题,比如:确认按钮应在界面最右边。咱也没学过设计,当时觉得老师就是在找茬(bushi),现在反过头来看确实有一定的道理,现实生活中的网站相似的按钮一般也都在右边(好像)。

2_普通用户管理界面

web后端原型图示例

主页面

小程序端示例

用例文档

由于我们的项目相对一般的项目工作量会偏大,所以用例比一般的组多了很多(大概60+)。用例文档感觉像是我们所期待的系统最终拥有的功能,要求功能完备且逻辑清晰。

这里不得不自我批评一下,由于后续的变动导致我们最终实现的项目与用例文档包含的功能并不完全一致,很多时候变成了想起什么做什么(前端提要求后端给接口),导致最后功能并不是完全完备的,且由于实际功能与用例文档的不同,提交之前又重新按照成品改了一版用例文档…

数据库设计

数据库设计使用了power designer来生成ER图,并转化为SQL语句。设计过程中我们展开了激烈的讨论,解决了一些问题,但也有一些没考虑到或者没有好办法的问题被迫保留。

解决了

  • 两表的主键互为外键:

在设计时,为了体现两表之间的关联性和可维护性,我们设计了一些外键。但是在实际使用中就遇到了两表互为外键,例如区县防疫办的id作为超级管理者的外键,而超级管理者的id也作为区县防疫办的外键。这就导致了在第一次新增数据时,由于两表都为空,无法使用外键,从而都无法顺利的插入数据。发现这个问题后我们又重新检查了设计好的数据库,最终觉得按照管理关系,去掉区县防疫办中关于超级管理者的外键,从而顺利的插入数据。

据分析,两表主键互为外键应该是一个设计问题,类似与死锁,并不合理,在首次插入时会造成极大问题。如果两表主键必须互为外键,可以在按照以上方法插入数据后,再重新设置外键;如果两表中已经存在其他数据对象,先插入的对象还可以先引用表的其他对象作为外键,待二者都完成插入,再修改外键。

  • 物理删除和逻辑删除:

由于一些外键的存在,我们在某些情况下不方便将数据对象直接从表中物理删除,这是就需要在表中添加新字段deleted来标识这个对象是否被删除,其中0表示未删除,1表示删除。当我们使用mybatis-plus框架时,对数据执行删除操作,就会自动将deleted字段赋为1,并无法查询。如果使用自己设置的查询语句,则应注意在需要时手动添加判断deleted是否为1的条件。

  • 乐观锁的应用:

为在保证数据库读写效率的同时,防止在并发操作时出现线程安全问题,我们在数据库中添加了version字段。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。需要注意的是,我们需要添加项目的配置文件来启动乐观锁机制。

img

未解决

  • 固定核酸检测点的时间问题

由于固定监测点的时间,所有需要按照星期来进行设置。但是一星期中每天的具体时间又不同,最终只好采取了星期几+上下文的方式来存时间(7*2列)。导致无论是在使用过程中还是在编码过程中都非常的麻烦,可用性不高,但又想不到什么好的解决办法…

未发现

  • 很多表和字段并未使用或未设计

在设计时,我们根据需求和自己的判断完成了一个数据库,但是在编码时还是常常遇到问题,归其根本还是设计数据库时考虑不周。这或许就需要我们在一个个项目中慢慢的积累经验,了解数据库设计的原则和可用性,提高自己的设计水平。

α版本开发

从这里才开始算是正式写代码~

致谢:感谢黑马讲了代码生成器,要不创建项目不知道要花多久呢

除去第一天晚上熟悉框架和mybatis-plus的时间,大概三天左右就把α版本的后端接口基本完成了。当然只是我以为的完成…由于没有注意到代码质量和异常处理等问题,最后在验收前可没少填坑…由于忽视了很多需要在后端完成的异常处理,导致在项目测试时经常出现卡死或无反应等情况,麻烦前端的同学一点一点加js了…

项目搭建后我们还导入或编写了一些工具类,比如Result返回类型、JsonUtils工具类、Authentication身份信息验证类、JacksonObjectMapper对象映射器等等,在后续的开发中这些类多多少少发挥着作用。

image-20230308220512080

后端测试使用了Apifox,一开始我们都以为接口通过了前后端交互就没啥问题,但是在开始阶段还遇到了一些配置问题,最经典的就是Vue的跨域问题了,虽然解决后认识到是一个比较固定的配置问题,但是在开发过程中还是为了前后端交互发了不少愁…

image-20230308220545899

β版本开发

首先根据α版本的开发情况,对用例文档进行修改,明确接下来的开发方向。我们除了删去了一些和项目关系不太大的版块,还根据实际的上线情况和资源情况进行了调整(比如由于健康码的特殊性,没有办法上线小程序…只能说这项目太特殊了)

后端的代码开发和前面也有一些改变:

  • 由于联合查询的出现,新建和使用DTO类

根据数据库范式的相关知识,我们不能直接把要用到所有信息存到一张表里,常用联合查询的情况,这时候就需要我们新建DTO类来继承初始类,并添加新的信息以供使用。

  • redis的使用

随着测试的数据量不断加大,一些查询甚至是需要5s左右才能返回结果,体验非常不好,所有引入了redis作为缓存来提高查询效率。同时在redis也用到了验证码部分,根据redis缓存时间的特性,来实现了固定时间内的短信验证码验证功能。

测试

在此之前我们也没有接触过测试的相关培训和知识,也不会使用自动化的测试工具,就凭借自己的感觉来测试功能(例如 随便乱输)。你别说,你还真别说,还真遇到了大问题…由于没有进行前端没有考虑js校验,后端也没用进行异常处理,这下随便输什么信息都能存到数据库或者报异常…现在想起来真是追悔莫及…最后项目也是在这个问题上扣了不少分(按bug个数扣分,我们的功能又多,重复扣了好多次…感谢xxr老师)。还有一些由于外键连接导致的删除数据后查询异常的问题(就该把他整成禁止删除的),反正搞得乱糟糟的,肯定要重新构思实现了…

整个项目到这里基本上就结束了,最后应该还被老师拿去放到新书里当案例了,课程评优三等奖,但是回过头来看项目还有很多可以调优和改进的地方(安全性、响应速度、用户体验等),暂时就给后续的项目当个反面典型得了

整个合作过程还算是比较轻松愉快的,当时分组就挑选了平时一起学习的伙伴,大家的水平和技术栈也相对来说比较相似,大多也肯干能干(就是对自己很自信才选了个大项目,但是这评分机制我是真的无力吐槽了…),在某部分比较忙的时候也少不了大家互相客串一下来完成任务(被迫全栈),如果有机会再一起写项目的话(那肯定有),希望我们能做出真正符合使用需求的优秀项目!

数据库设计和使用中遇到的问题

  1. 两表的主键互为外键:

在设计时,为了体现两表之间的关联性和可维护性,我们设计了一些外键。但是在实际使用中就遇到了两表互为外键,例如区县防疫办的id作为超级管理者的外键,而超级管理者的id也作为区县防疫办的外键。这就导致了在第一次新增数据时,由于两表都为空,无法使用外键,从而都无法顺利的插入数据。发现这个问题后我们又重新检查了设计好的数据库,最终觉得按照管理关系,去掉区县防疫办中关于超级管理者的外键,从而顺利的插入数据。

据分析,两表主键互为外键应该是一个设计问题,类似与死锁,并不合理,在首次插入时会造成极大问题。如果两表主键必须互为外键,可以在按照以上方法插入数据后,再重新设置外键;如果两表中已经存在其他数据对象,先插入的对象还可以先引用表的其他对象作为外键,待二者都完成插入,再修改外键。

  1. 物理删除和逻辑删除:

由于一些外键的存在,我们在某些情况下不方便将数据对象直接从表中物理删除,这是就需要在表中添加新字段deleted来标识这个对象是否被删除,其中0表示未删除,1表示删除。当我们使用mybatis-plus框架时,对数据执行删除操作,就会自动将deleted字段赋为1,并无法查询。如果使用自己设置的查询语句,则应注意在需要时手动添加判断deleted是否为1的条件。

  1. 乐观锁的应用:

为在保证数据库读写效率的同时,防止在并发操作时出现线程安全问题,我们在数据库中添加了version字段。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。需要注意的是,我们需要添加项目的配置文件来启动乐观锁机制。

img

流式计算中的Window机制

Window 是什么

在流计算中,数据流是持续不断的,因此不可能等全部数据都到了再开始处理。Window的作用就是将无限的Streaming拆分成大小有限的Batch,我们可以对每个Window里的数据进行应用和计算。

典型Window的基本功能

本文介绍 滚动窗口 、滑动窗口 和 会话窗口

Tumble Window(滚动窗口)

img

滚动窗口特点:

  • 窗口不重叠,每个数据只能属于一个窗口
  • 窗口长度固定
  • 当时间大于等于Window end时,一次性触发对应的Window的输出

Sliding Window(滑动窗口)

img

滚动窗口特点:

  • 滑动窗口以一个步长不断向前滑动,窗口的长度固定。

  • 窗口可能重叠

    • 当窗口长度大于滑动窗口的步长,数据就可能属于多个窗口
    • 当窗口长度小于滑动窗口的步长,数据就可能不属于任何窗口
  • 当时间大于等于Window end时,一次性触发对应的Window的输出

Session Window(会话窗口)

img

  • session gap 指 各个session之间的间隔,一般会设置一个会话的最大gap,比如1分钟,当session gap大于1分钟,数据就会被分到不同的session中。
  • 窗口长度不等
  • 当时间大于等于Window end时,一次性触发对应的Window的输出

迟到数据的处理

  • 迟到的定义:watermark驱动某个窗口触发输出之后,这个窗口如果后面又来了数据,那这种情况就属于是迟到的数据了。

  • 处理办法:

    1. 直接丢弃(默认)
    2. 设置一个允许迟到时间,这种情况下,窗口正常计算时间结束后不会马上清理数据,而是多保留一个“迟到时间”,如果这段时间内有数据到达,则继续计算
    3. 将迟到数据转变成一个单独的流,再由用户自己决定如何处理(侧输出流)

增量计算 和 全量计算

  • 增量计算:每条数据到来后,直接参与计算,但是暂不输出结果
  • 全量计算:每条数据到来后,先放到一个buffer中,这个buffer会存储到状态里,直到窗口触发输出的时候,才把所有数据拿出来统一进行计算

EMIT触发

  • 背景:正常的窗口都是在窗口结束时才会进行输出,比如窗口时间为一天,只有在一天结束时才会输出结果,这时便失去了实时计算的意义。
  • 作用:EMIT触发是一种可以提前把窗口内容输出的机制,比如窗口时间为一天的窗口,设置其5s输出一次,使下游更快的获得到窗口计算的结果。

结对编程代码互评

本次代码评价基于 lyc 同学的中小学数学卷子自动生成程序(个人项目)进行测试、分析和评价。我和lyc同学一起参加过一些比赛,对彼此的代码风格和处理问题的逻辑都比较熟悉,希望这次能从他的代码中得到新的收获。

代码功能测试

  1. 基础功能分析测试

按照题目给出的用户和常规操作进行测试。

img

对应的卷子生成在程序所设置的绝对路径下,命名符合题目要求。

img

生成试卷进一步增加了难度标题,便于用户分辨,优化了用户体验。生成题目的格式符合规范,各个运算符号添加位置合理,符合题目要求。

img

  1. 随机输入测试

这部分测试不按照题目给出的格式进行,而是随机输入不符合要求的字符测试程序。可以看到,程序在遇到无法识别的字符串时会提示输入错误,并自动返回输入字符串前的状态,由此保障了程序的安全性和高可用性。

img

综上,可以看出 lyc 同学代码实现的功能和效果还是非常好的。

代码分析

这份代码有四个类:

img

其中Paper和User是实体类,分别定义了试卷和用户的属性和方法,PaperGenerationSystem是试卷产生系统的工具类,Main是程序的入口。

下面具体分析每个类的实现和作用

User

User类有三个属性:分别是类型,用户名和密码

属性名 类型 含义
type String 用户此时的生成试卷的类型
username String 用户名
password String 密码

构造方法如下

1
2
3
4
5
public User(String type, String username, String password) {
this.type = type;
this.username = username;
this.password = password;
}

其他方法:

方法名 返回值 作用
getType String 获取用户类型
setType void 设置用户类型
getUsername String 获取用户名
setUsername void 设置用户名
getPassword String 获取用户密码
setPassword void 设置用户密码

总体来说User类设计逻辑清晰,方法得体。

Paper

Paper类有三个属性:分别是三种难度的运算符号

属性名 类型 含义
priSign String[] 小学试卷操作符号
midSign String[] 初中试卷操作符号
highSign String[] 高中试卷操作符号
1
2
3
4
5
private final String[] priSign = {"+", "-", "*", "/"}; // 小学试卷操作符号

private final String[] midSign = {"²", "√"}; // 初中试卷操作符号

private final String[] highSign = {"sin", "cos", "tan"}; // 高中试卷操作符号

Paper类只有一个方法,这也是这份代码做的不太好的地方:一个方法过长,即使注释写的比较清楚,也显得繁杂,可读性较低。

方法名 返回值 作用
makeQuestion String 生成数学题的方法

生成算式的逻辑是按照算式元素从左到右的顺序,除了必须的操作数和加减乘除等于符号,根据试卷类型和随机数来决定是否插入每个符号元素。这种方式生成逻辑清晰,便于理解,但是生成过程繁杂,算法性能并不是很好。

PaperGenerationSystem

PaperGenerationSystem类仅有一个属性,是用户信息的存储链表

属性名 类型 含义
userMessage List 存储用户信息的链表

PaperGenerationSystem的方法如下:

方法名 *返回值 作用
userInit void 初始化用户信息的链表信息
generate void 程序的主系统
PaperGeneration void 试卷生成写入函数
check boolean 查重函数
isNumeric boolean 判断是否为数字
  • userInit

使用链表存储小规模的用户信息,避免了数据库的使用,简化了程序。但是在判断登录时,需要遍历链表来比对用户名和密码,效率较低,可以换成hashmap提高查找效率,同时避免用户重复,进而提升程序的工作效率。

1
2
3
4
5
6
7
8
9
10
11
public void userInit() {
userMessage.add(new User("小学", "张三1", "123"));
userMessage.add(new User("小学", "张三2", "123"));
userMessage.add(new User("小学", "张三3", "123"));
userMessage.add(new User("初中", "李四1", "123"));
userMessage.add(new User("初中", "李四2", "123"));
userMessage.add(new User("初中", "李四3", "123"));
userMessage.add(new User("高中", "王五1", "123"));
userMessage.add(new User("高中", "王五2", "123"));
userMessage.add(new User("高中", "王五3", "123"));
}
  • generate

是程序的主系统,实现了 用户交互部分 的功能,由于代码过长,只展示部分代码。这个方法同样有代码过长的问题,可以进一步封装来提高代码的可读性。

    • 用户登录
1
2
3
4
5
6
7
8
9
10
11
System.out.println("-----欢迎来到中小学数学卷子自动生成系统!-----");
System.out.println("## 请输入用户名和密码(两者之间用空格隔开)");

while (true) {
Scanner sc = new Scanner(System.in);
String str = sc.nextLine();

if (!str.contains(" ")) {
System.out.println("## 输入的账号密码格式不对,请重新输入");
continue;
}
    • 难度切换部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
System.out.println(
"## 当前选择为"
+ userMessage.get(index).getType()
+ "出题,请输入生成题目数量(题目数量范围为10~30,输入-1将退出当前用户,重新登录),如需切换出题类型,请输入切换为xx,xx为小学、初中和高中三个选项中的一个");
String command = sc.nextLine();
if (command.equals("")) {
System.out.println("请输入内容!");
continue;
}
if (command.equals("切换为小学")) {
userMessage.get(index).setType("小学");
continue;
} else if (command.equals("切换为初中")) {
userMessage.get(index).setType("初中");
continue;
} else if (command.equals("切换为高中")) {
userMessage.get(index).setType("高中");
continue;
}
    • 题目生成部分:根据输入是否有数字和数字的范围,来判断是否要生成题目
1
2
3
4
5
6
7
8
9
10
11
if (isNumeric(command)) {
if (Integer.valueOf(command) <= 30 && Integer.valueOf(command) >= 10) {
PaperGeneration(
Integer.valueOf(command),
userMessage.get(index).getType(),
userMessage.get(index).getUsername());
System.out.println("## 试卷生成成功!请在对应包下查看试卷!");
} else {
System.out.println("## 不满足题目数量,请重新输入");
}
}
  • PaperGeneration

此方法是 试卷生成写入函数,用于

    1. 新建用户对应的文件夹和txt文件
    2. 将生成的题目经过查重,加序号处理后写入文件

在程序中,文件使用的是绝对路径,因此会在未经过用户同意的情况下,在用户的电脑的对应路径下产生相应的文件夹,可能会造成用户找不到文件的问题,降低用户的使用体验。

由于代码过长,只展示部分代码。这个方法同样有代码过长的问题,可以进一步封装来提高代码的可读性。

    • 获取系统时间
1
2
3
Date time = new Date();
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss");
String title = df.format(time);
    • 生成文件,并将查重后的试题写入文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
    String dirPath = "D:\\IDEA code\\PaperSystem\\system\\src\\com\\papers\\" + username;
File file = new File(dirPath);

try {
if (!file.exists()) file.mkdirs();
String paperDir = dirPath + "\\" + title + ".txt";
// 写入文件内容,即试题
File newPaper = new File(paperDir);
writer = new FileWriter(newPaper);
// 标明是哪种卷子
writer.write(type + "期末考试试卷\r\n\r\n");

int quesIndex = 1; // 题目序号

// 根据输入的数量开始出题并写入文件
while (sum > 0) {
String content = paper.makeQuestion(type);
// 试题查重
if (check(newPaper, content)) {
if (sum == 1) {
writer.write(quesIndex + ". " + content);
break;
}
writer.write(quesIndex + ". " + content + "\r\n" + "\r\n");
sum--;
quesIndex++;
}
if (!check(newPaper, content)) {
System.out.println(content);
}
}
// 刷新并关闭FileWriter
writer.flush();
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
  • check

此方法负责对题目进行查重,主要思路是遍历用户文件夹下的txt文件,逐个对比题目是否重复。这部分有一个小缺陷

1
2
3
4
// 只有一张卷子不查重
if (files.length == 1) {
return true;
}

我认为在同一张卷子的生成过程中,题目仍需与上面已经生产的题目进行查重处理。

  • isNumeric

此方法的作用是判断输入的字符串是否是数字,如果是数字则可能可以进行题目生成处理。

1
2
3
4
5
6
7
8
public boolean isNumeric(String str) {
Pattern pattern = Pattern.compile("[0-9]*");
Matcher isNum = pattern.matcher(str);
if (!isNum.matches()) {
return false;
}
return true;
}

代码评价

  • 优点:

    1. 这份代码的功能实现十分完备,各个判断和边界考虑的清晰周到。
    2. 类的设计逻辑清晰,易于他人理解。
    3. 程序的文字提示十分完善,便于用户操作。
    4. 属性的安全性较好,在适当的地方采用了private和final关键字进行处理。
  • 缺点:

    1. 方法的封装不够完善,程序整体的可读性偏差。
    2. 存储用户信息用的是链表,查找效率较低,可以采用map或者数据库
    3. 文件生成的路径是绝对路径,单从终端无法得知文件生成在哪里。
    4. 算法相对较为简单,效率较低,期待可以进行优化。

总体上来说,lyc同学编写的程序还是很值得我去学习的,尤其是在用户体验方面的考虑十分周到,向yc大佬低头!

基于ZooKeeper临时顺序节点的分布式锁实现

[TOC]

1. 分布式锁

分布式锁是一种在分布式系统中协调进程或线程之间对共享资源进行访问控制的机制。在分布式系统中,多个进程或线程同时访问同一个资源时,为了保证数据的一致性和正确性,需要进行同步访问,避免多个进程或线程同时修改同一个资源造成数据的不一致。分布式锁就是为了解决这个问题而产生的。

在Java多线程中,我们了解过锁,这种锁主要用来控制一个JVM内的多个线程对本地共享资源的访问

本地锁

但是在分布式系统中,可能会有运行在不同JVM上的线程来访问同一份资源,这时候本地的锁就无法解决问题了,分布式锁因而诞生。

分布式锁

一个最基本的分布式锁需要满足:

  • 互斥 :任意一个时刻,锁只能被一个线程持有;
  • 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。
  • 可重入:一个节点获取了锁之后,还可以再次获取锁。

2. ZooKeeper 在Java中的基本使用

若想在Java中使用ZooKeeper,首先我们还是需要先启动ZooKeeper集群(可以有多个ZooKeeper服务端相连接,一般是奇数个)

接下来我们就可以通过Java来控制ZooKeeper操作了

在操作中有一系列参数,这里暂时不一一解释,咱们用到哪说到哪,感兴趣的小伙伴也可以自己去查一查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//这里是自己导入了ZooKeeper的包
import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;

public class ZKOper {
private static String nodeAdds = "localhost:2181,localhost:2182,localhost:2183";

public static void main(String[] args) throws Exception {
//新建一个zookeeper连接,超时为 3s,以后对集群的操作,都在 zk 上完成
ZooKeeper zk = new ZooKeeper(nodeAdds, 3000, watchedEvent -> {});

//新增一个节点
String seq = zk.create("/hnu", "loginIn".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT);
System.out.println("保存成功,序号:" + seq);
//获取节点
byte[] data = zk.getData("/hnu",null,null);
String v = new String(data);
System.out.println("取得节点内的数据" + v);
//修改节点信息
Stat rs = zk.setData(seq, "loginOut".getBytes(),-1);
System.out.println("修改结果" + rs);
//删除节点
zk.delete(seq, -1);
System.out.println("删除成功" + seq);
zk.close();
}
}

在输出中,我们就可以观察到程序对ZooKeeper进行的一系列curd操作了

3. ZooKeeper实现分布式锁的思路

ZooKeeper实现分布式锁主要依赖它的两种特性:

  • Watcher监听机制

Watcher 监听机制是 ZooKeeper 的客户端与服务端之间的一种通信方式。当客户端与服务端建立连接后,客户端可以注册一个 Watcher 对象,来监听某个节点的变化。当这个节点的状态发生变化时,服务端会向客户端发送一个通知,告诉客户端这个节点的状态已经发生了变化。客户端收到通知后,可以根据需要执行一些操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import org.apache.zookeeper.*;

public class ZKWatch {
// 可用的所有的节点地址,注意格式:
private static String nodeAdds = "localhost:2181,localhost:2182,localhost:2183";
public static void main(String[] args) throws Exception {
Watcher watch=new Watcher(){
// 创建一个监听器对象
public void process(WatchedEvent e) {
System.out.println("WatchedEvent:"+e.getPath()+" "+e.getType());
if(e.getType()==Watcher.Event.EventType.NodeDeleted){
System.out.println(" NodeDeleted");
}
if(e.getType()==Watcher.Event.EventType.NodeCreated){
System.out.println(" NodeCreated");
}
if(e.getType()==Watcher.Event.EventType.NodeDataChanged){
System.out.println(" NodeDataChanged");
}
}
};
//将此 watch 对象加给连结对象,此处监听根节点,及其以下所有子节点变化
// AddWatchMode.PERSISTENT : 只监听指定的 path
// AddWatchMode.PERSISTENT_RECURSIVE :监听 path 下所有节点
// 如设为根目录 / 则所有节点的 crud 操作都会响应
ZooKeeper zk = new ZooKeeper(nodeAdds, 3000, watch);
zk.addWatch("/", watch, AddWatchMode.PERSISTENT_RECURSIVE);
while(true) {
Thread.sleep(1000); //暂不退出
}
// zk.close();
// 关闭连结
}
}

如果启动了以上程序,再启动curd,该程序就会根据监听到的信息打印了

  • 临时顺序节点

ZooKeeper 节点有四种类型:

  1. 持久节点(Persistent Node):创建后一直存在,直到被主动删除。即使创建节点的客户端断开连接,节点依然存在。

  2. 临时节点(Ephemeral Node):只在创建它的客户端与 ZooKeeper 之间的连接保持有效时存在。如果客户端与 ZooKeeper 断开连接,那么临时节点也会被删除。

  3. 持久顺序节点(Persistent Sequential Node):创建后一直存在,直到被主动删除。与持久节点不同的是,持久顺序节点会根据节点的创建顺序为节点分配一个唯一的编号。这个编号是由 ZooKeeper 保证唯一的。

  4. 临时顺序节点(Ephemeral Sequential Node):只在创建它的客户端与 ZooKeeper 之间的连接保持有效时存在。与临时节点不同的是,临时顺序节点也会根据节点的创建顺序为节点分配一个唯一的编号。这个编号是由 ZooKeeper 保证唯一的。如果客户端与 ZooKeeper 断开连接,那么临时顺序节点也会被删除。

临时节点的特征:

  • session连接断开就没了
  • 不能够创建子节点
  • 不能有同名节点
  • 监听节点删除和修改的变化

根据以上两种特性,我们就可以设计出一种分布式锁

  1. 当客户端请求数据时,会在ZooKeeper中建立一个临时顺序节点,这个节点有一个自己的序号
  2. 判断当前序号是否是最小的,如果是则获得分布式锁,可以执行操作
  3. 如果序号不是最小的,监听序号为 当前序号-1 的节点,而后线程进入wait状态,当序号为当前序号-1的节点被删除,则notify,可以开始操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import org.apache.zookeeper.*;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.function.DoubleToIntFunction;

public class ZKShareLock extends Thread{
//抢锁线程(用户进程)内部等待通知器: 在其他用户删除我前面的节点时用
private Object innerLock = new Object();
private ZooKeeper zconn;

//需要提前创建路径
private String basePath="/ShareLocksTest";

private String userPath=basePath+"/User-";
private String cName;

//构造器
public ZKShareLock(ZooKeeper zconn, String cName) {
this.zconn = zconn;
this.cName = cName;
}

private void todoSome() throws Exception{
//耗时操作,模拟用户拿到锁后去做一些事情
Random ran = new Random();
int t = ran.nextInt(3000)+2000;
Thread.sleep(t);
}
//释放锁操作
private void unLock(String myPath) {
try {
System.out.println(cName + "释放锁" + myPath);
//删除当前节点
zconn.delete(myPath, -1);
zconn.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public void run(){
try {
//1.建一个自己的临时、顺序节点(CreateMode.EPHEMERAL_SEQUENTIAL)
String mySeq = zconn.create(userPath, cName.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
System.out.println(cName + " 1-创建顺序临时节点成功:" + mySeq);
//2.取地basePath下所有子节点,看是否自己最小
//取地共享锁路径下所有节点输出
List<String> paths=zconn.getChildren(basePath, null);
//为了排序,只取序号的最后10位
List<String> seqs = new ArrayList<>();
for(String s:paths) {
s = s.substring(s.length()-10,s.length());
seqs.add(s);
}
//排序
Collections.sort(seqs);
//3.取得自己节点所处的排序位置
String myss = mySeq.substring(mySeq.length()-10,mySeq.length());
int index = seqs.indexOf(myss);
System.out.println(cName + " 2-自己节点所处的排序位置:" + index);
//如果自己的节点位置是0,则自己就是第一个获得锁的用户
if (index==0){
System.out.println(cName + " 3-1 自己节点最小,获得锁");
todoSome();
unLock(userPath+myss);
System.out.println(cName + " 3-2 执行任务完毕,释放锁");
zconn.close();
return;
}
else {
//获取当前节点前一节点的序号
String second = seqs.get(index - 1);
String secondPath = userPath + second;
System.out.println(cName + " 3- 排位后靠,要等待在路径:" + secondPath);
//监听前一节点
addWatcher(secondPath);
try {
System.out.println(cName + " 3-3 在innerLock上锁定,等待通知事件:");
//先锁定线程
synchronized (innerLock) {
innerLock.wait();
}
} catch (Exception e) {
e.printStackTrace();
}
//当前一节点被删除,继续操作
System.out.println(cName + " 3-4 通知事件解除,获得锁");
todoSome();
unLock(userPath + myss);
zconn.close();
return;
}
}catch (Exception e){
e.printStackTrace();
}
}

private void addWatcher(String secondPath) throws Exception{
//创建一个监听器对象
Watcher watcher = new Watcher() {
@Override
public void process(WatchedEvent event) {
if (event.getType() == Event.EventType.NodeDeleted) {
System.out.println("NodeDeleted");
//当监听的节点被删除,唤醒监听它的线程
try {
synchronized (innerLock) {
innerLock.notify();
}
System.out.println(cName+"3-2 监听到节点:"+secondPath+"被删除,发出通知");
} catch (Exception e) {
e.printStackTrace();
}
}
}
};
//设置监听路径
zconn.addWatch(secondPath, watcher,AddWatchMode.PERSISTENT);
System.out.println(cName+" 3-1 在此路径上加上监听器:"+secondPath);
}

public static void main(String[] args) {
String nodeAdds = "localhost:2181,localhost:2182,localhost:2183";
//启动10个线程模拟
for (int i = 0;i<10;i++){
try {
ZooKeeper zconn = new ZooKeeper(nodeAdds, 3000, watchedEvent -> {});
ZKShareLock m1 = new ZKShareLock(zconn,"用户"+i);
m1.start();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

代码中仍可能存在的问题:

如果由于网络原因,某个zookeeper客户端提前断开了连接,会导致后续的节点检测到它已被删除,而可能提前解锁进入操作,不过在本地测试环境不太容易出现这种问题。若想在本代码中解决问题,在每次唤醒后再次判断自己是否为当前最小节点也可(有点麻烦)。

基于临时顺序节点实现分布式锁的大体是这样的,可能在代码实现上会还有一些差异。

4. 两种基于ZooKeeper分布式锁的优缺点

ZooKeeper提供了两种实现分布式锁的方式:基于临时顺序节点和基于临时节点的锁。我们前面所说的都是基于临时顺序节点的分布式锁,还有一种是基于临时节点的分布式锁。

  1. 基于临时顺序节点的锁

基于临时顺序节点的锁实现原理是,在ZooKeeper中创建一个临时顺序节点,每个客户端都可以创建这样的节点。当客户端需要获取锁时,它会在指定的ZooKeeper目录下创建一个临时顺序节点,并且获取当前目录下所有的节点列表。然后,它将节点列表排序,并检查自己创建的节点是否是当前节点列表中的第一个节点。如果是,那么它获得了锁,如果不是,那么它会监视比自己小的节点的删除事件,直到这些节点被删除,然后再次尝试获取锁。

优点:

  • 能够避免羊群效应(herd effect),即多个客户端同时争抢锁的情况,因为每个客户端都会按顺序获取锁。
  • 可以保证客户端获取锁的顺序和创建节点的顺序一致,因此可以从一定程度上避免死锁的情况。

缺点:

  • 需要频繁创建和删除临时节点,当锁的获取和释放频繁发生时,会对ZooKeeper的性能产生一定影响。
  1. 基于临时节点的锁

基于临时节点的锁实现原理是,在ZooKeeper中创建一个临时节点,这个节点就是锁的代表。当客户端需要获取锁时,它会在指定的ZooKeeper目录下创建一个临时节点。如果它成功创建了这个节点,那么它就获得了锁,否则它需要等待。当客户端释放锁时,它会删除自己创建的临时节点。

优点:

  • 由于每个客户端只创建一个节点,因此可以减少ZooKeeper的负载,对性能的影响比较小。
  • 可以避免由于客户端崩溃或网络故障导致锁无法释放的问题。

缺点:

  • 存在羊群效应,当多个客户端同时争抢锁的情况时,可能会导致大量的客户端同时请求ZooKeeper,影响性能。
  • 无法保证客户端获取锁的顺序,因此可能会出现死锁的情况。

如果应用场景中锁的获取和释放频率较低,且需要保证锁的可靠性,那么基于临时节点的锁更为合适;如果应用场景中锁的获取和释放频率较高,且需要保证客户端获取锁的顺序和避免死锁的情况,那么基于临时顺序节点的锁更为合适。

本文参考:

​ JavaGuide、CSDN、ChatGPT

Flink引擎

Flink概述

  • 什么是大数据

指无法在一定时间内用常规软件工具对其进行获取、存储、管理和处理的数据集合。

批计算和流计算的区别

img

  • 为什么需要流计算

大数据的实时性带来价值更大,eg:实时推荐、数据监控

  • Flink的特点

    • Exactly-Once
    • 高吞吐低延迟,实时快速
    • 高容错
    • 流批一体
    • Streaming/Batch SQL

Flink整体框架

img

  • Flink 分层框架

    • SDK层:支持SQL/Table、DataStream(java)、Python

img

    • 执行引擎层:提供统一DAG(有向无环图)来描述数据处理的Pipeline;调度层再把DAG转化成分布式环境下的Task;Task之间通过Shuffle传输数据

img

    • 状态存储层:存储算子状态信息

img

    • 资源调度层:Flink可以支持部署在多种环境

img

  • Flink 整体框架

一个Flink集群主要包含两个核心组件:JM(JobManager)、TM(TaskManager)

  • JM 负责整个任务的协调工作,包括:调度 task、触发协调 Task 做 Checkpoint、协调容错恢复等,核心有下面三个组件:

    • Dispatcher: 接收作业,拉起 JobManager 来执行作业,并在 Job Master 挂掉之后恢复作业;
    • Job Master: 管理一个 job 的整个生命周期,会向 Resource Manager 申请 slot,并将 task 调度到对应 TM 上;
    • Resource Manager:负责 slot 资源的管理和调度,Task manager 拉起之后会向 RM 注册;
  • TM 负责执行一个 DataFlow Graph 的各个 task 以及 data streams 的 buffer 和数据交换。

img

  • 流计算和批计算独立情况下:

    • 人力成本高:批、流两套系统的逻辑相似,但是需要开发两遍;
    • 数据链路冗余:本身计算内容是一致的,用两套逻辑相似的链路来处理,产生一定的资源浪费;
    • 数据口径不一致:两套链路或多或少会产生误差,会给业务方带来困扰。
  • 为什么可以实现流批一体:

    • 站在 Flink 的角度,Everything is Streams,无边界数据集是一种数据流,可以按照时间分成一个个有界数据集;
    • 而批计算可以看作是流计算的特例,其有界数据集也是一种特殊数据流。
    • 因此,无论是无边界数据集还是有界数据集,Flink都可以支持,并且从API到底层处理都是统一的,实现了流批一体。
  • 流批一体的 Scheduler 层

    • Scheduler 主要负责将作业的 DAG 转化为在分布式环境中可以执行的 Task;
    • EAGER模式(Streaming 场景):申请一个作业所需要的全部资源,然后同时调度这个作业的全部 Task,所有的 Task 之间采取 Pipeline 的方式进行通信;
    • LAZY模式(Batch 场景):先调度上游,等待上游产生数据或结束后再调度下游,类似 Spark 的 Stage 执行模式。
  • 流批一体的 Shuffle Service 层

    • Shuffle:在分布式计算中,用来连接上下游数据交互的过程叫做 Shuffle。
    • 为了统一在Streaming和Batch模式下的Shuffle架构,Flink实现了一个Pluggable的Shuffle Service框架,抽象出一些公共模块,详情如下

img

经过在 DataStream 层、Scheduler层、Shuffle Service 层进行改造和优化,Flink已经可以方便地解决流和批场景的问题。

0%