开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

简单的注册登陆(第三方微信登陆) 注册登陆

发表于 2020-12-31

注册登陆

注册包括邮箱注册和手机号注册,两种注册方法业务都相同,只是激活方式不同

注册模块:

  1. 用户在页面输入邮箱/手机号,点击获取验证码,就会向后台发送post请求
  2. 后端接收到前端请求,发送验证码
  • 通过’工具类‘,生成一个随机四位数作为验证码
  • 将验证码保存到**‘redis‘**中,redis保存数据是通过键值对的方式保存
    • 所以我们可以通过电话+一个固定的常量作为key值
    • 随机数+当前毫秒值作为value值
    • 注意设置有效期,一般为5分钟
  • 注意当用户点击获取验证码时,我们要判断用户是否已经点击过获取验证码
    • 通过输入的手机号,按照我们拼接key值的方式拼接,然后从redis中获取
    • 如果获取到的value值不为空,表示这不是第一次点击获取,此时我们需要判断当前时间和第一次点击获取的时间,如果小于一分钟,抛出异常,告诉用户不要重复获取验证码,如果超过了一分钟,此时我们的验证码还是等于第一次获取的验证码(这样做是为了防止网络问题导致验证码累计问题)
    • 如果value值为空时,表明是第一次获取验证码,直接拼接验证码发送给用户
  • 然后调用**‘发送验证码工具类‘**发送验证码给用户
  1. 用户在页面输入收到的验证码,点击注册
  • 向后台发送请求,带有注册的信息
  • 后端响应请求获取请求携带的参数,此时我们可以使用一个临时对象接受参数(方便定义添加属性)
  • 首先第一件事就是进行校验
+ 校验数据是否为空,如果为空,抛出异常提示用户输入完整的信息
+ 校验手机号是否已被注册,通过前端传入的用户输入的手机号到数据库用户表进行查询,如果存在,抛出异常提示该手机已注册
+ 校验两次密码是否相同
+ 校验验证码,通过用户手机号和固定常量从redis中获取刚才发送的验证码


    - 判断验证码是否为空,如果为空表示已过期,抛出异常告诉用户验证码过期,请重新获取验证码
    - 如果不为空,比较用户输入的验证码和保存在redis缓存中的验证码是否相等,如果不等,抛出异常告诉用户验证码错误
  • 校验成功之后,将临时对象转化为登陆信息对象,保存登陆信息对象,就有了id

注意我们在保存密码时为了安全性,需要对密码进行加密,我们是通过盐值进行加密

+ 通过随机数获取盐值,并保存到登录信息中
+ 加密方式MD5:输入任意长度的信息,经过处理,输出都是128位的信息值,无法看到明文


​ 不同的输入对应的输出一定不同,保证唯一性


​ 计算速度快,加密速度快
+ 调用\*\*[MD5工具类](#MD5)\*\*的方法使用密码+盐值进行加密并设置保存 **注意有顺序次数的问题**
+ 需要注意的是我们的登录信息包含前台普通用户和后台用户,为了区分,我们需要设置账户类型(0代表后台用户,1代表前台普通用户)
  • 将登陆信息对象转化为user前端用户对象并保存,这样我们将关联了前端用户和登陆信息对象
+ (在转化对象时,相同普通属性进行拷贝,特殊属性手动设置)
  • 没有问题就返回AjaxResult-success给前端
  • 前端接收返回值,判断,如果注册成功,跳转到前端首页,如果不成功,就将错误异常展示到页面

账户密码(手机号,邮箱)登录模块

表的设计

用户是一张表,管理员是一张表,然后我们还做了一个登录表,登录表里面是所有用户和管理员的登录账号和密码,因为管理员也可以登录我们的网站享受服务,所以有可能一个手机或邮箱既是用户又是管理员。为了区分这种登录账号到底是要登录到后台还是网站,所以我们登录表里面有一个type字段,区分该账号到底是用户还是管理员

登录

  • 当用户输入信息点击登录时发送post登录请求,并携带了登录信息
  • 后台响应请求使用临时对象接收参数
  • 校验登录信息
+ 校验数据是否完整
+ 校验用户名是否存在,通过用户输入的用户名查询登录信息表中的登录对象,判断是否为空
+ 密码是否正确 **注意此时校验密码我们需要将临时对象中的密码按照我们注册时的加密方式进行加密,然后与数据库保存的密码比对**
+ 还需要判断此用户对象对应的登录信息状态是否禁用
+ 返回对应的登录信息对象### 判断用户是否登录

后端

+ 校验登录信息正确之后,通过UUID产生随机的token值
+ 将token值和对应的的登录信息对象保存到redis内存中
+ 将token作为key,登录信息对象作为value,返回一个map给前端


### 前端
+ 登录成功,后端会返回map格式的token值,将token值保存到\*\*[localStorage](#storage)\*\*中
+ 跳转到前端首页### 判断登录
  • axios前端前置拦截器:当前端每次发起请求时,都要将token值放入请求头中,每一个请求都需要token,所以我们可以配置一个前置拦截器,为所有请求头添加token
  • 后端拦截器:后端接收前端的访问请求,判断有没有token,通过token查询数据库中是否有对应的登录信息
+ 如果没有,则没有登录,返回一个未登录的状态给前端,如果有就放行(注意配置拦截器拦截和放行的页面,比如注册和登陆的页面必须放行)
+ 如果有,返回true,同时再设置一次token有效期
  • axios前端后置拦截器:判断后端拦截器返回的状态,如果为未登录状态,则跳转到登录页面,如果已登录状态,则跳转到请求访问的页面 **问题?**返回什么结果如何跳转到请求页面

微信第三方登陆

  • 用户点击微信登陆时后端响应请求,拉取二维码,重定向到二维码展示界面
  • 用户扫码确认登陆,执行回调函数并携带了授权码,我们设计了一个前端页面来响应这个回调函数,向后端发送请求获取登陆信息对象
  • 后端响应请求,通过授权码获取用户的token信息地址url,我们**通过httpClient插件向token信息地址获取token和微信用户唯一标识**
  • 通过token和唯一标识就能获取登录的微信用户的信息
    • 我们通过微信中的唯一标识去微信用户表查询,如果存在就微信用户,继续判断是否有登陆信息,如果登陆信息不为空,直接登陆
    • 如果没有登陆信息,就返回微信的用户的唯一标识,此时前端判断没有登陆信息只有唯一标识,跳转到绑定页面
    • 如果有登陆信息,我们就创建token,放入redis中,并创建map放入token和登陆信息对象返回前端,前端判断有登陆信息,放入localstorage中,然后跳转到后台首页

相关工具代码

随机数工具类

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
java复制代码package cn.itsource.pethome.basic.util;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
* @author yaohuaipeng
* @date 2018/10/26-16:16
*/
public class StrUtils {
/**
* 把逗号分隔的字符串转换字符串数组
*
* @param str
* @return
*/
public static String[] splitStr2StrArr(String str,String split) {
if (str != null && !str.equals("")) {
return str.split(split);
}
return null;
}


/**
* 把逗号分隔字符串转换List的Long
*
* @param str
* @return
*/
public static List<Long> splitStr2LongArr(String str) {
String[] strings = splitStr2StrArr(str,",");
if (strings == null) return null;

List<Long> result = new ArrayList<>();
for (String string : strings) {
result.add(Long.parseLong(string));
}

return result;
}
/**
* 把逗号分隔字符串转换List的Long
*
* @param str
* @return
*/
public static List<Long> splitStr2LongArr(String str,String split) {
String[] strings = splitStr2StrArr(str,split);
if (strings == null) return null;

List<Long> result = new ArrayList<>();
for (String string : strings) {
result.add(Long.parseLong(string));
}

return result;
}

public static String getRandomString(int length) {
String str = "01234567892342343243";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < length; i++) {
int number = random.nextInt(10);
sb.append(str.charAt(number));
}
return sb.toString();

}

public static String getComplexRandomString(int length) {
String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < length; i++) {
int number = random.nextInt(62);
sb.append(str.charAt(number));
}
return sb.toString();
}

public static void main(String[] args) {
String s = getComplexRandomString(4);
System.out.println(s);
}

public static String convertPropertiesToHtml(String properties){
//1:容量:6:32GB_4:样式:12:塑料壳
StringBuilder sBuilder = new StringBuilder();
String[] propArr = properties.split("_");
for (String props : propArr) {
String[] valueArr = props.split(":");
sBuilder.append(valueArr[1]).append(":").append(valueArr[3]).append("<br>");
}
return sBuilder.toString();
}

}

redis

了解数据库

​ 数据库分类

  • NoSQL非关系型数据库:存储数据都是没有结构的(没有表的概念),并且存储数据都是以key-value的的方式存储,都是把数据存储到内存或者磁盘上
  • RDBSM关系型数据库:是有行和列组成的二维表,存储数据是有一定格式的,都是把数据存储到磁盘上

区别:

1. 关系型数据库不支持高并发访问,非关系型数据库支持高并发访问
2. 关系型数据库存储数据都是有结构的,而非关系型数据库存储数据是没有结构的(,没有表概念)
3. 关系型数据库存储数据只能放到磁盘上,非关系型数据库存储数据是放在内存和磁盘上
4. 关系型数据库的结构和数据存储都是有限的(列最多200列,存储数据量也是有限的,最多不超过200万),而非关系型数据库只要硬件够好,数据量是没有限制的

Redis是什么

Redis是一个高性能的开源的,c语言写的NoSQL,数据保存在内存/磁盘中。

Redis是以key-value形式存储,不一定遵循传统数据库的一些基本要求,比如不遵循sql标准,事务,表结构等,redis严格说来不是一个数据库,应该是一种数据结构化存储方法的集合

存储数据都是以字符串的形式进行存储,把存储的字符串按一定的规则进行摆放(规则:String list set zset Hash)

特点

  1. 开源免费
  2. 支持高并发,读取速度非常快
  3. 存储数据放到内存或者磁盘
  4. 支持多种类型的客户端访问(c,php,java)
  5. 支持集群(集群的意思:一台不够用就再加一台)

使用场景

  1. 做中央缓存
  2. 点赞计数器
  3. 防攻击系统

使用redis保存验证码

绿色版的在cmd终端启动服务 redis-server.exe redis.window.config

然后我们在springboot项目中使用redis

  • 导包
1
2
3
4
5
xml复制代码<!--对redis的支持-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  • 配置文件application.properties
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
properties复制代码# redis 属性配置
## redis数据库索引(默认为0)
spring.redis.database=0
## redis服务器地址
spring.redis.host=localhost
## redis服务器连接端口
spring.redis.port=6379
## redis服务器连接密码(默认为空)
spring.redis.password=123456
## 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=8
## 连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=8
## 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=-1ms
## 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=0
  • 保存验证码到redis(我们为验证码设置对应的全局常量作为标识,并设置了当前的毫秒值,方便验证码过期问题)
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
java复制代码 /package cn.itsource.pethome.basic.service.impl;

import cn.itsource.pethome.basic.constant.PetHomeConstant;
import cn.itsource.pethome.basic.service.IVerificationService;
import cn.itsource.pethome.basic.util.SendMsgUtil;
import cn.itsource.pethome.basic.util.StrUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;

@Service
@Transactional(readOnly = true,propagation = Propagation.SUPPORTS)
public class VerificationCodeServiceImpl implements IVerificationService {

@Autowired
private RedisTemplate redisTemplate;

/**
* 验证码特点:
* 1.随机产生4位
* 2.有效期5分===》redis
* 3.区分验证码类型
*
* 市场上常见的验证码:
* 1.有效期是5分钟,如果在60s以内我连续发了2次,
* 1种方案是提示前端用户,不能重复发送,
* 如果超过了60s,但没有超过5分钟,我就不产生新的验证码,还是用第一次产生的验证码
* 如果超过了5分钟, 就产生全新的验证码
*
* @param phone 手机号码
*/
@Override
public void sendRegisterMobileCode(String phone) {
//手机号发送手机验证码
sendMobileCode(phone,PetHomeConstant.REGISTER_VERIFICATION_CODE);
}

@Override
public void sendBinderMobileCode(String phone) {
//绑定用户发送手机验证码
sendMobileCode(phone,PetHomeConstant.BINDER_VERIFICATION_CODE);
}

private void sendMobileCode(String phone,String type){
//1.产生随机4位的验证码 JK82
String code = StrUtils.getComplexRandomString(4);
//通过手机号码在redis中获取对应的验证码 JK82:21312433542423
String codeValue = (String) redisTemplate.opsForValue().get(type+":"+phone);
if(!StringUtils.isEmpty(codeValue)){
//获取第一次的毫秒时间
Long beginTimer = Long.valueOf(codeValue.split(":")[1]);
if(System.currentTimeMillis()-beginTimer<60*1000){
throw new RuntimeException("一分钟以内不能重复获取验证码!!!");
}
//如果没有走抛异常就证明验证码依然在5分钟以内,但是超过了1分钟
code = codeValue.split(":")[0];
}
//2.把验证码存储到redis中,并且有效期是5分钟
redisTemplate.opsForValue().set(type+":"+phone,
code+":"+System.currentTimeMillis(),5, TimeUnit.MINUTES);
String content = "尊敬的用户,你的验证码为:"+code+",请在5分钟以内完成验证操作!!";
System.out.println(content);
// 发送验证码
// SendMsgUtil.sendMsg(phone, content);
}


}

手机发送验证码的包

1
2
3
4
5
6
xml复制代码<!-- https://mvnrepository.com/artifact/commons-httpclient/commons-httpclient -->
<dependency>
<groupId>commons-httpclient</groupId>
<artifactId>commons-httpclient</artifactId>
<version>3.1</version>
</dependency>

发送验证码的工具类

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
java复制代码package cn.itsource.pethome.basic.util;


import org.apache.commons.httpclient.Header;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.methods.PostMethod;

import java.io.IOException;

public class SendMsgUtil {
//本站用户名
public static final String UID = "huangxuyang";
public static final String KEY = "d41d8cd98f00b204e980";

/**
* 发送邮件
* @param phone 手机号码
* @param content 短信内容
*/
public static void sendMsg(String phone,String content){
PostMethod post = null;
try {
//创建客户端
HttpClient client = new HttpClient();
//发送post请求
post = new PostMethod("http://utf8.api.smschinese.cn");
//添加请求头信息
post.addRequestHeader("Content-Type","application/x-www-form-urlencoded;charset=utf8");//在头文件中设置转码
//设置请求的基本信息
NameValuePair[] data ={ new NameValuePair("Uid", UID),
new NameValuePair("Key", KEY),
new NameValuePair("smsMob",phone),
new NameValuePair("smsText",content)};
//设置请求体
post.setRequestBody(data);
//开始调用
client.executeMethod(post);

//以下代码没什么用了,就是返回响应状态而已
Header[] headers = post.getResponseHeaders();
int statusCode = post.getStatusCode();
System.out.println("statusCode:"+statusCode);
for(Header h : headers)
{
System.out.println(h.toString());
}
String result = new String(post.getResponseBodyAsString().getBytes("gbk"));
System.out.println(result); //打印返回消息状态

} catch (IOException e) {
e.printStackTrace();
} finally {
if(post!=null){
post.releaseConnection();
}
}
}
}

发送邮件的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
properties复制代码# 设置邮箱主机(服务商)
spring.mail.host=smtp.qq.com
# 设置用户名
spring.mail.username=798477672@qq.com

# 设置密码,该处的密码是QQ邮箱开启SMTP的授权码而非QQ密码
spring.mail.password=eglwfhofzaoubche

# 必须进行授权认证,它的目的就是阻止他人任意乱发邮件
spring.mail.properties.mail.smtp.auth=true

#SMTP加密方式:连接到一个TLS保护连接
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true

发送邮件的实现

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
java复制代码package cn.itsource.pethome;

import cn.itsource.pethome.App;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.io.File;

@SpringBootTest(classes = App.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class SimpleEmailTest {

@Autowired
private JavaMailSender javaMailSender;

@Test
public void test(){
//首先创建普通邮件
SimpleMailMessage message = new SimpleMailMessage();
//设置普通邮件的发件人
message.setFrom("798477672@qq.com");
//设置收件人
message.setTo("798477672@qq.com");
//设置邮件标题
message.setSubject("入学通知");
//设置文件内容
message.setText("恭喜你,我校对于你的表现十分满意,特招你成为我们的学生——新东方技术学院");
//发送邮件
javaMailSender.send(message);
}

@Test
public void test2() throws MessagingException {
//创建复杂文件对象
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
//获取复杂邮件的工具类
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage, true, "utf-8");
//设置发件人
messageHelper.setFrom("798477672@qq.com");
//设置文件标题
messageHelper.setSubject("天上人间邀请函");
//设置文件内容
messageHelper.setText("<h1>天上人间</h1>"
+"<img src='https://pic.feizl.com/upload2007/allimg/170628/1KK2IF-14.jpg' />" ,true);
//设置附件
messageHelper.addAttachment("1.jpg", new File("D:\\图片壁纸\\壁纸\\1.jpg"));
//设置收件人
messageHelper.setTo("798477672@qq.com");
//发送邮件
javaMailSender.send(mimeMessage);
}
}

MD5加密工具类

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
java复制代码package cn.itsource.pethome.basic.util;


import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class MD5Utils {

/**
* 加密
* @param context
*/
public static String encrypByMd5(String context) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(context.getBytes());//update处理
byte [] encryContext = md.digest();//调用该方法完成计算

int i;
StringBuffer buf = new StringBuffer("");
for (int offset = 0; offset < encryContext.length; offset++) {//做相应的转化(十六进制)
i = encryContext[offset];
if (i < 0) i += 256;
if (i < 16) buf.append("0");
buf.append(Integer.toHexString(i));
}
return buf.toString();
} catch (NoSuchAlgorithmException e) {
// TODO Auto-generated catch block
e.printStackTrace();
return null;
}
}

public static void main(String[] args) {
//加密
System.out.println(MD5Utils.encrypByMd5("1")+"37XFtqiwKz");
//加密加盐 查询用户时,除了查到加密密码外,还能查到颜值。 把输入密码+盐值加密和数据库存放密码比对就OK
System.out.println(MD5Utils.encrypByMd5("123456"+ StrUtils.getComplexRandomString(32)));
System.out.println(MD5Utils.encrypByMd5("123456"+ StrUtils.getComplexRandomString(32)));
System.out.println(MD5Utils.encrypByMd5("123456"+ StrUtils.getComplexRandomString(32)));
}

}

storage的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
vue复制代码 <script type="text/javascript">
new Vue({
el: "#app",
mounted(){
//获取url?传递的值
let param = getParam()
//发送axios请求,通过授权码获取token
this.$http.post("/wechat/getToken",param).then(res=>{
console.debug(res.data);
let {success, msg, resultobj} = res.data;
//如果成功,并且openid有值
if (success && resultobj.openid) {
location.href = "binder.html?openid=" + resultobj.openid;
} else if (success && resultobj.token && resultobj.loginUser) {
//将token'值保存到localStorage中
localStorage.setItem("token", resultobj.token);
localStorage.setItem("loginUser", JSON.stringify(resultobj.loginUser));
location.href = "/index.html";
}

})
}
})
</script>

保存密码加密

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
java复制代码package cn.itsource.pethome.user.service.impl;

import cn.itsource.pethome.basic.constant.PetHomeConstant;
import cn.itsource.pethome.org.domain.Employee;
import cn.itsource.pethome.user.domain.Logininfo;
import cn.itsource.pethome.user.domain.User;
import cn.itsource.pethome.user.domain.dto.UserDto;
import cn.itsource.pethome.user.mapper.LogininfoMapper;
import cn.itsource.pethome.user.mapper.UserMapper;
import cn.itsource.pethome.user.service.IUserService;
import cn.itsource.pethome.basic.util.MD5Utils;
import cn.itsource.pethome.basic.util.StrUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

@Service
@Transactional(readOnly = true,propagation = Propagation.SUPPORTS)
public class UserServiceImpl implements IUserService {

@Autowired
private UserMapper userMapper;

@Autowired
private LogininfoMapper logininfoMapper;

@Autowired
private RedisTemplate redisTemplate;

/**
* 注册用户
* @param userDto
*/
@Override
public void register(UserDto userDto) {
//校验传入的临时数据对象进行
checkUserDto(userDto);
//将临时数据转为登陆信息
Logininfo logininfo = userDto2Logininfo(userDto);
//保存登陆信息
logininfoMapper.save(logininfo);
//将登陆信息logininfo对象转化为user对象
User user = Logininfo2User(logininfo);
//保存user对象
userMapper.save(user);
}

/**
* 将logininfo登陆信息对象转化user对象
* @param logininfo
* @return
*/
private User Logininfo2User(Logininfo logininfo) {
User user = new User();
//使用方法拷贝对象,只拷贝字段相同的属性
BeanUtils.copyProperties(logininfo, user);
//设置激活状态
user.setState(PetHomeConstant.STATEOK);
//设置登录信息
user.setLogininfo(logininfo);
return user;
}

/**
* 将临时对象数据转为为登陆信息
* @param userDto
* @return
*/
private Logininfo userDto2Logininfo(UserDto userDto) {
Logininfo logininfo = new Logininfo();
//设置用户名
logininfo.setUsername(userDto.getPhone());
//设置手机号
logininfo.setPhone(userDto.getPhone());
//设置盐值
logininfo.setSalt(StrUtils.getComplexRandomString(10));
//设置密码(使用盐值进行加密)
logininfo.setPassword(MD5Utils.encrypByMd5(userDto.getPassword()+logininfo.getSalt()));
//登录的类型(是后端登录还是前端登录) 0代表管理员,1代表普通用户,true默认为前端用户
logininfo.setType(true);
//disable是否可用默认为可用
return logininfo;
}

/**
* 校验前端传入的注册信息
* @param userDto
*/
public void checkUserDto(UserDto userDto) {
//1.校验前端传入的注册信息是否为空
if(StringUtils.isEmpty(userDto.getCode()) || StringUtils.isEmpty(userDto.getPhone())
|| StringUtils.isEmpty(userDto.getPassword()) || StringUtils.isEmpty(userDto.getPasswordRepeat())){
throw new RuntimeException("请输入完整信息!");
}
//2.校验手机号是否已被注册
//查询根据前端传入的手机号与数据库比对
User user = userMapper.findonebyphone(userDto.getPhone());
//如果能查到手机号对应的用户,说明已被注册
if(user!=null){
throw new RuntimeException("该手机号已被注册");
}
//3.校验用户输入的两次密码是否一致
if (!userDto.getPassword().equals(userDto.getPasswordRepeat())) {
throw new RuntimeException("两次输出的密码不一致!");
}
//4.校验验证码是否过期
//从redis内存中获取用户对应的验证码的信息,此时获得的验证码格式为 code+时间戳
String codeValue = (String) redisTemplate.opsForValue().get(PetHomeConstant.REGISTER_VERIFICATION_CODE + ":" + userDto.getPhone());
//判断验证码是否过期
if (StringUtils.isEmpty(codeValue)) {
throw new RuntimeException("验证码已过期,请重新获取");
}
//获取redis中的验证码
String code = codeValue.split(":")[0];
//如果内存中保存的验证码和前端传入的验证码不同,说明验证码已过期
System.out.println(code);
if (!code.toLowerCase().equals(userDto.getCode().toLowerCase())) {
throw new RuntimeException("输入的验证码错误!");
}
}
}

后端控制器

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
java复制代码package cn.itsource.pethome.user.interceptor;

import cn.itsource.pethome.user.domain.Logininfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.concurrent.TimeUnit;


/**
* 配置后端拦截器,判断当前请求是否有token,没有则告诉前端,尚未登录,无权限访问
*/
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
System.out.println(request.getRequestURL());
//获取token的值
String token = request.getHeader("token");
if(StringUtils.isEmpty(token)){
// 提示前端用户,登录超时,请重新登录
wirteError(response);
return false;
}
Logininfo loginInfo = (Logininfo) redisTemplate.opsForValue().get(token);
if(loginInfo == null){
// 提示前端用户,登录超时,请重新登录
wirteError(response);
return false;
}
redisTemplate.opsForValue().set(token, loginInfo, 30, TimeUnit.MINUTES);
return true;
}
private void wirteError(HttpServletResponse response){
try {
response.setContentType("text/json;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write("{\"success\":false,\"msg\":\"noLogin\"}");
} catch (IOException e) {
e.printStackTrace();
}
}
}

axios前置后置拦截器

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
js复制代码Vue.prototype.$http = axios;
/*给所有的axios请求,添加请求前缀*/
axios.defaults.baseURL="http://localhost";
Vue.prototype.dfsUrl="http://115.159.217.249:8888";


/*axios前置拦截器*/
axios.interceptors.request.use(config => {
//携带token
let token = localStorage.getItem("token");
if (token) {
//在头信息中添加token
config.headers['token'] = token;
}
return config;
}, error => {
Promise.reject(error);
});
//动态获取url地址?
//axios后置拦截器
axios.interceptors.response.use(result => {
let {success, msg} = result.data;
if(!success && msg === "noLogin"){
//清空本地存储
localStorage.removeItem("token");
localStorage.removeItem("loginUser");
router.push({ path: '/login' });
}
return result;
}, error => {
Promise.reject(error);
});

微信获取token的插件

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
java复制代码package cn.itsource.pethome.basic.util;

import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.params.HttpMethodParams;

import java.io.IOException;

/**
* 使用httpclient组件发送http请求
* get:现在只用到get
* post
*/
public class HttpClientUtils {
/**
* 发送get请求
* @param url 请求地址
* @return 返回内容 json
*/
public static String httpGet(String url){

// 1 创建发起请求客户端
try {
HttpClient client = new HttpClient();
// 2 创建要发起请求-tet
GetMethod getMethod = new GetMethod(url);
// getMethod.addRequestHeader("Content-Type",
// "application/x-www-form-urlencoded;charset=UTF-8");
getMethod.getParams().setParameter(HttpMethodParams.HTTP_CONTENT_CHARSET,"utf8");
// 3 通过客户端传入请求就可以发起请求,获取响应对象
client.executeMethod(getMethod);
// 4 提取响应json字符串返回
String result = new String(getMethod.getResponseBodyAsString().getBytes("utf8"));
return result;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}

微信登陆的实现

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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
java复制代码package cn.itsource.pethome.user.service.impl;

import cn.itsource.pethome.basic.constant.PetHomeConstant;
import cn.itsource.pethome.basic.util.HttpClientUtils;
import cn.itsource.pethome.basic.util.MD5Utils;
import cn.itsource.pethome.basic.util.StrUtils;
import cn.itsource.pethome.user.domain.Logininfo;
import cn.itsource.pethome.user.domain.User;
import cn.itsource.pethome.user.domain.WechatUser;
import cn.itsource.pethome.user.domain.dto.UserDto;
import cn.itsource.pethome.user.mapper.LogininfoMapper;
import cn.itsource.pethome.user.mapper.UserMapper;
import cn.itsource.pethome.user.mapper.WechatMapper;
import cn.itsource.pethome.user.service.IWechatService;
import com.alibaba.fastjson.JSONObject;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

@Service
public class WechatServerImpl implements IWechatService {

@Autowired
private UserMapper userMapper;

@Autowired
private LogininfoMapper logininfoMapper;
@Autowired
private WechatMapper wechatMapper;

@Autowired
private RedisTemplate redisTemplate;
/**
* 判断用户是否绑定
* @param code 授权码
* @return
*/
@Override
public Map<String, Object> getToken(String code) {

//首先根据code获取token的url地址
String tokenUrl = PetHomeConstant.TOKENURL.replace("APPID", PetHomeConstant.APPID)
.replace("SECRET", PetHomeConstant.SECRET)
.replace("CODE", code);

//然后向url地址发送get请求获取json字符串格式的
String httpGet = HttpClientUtils.httpGet(tokenUrl);
//将json字符串转化为json对象
JSONObject jsonObject = JSONObject.parseObject(httpGet);
System.out.println(jsonObject);
//通过json对象获取openid唯一标识
String openid = jsonObject.getString("openid");
//获取token
String token = jsonObject.getString("access_token");
//获取unionid,当用户授权之后才有的标识
String unionid = (String) jsonObject.get("unionid");
//通过token获取用户资源url
String userinfoUrl = PetHomeConstant.USERINFOURL.replace("ACCESS_TOKEN", token)
.replace("OPENID", openid);
//通过资源url获取用户信息
String userinfo = HttpClientUtils.httpGet(userinfoUrl);
//将用户信息json字符串转为json对象
JSONObject userObject = JSONObject.parseObject(userinfo);
System.out.println(userObject);
//获取唯一标识
String userOpenid = userObject.getString("openid");
//通过用户的唯一标识openid去数据库查找对应的数据
WechatUser wechatUser = wechatMapper.loadByopenid(userOpenid);
//设置一个map,封装数据返回给前端
Map<String, Object> map = new HashMap<>();
//如果查询的用户为空,说明用户还没有绑定登陆信息
if (wechatUser == null) {
WechatUser user = new WechatUser();
//设置唯一标识
user.setOpenid(userOpenid);
//设置用户名
user.setNickname(userObject.getString("nickname"));
//设置性别
user.setSex(userObject.getBoolean("sex"));
//设置地址
user.setAddress(userObject.getString("province")+userObject.getString("city"));
//设置头像
user.setHeadimgurl(userObject.getString("headimgurl"));
//设置授权后唯一标识
user.setUnionid(userObject.getString("unionid"));
//保存当前用户
wechatMapper.save(user);
//将用户的唯一标识响应前端,跳转到授权页面
map.put("openid", openid);
return map;
}else{//如果user不为空,判断用户是否绑定登陆信息
Logininfo logininfo = wechatUser.getLogininfo();
System.out.println(logininfo+"--------------------------------------1");
if (logininfo == null) {
//响应给前端跳转到绑定页面
map.put("openid", openid);
return map;
}else{//不为空,代表已经绑定了,直接登陆
System.out.println(logininfo+"----------------------------------------2");
//创建token
String token1 = UUID.randomUUID().toString();
//将token放入内存中
redisTemplate.opsForValue().set(token1, logininfo);
//将token和用户信息返回
map.put("token", token1);
map.put("loginUser", logininfo);
return map;
}

}


}

/**
* 进行绑定跳转
* @param userDto
* @return
*/
@Override
public Map<String, Object> binder(UserDto userDto) {
//首先校验前端数据
checkDto(userDto);
//设置用户名,方便在登陆信息中查询该用户对象
userDto.setUsername(userDto.getPhone());
//创建map集合,封装登录信息和token信息
Map<String, Object> map = new HashMap<>();
//根据用户名查询登陆信息
Logininfo logininfo = logininfoMapper.loadByUserDto(userDto);
if (logininfo == null) {//登陆信息为空
logininfo = new Logininfo();
//将用户信息转为登陆信息
logininfo =userDto2logininfo(userDto);
//保存登陆信息
logininfoMapper.save(logininfo);
//将登陆信息转为user对象信息
User user = logiinfo2User(logininfo);
//保存user信息
userMapper.save(user);
}
//根据查询出来的登陆信息,将微信扫描码的用户信息与登陆信息关联
wechatMapper.binder(logininfo.getId(), userDto.getOpenid());
//然后创建token,返回前端,直接登陆
//创建token
String token1 = UUID.randomUUID().toString();
//将token放入内存中
redisTemplate.opsForValue().set("token", token1);
//将token和用户信息返回
map.put("token", token1);
map.put("loginUser", logininfo);
return map;
}

/**
* 将登陆信息转为user信息
* @param logininfo
*/
private User logiinfo2User(Logininfo logininfo) {
User user = new User();
BeanUtils.copyProperties(logininfo, user);
user.setState(PetHomeConstant.STATEOK);
user.setLogininfo(logininfo);
return user;
}

/**
* 将临时信息转为登陆信息
* @param userDto
* @return
*/
private Logininfo userDto2logininfo(UserDto userDto) {
Logininfo logininfo = new Logininfo();
logininfo.setUsername(userDto.getPhone());
logininfo.setPhone(userDto.getPhone());
logininfo.setType(userDto.getType());
logininfo.setSalt(StrUtils.getComplexRandomString(10));
logininfo.setPassword(MD5Utils.encrypByMd5(logininfo.getPhone()+logininfo.getSalt()));
return logininfo;
}

private void checkDto(UserDto userDto) {
//校验输入信息完整
if(StringUtils.isEmpty(userDto.getPhone())||
StringUtils.isEmpty(userDto.getCode())||
StringUtils.isEmpty(userDto.getType())){
throw new RuntimeException("请输入完整信息");
}
//校验验证码
//从内存中获取验证码
String codeValue = (String) redisTemplate.opsForValue().get(PetHomeConstant.BINDER_VERIFICATION_CODE + ":" + userDto.getPhone());
if (codeValue == null) {
throw new RuntimeException("验证码已过期");
}
//比对用户输入的验证码和redis内存中的验证码是否相等
if (!codeValue.split(":")[0].toLowerCase().equals(userDto.getCode().toLowerCase())) {
throw new RuntimeException("验证码错误");
}
}

}

随机生成订单号

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
java复制代码/**
* 存放经纬度
*/
@Data
public class Point {
//经度
private Double lng;
//维度
private Double lat;
}



package cn.itsource.pethome.basic.util;

import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;


public class CodeGenerateUtils {

/**
* 获取商品编码
* 商品编码规则:nanoTime(后5位)*5位随机数(10000~99999)
* @return
*/
public static String generateProductCode(){
long nanoPart = System.nanoTime() % 100000L;
if(nanoPart<10000L){
nanoPart+=10000L;
}
long randomPart = (long)(Math.random()*(90000)+10000);
String code = "0"+String.valueOf((new BigDecimal(nanoPart).multiply(new BigDecimal(randomPart))));
return code.substring(code.length()-10);
}

/**
* @param id: 用户id
* 生成订单编号
* 订单编号规则:(10位):(年末尾*月,取后2位)+(用户ID%3.33*日取整后2位)+(timestamp*10000以内随机数,取后6位)
* @return
*/
public static String generateOrderSn(long id){
Calendar calendar = Calendar.getInstance();
int year = calendar.get(Calendar.YEAR);
year = year % 10;
if(year == 0) year = 10;
int month = calendar.get(Calendar.MONTH)+1;
int yearMonth = year * month;
String yearMonthPart = "0"+yearMonth;
yearMonthPart = yearMonthPart.substring(yearMonthPart.length() - 2 );

int day = calendar.get(Calendar.DAY_OF_MONTH);
int dayNum = (int)((id % 3.33) * day);
String dayPart = "0"+dayNum;
dayPart = dayPart.substring(dayPart.length() - 2);

String timestampPart = ""+(Math.random() * 10000) * (System.currentTimeMillis()/10000);
timestampPart = timestampPart.replace(".", "").replace("E", "");
timestampPart = timestampPart.substring(0,6);
return yearMonthPart+dayPart+timestampPart;
}

/**
* 生成统一支付单号
* 规则:年(2)月(2)日(2)时(2)分(2)+timestamp*5位随机整数取后5位
* @return
*/
public static String generateUnionPaySn(){
Calendar calendar = Calendar.getInstance();
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddhhmm");
String dateTime = dateFormat.format(calendar.getTime());
dateTime = dateTime.substring(2);
String timestampPart = ""+(Math.random() * 10000) * (System.currentTimeMillis()/10000);
timestampPart = timestampPart.replace(".", "").replace("E", "");
timestampPart = timestampPart.substring(0,5);
return dateTime+timestampPart;
}

public static void main(String[] args) {
for(long i=0;i<100;i++)
{
//String timestampPart = ""+(Math.random() * 10000) * (System.currentTimeMillis()/10000);
//System.out.println(timestampPart);
//System.out.println(generateOrderSn(i));
System.out.println(generateUnionPaySn());
}
}

}

通过地址获取经纬度距离

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
java复制代码package cn.itsource.pethome.basic.util;


import cn.itsource.pethome.org.domain.Shop;

import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.List;

/**
* 位置相关工具类
*/
public class DistanceUtil {

/**
* 通过地址转为经纬度
* @param address
* @return
*/
public static Point getPoint(String address){
String Application_ID="PQ9FAt6qg7taDWj6LLABYO7u6bSETXhD";//配置上自己的百度地图应用的AK
try{
String sCurrentLine; String sTotalString;sCurrentLine ="";
sTotalString = "";
InputStream l_urlStream;
URL l_url = new URL("http://api.map.baidu.com/geocoding/v3/?address="+address+"&output=json&ak="+Application_ID+"&callback=showLocation");
HttpURLConnection l_connection = (HttpURLConnection) l_url.openConnection();
l_connection.connect();
l_urlStream = l_connection.getInputStream();
java.io.BufferedReader l_reader = new java.io.BufferedReader(new InputStreamReader(l_urlStream));
String str=l_reader.readLine();
System.out.println(str);
//用经度分割返回的网页代码
String s=","+"\""+"lat"+"\""+":";
String strs[]=str.split(s,2);
String s1="\""+"lng"+"\""+":";
String a[]=strs[0].split(s1, 2);
s1="}"+","+"\"";
String a1[]=strs[1].split(s1,2);

Point point=new Point();
point.setLng(Double.valueOf(a[1]));
point.setLat(Double.valueOf(a1[0]));
return point;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

//地球半径,进行经纬度运算需要用到的数据之一
private static final double EARTH_RADIUS = 6378137;
//根据坐标点获取弧度
private static double rad(double d)
{
return d * Math.PI / 180.0;
}

/**
* 根据两点间经纬度坐标(double值),计算两点间距离,单位为米
* @param point1 A点坐标
* @param point2 B点坐标
* @return
*/
public static double getDistance(Point point1,Point point2)
{
double radLat1 = rad(point1.getLat());
double radLat2 = rad(point2.getLat());
double a = radLat1 - radLat2;
double b = rad(point1.getLng()) - rad(point2.getLng());
double s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a/2),2) +
Math.cos(radLat1)*Math.cos(radLat2)*Math.pow(Math.sin(b/2),2)));
s = s * EARTH_RADIUS;
s = Math.round(s * 10000) / 10000;
return s;
}

/**
* 根据两点间经纬度坐标(double值),计算两点间距离,单位为米
* @param point 用户指定的地址坐标
* @param shops 商店
* @return
*/
public static Shop getNearestShop (Point point, List<Shop> shops) {

//如果传过来的集合只有一家店铺,那么直接将这家店铺的信息返回就是最近的店铺了
Shop shop=shops.get(0);
//获取集合中第一家店铺到指定地点的距离
double distance=getDistance(point,getPoint(shops.get(0).getAddress()));
//如果有多家店铺,那么就和第一家店铺到指定地点的距离做比较
if (shops.size()>1){
for (int i=1;i<shops.size();i++){
if (getDistance(point,getPoint(shops.get(i).getAddress()))<distance){
shop=shops.get(i);
}
}
}
return shop;
}

public static void main(String[] args) {
System.out.println(getPoint("成都市武侯区天府新谷-10号楼"));
}
}

fastdfs上传下载文件

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
java复制代码package cn.itsource.pethome.basic.util;


import cn.itsource.pethome.org.domain.Shop;

import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.List;

/**
* 位置相关工具类
*/
public class DistanceUtil {

/**
* 通过地址转为经纬度
* @param address
* @return
*/
public static Point getPoint(String address){
String Application_ID="PQ9FAt6qg7taDWj6LLABYO7u6bSETXhD";//配置上自己的百度地图应用的AK
try{
String sCurrentLine; String sTotalString;sCurrentLine ="";
sTotalString = "";
InputStream l_urlStream;
URL l_url = new URL("http://api.map.baidu.com/geocoding/v3/?address="+address+"&output=json&ak="+Application_ID+"&callback=showLocation");
HttpURLConnection l_connection = (HttpURLConnection) l_url.openConnection();
l_connection.connect();
l_urlStream = l_connection.getInputStream();
java.io.BufferedReader l_reader = new java.io.BufferedReader(new InputStreamReader(l_urlStream));
String str=l_reader.readLine();
System.out.println(str);
//用经度分割返回的网页代码
String s=","+"\""+"lat"+"\""+":";
String strs[]=str.split(s,2);
String s1="\""+"lng"+"\""+":";
String a[]=strs[0].split(s1, 2);
s1="}"+","+"\"";
String a1[]=strs[1].split(s1,2);

Point point=new Point();
point.setLng(Double.valueOf(a[1]));
point.setLat(Double.valueOf(a1[0]));
return point;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

//地球半径,进行经纬度运算需要用到的数据之一
private static final double EARTH_RADIUS = 6378137;
//根据坐标点获取弧度
private static double rad(double d)
{
return d * Math.PI / 180.0;
}

/**
* 根据两点间经纬度坐标(double值),计算两点间距离,单位为米
* @param point1 A点坐标
* @param point2 B点坐标
* @return
*/
public static double getDistance(Point point1,Point point2)
{
double radLat1 = rad(point1.getLat());
double radLat2 = rad(point2.getLat());
double a = radLat1 - radLat2;
double b = rad(point1.getLng()) - rad(point2.getLng());
double s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a/2),2) +
Math.cos(radLat1)*Math.cos(radLat2)*Math.pow(Math.sin(b/2),2)));
s = s * EARTH_RADIUS;
s = Math.round(s * 10000) / 10000;
return s;
}

/**
* 根据两点间经纬度坐标(double值),计算两点间距离,单位为米
* @param point 用户指定的地址坐标
* @param shops 商店
* @return
*/
public static Shop getNearestShop (Point point, List<Shop> shops) {

//如果传过来的集合只有一家店铺,那么直接将这家店铺的信息返回就是最近的店铺了
Shop shop=shops.get(0);
//获取集合中第一家店铺到指定地点的距离
double distance=getDistance(point,getPoint(shops.get(0).getAddress()));
//如果有多家店铺,那么就和第一家店铺到指定地点的距离做比较
if (shops.size()>1){
for (int i=1;i<shops.size();i++){
if (getDistance(point,getPoint(shops.get(i).getAddress()))<distance){
shop=shops.get(i);
}
}
}
return shop;
}

public static void main(String[] args) {
System.out.println(getPoint("成都市武侯区天府新谷-10号楼"));
}
}

常用包

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
xml复制代码 <!--对redis的支持-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 模拟发送http请求 -->
<dependency>
<groupId>commons-httpclient</groupId>
<artifactId>commons-httpclient</artifactId>
<version>3.1</version>
</dependency>
<!--处理json-->
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.7</version>
</dependency>
<!--支付宝支付所需jar包-->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.9.13.ALL</version>
</dependency>

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

DDD系列第四讲:领域层设计规范 初探龙与魔法的世界架构 E

发表于 2020-12-31

在一个DDD架构设计中,领域层的设计合理性会直接影响整个架构的代码结构以及应用层、基础设施层的设计。但是领域层设计又是有挑战的任务,特别是在一个业务逻辑相对复杂应用中,每一个业务规则是应该放在Entity、ValueObject 还是 DomainService是值得用心思考的,既要避免未来的扩展性差,又要确保不会过度设计导致复杂性。今天我用一个相对轻松易懂的领域做一个案例演示,但在实际业务应用中,无论是交易、营销还是互动,都可以用类似的逻辑来实现。

初探龙与魔法的世界架构

背景和规则

平日里看了好多严肃的业务代码,今天找一个轻松的话题,如何用代码实现一个龙与魔法的游戏世界的(极简)规则?

基础配置如下:

  • 玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)
  • 怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量
  • 武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力
  • 玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型

攻击规则如下:

  • 兽人对物理攻击伤害减半
  • 精灵对魔法攻击伤害减半
  • 龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍

OOP实现

对于熟悉Object-Oriented Programming的同学,一个比较简单的实现是通过类的继承关系(此处省略部分非核心代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scala复制代码public abstract class Player {
Weapon weapon
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}

public abstract class Monster {
Long health;
}
public Orc extends Monster {}
public Elf extends Monster {}
public Dragoon extends Monster {}

public abstract class Weapon {
int damage;
int damageType; // 0 - physical, 1 - fire, 2 - ice etc.
}
public Sword extends Weapon {}
public Staff extends Weapon {}

而实现规则代码如下:

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
scala复制代码public class Player {
public void attack(Monster monster) {
monster.receiveDamageBy(weapon, this);
}
}

public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 基础规则
}
}

public class Orc extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (weapon.getDamageType() == 0) {
this.setHealth(this.getHealth() - weapon.getDamage() / 2); // Orc的物理防御规则
} else {
super.receiveDamageBy(weapon, player);
}
}
}

public class Dragon extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (player instanceof Dragoon) {
this.setHealth(this.getHealth() - weapon.getDamage() * 2); // 龙骑伤害规则
}
// else no damage, 龙免疫力规则
}
}

然后跑几个单测:

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
java复制代码public class BattleTest {

@Test
@DisplayName("Dragon is immune to attacks")
public void testDragonImmunity() {
// Given
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Excalibur", 10);
fighter.setWeapon(sword);
Dragon dragon = new Dragon("Dragon", 100L);

// When
fighter.attack(dragon);

// Then
assertThat(dragon.getHealth()).isEqualTo(100);
}

@Test
@DisplayName("Dragoon attack dragon doubles damage")
public void testDragoonSpecial() {
// Given
Dragoon dragoon = new Dragoon("Dragoon");
Sword sword = new Sword("Excalibur", 10);
dragoon.setWeapon(sword);
Dragon dragon = new Dragon("Dragon", 100L);

// When
dragoon.attack(dragon);

// Then
assertThat(dragon.getHealth()).isEqualTo(100 - 10 * 2);
}

@Test
@DisplayName("Orc should receive half damage from physical weapons")
public void testFighterOrc() {
// Given
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Excalibur", 10);
fighter.setWeapon(sword);
Orc orc = new Orc("Orc", 100L);

// When
fighter.attack(orc);

// Then
assertThat(orc.getHealth()).isEqualTo(100 - 10 / 2);
}

@Test
@DisplayName("Orc receive full damage from magic attacks")
public void testMageOrc() {
// Given
Mage mage = new Mage("Mage");
Staff staff = new Staff("Fire Staff", 10);
mage.setWeapon(staff);
Orc orc = new Orc("Orc", 100L);

// When
mage.attack(orc);

// Then
assertThat(orc.getHealth()).isEqualTo(100 - 10);
}
}

以上代码和单测都比较简单,不做多余的解释了。

分析OOP代码的设计缺陷

编程语言的强类型无法承载业务规则

以上的OOP代码可以跑得通,直到我们加一个限制条件:

  • 战士只能装备剑
  • 法师只能装备法杖

这个规则在Java语言里无法通过强类型来实现,虽然Java有Variable Hiding(或者C#的new class variable),但实际上只是在子类上加了一个新变量,所以会导致以下的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scala复制代码@Data
public class Fighter extends Player {
private Sword weapon;
}

@Test
public void testEquip() {
Fighter fighter = new Fighter("Hero");

Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);

Staff staff = new Staff("Staff", 10);
fighter.setWeapon(staff);

assertThat(fighter.getWeapon()).isInstanceOf(Staff.class); // 错误了
}

在最后,虽然代码感觉是setWeapon(Staff),但实际上只修改了父类的变量,并没有修改子类的变量,所以实际不生效,也不抛异常,但结果是错的。

当然,可以在父类限制setter为protected,但这样就限制了父类的API,极大的降低了灵活性,同时也违背了Liskov substitution principle,即一个父类必须要cast成子类才能使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Data
public abstract class Player {
@Setter(AccessLevel.PROTECTED)
private Weapon weapon;
}

@Test
public void testCastEquip() {
Fighter fighter = new Fighter("Hero");

Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);

Player player = fighter;
Staff staff = new Staff("Staff", 10);
player.setWeapon(staff); // 编译不过,但从API层面上应该开放可用
}

最后,如果规则增加一条:

  • 战士和法师都能装备匕首(dagger)

BOOM,之前写的强类型代码都废了,需要重构。

对象继承导致代码强依赖父类逻辑,违反开闭原则Open-Closed Principle(OCP)

开闭原则(OCP)规定“对象应该对于扩展开放,对于修改封闭“,继承虽然可以通过子类扩展新的行为,但因为子类可能直接依赖父类的实现,导致一个变更可能会影响所有对象。在这个例子里,如果增加任意一种类型的玩家、怪物或武器,或增加一种规则,都有可能需要修改从父类到子类的所有方法。

比如,如果要增加一个武器类型:狙击枪,能够无视所有防御一击必杀,需要修改的代码包括:

  • Weapon
  • Player和所有的子类(是否能装备某个武器的判断)
  • Monster和所有的子类(伤害计算逻辑)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 老的基础规则
if (Weapon instanceof Gun) { // 新的逻辑
this.setHealth(0);
}
}
}

public class Dragon extends Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
if (Weapon instanceof Gun) { // 新的逻辑
super.receiveDamageBy(weapon, player);
}
// 老的逻辑省略
}
}

在一个复杂的软件中为什么会建议“尽量”不要违背OCP?最核心的原因就是一个现有逻辑的变更可能会影响一些原有的代码,导致一些无法预见的影响。这个风险只能通过完整的单元测试覆盖来保障,但在实际开发中很难保障单测的覆盖率。OCP的原则能尽可能的规避这种风险,当新的行为只能通过新的字段/方法来实现时,老代码的行为自然不会变。

继承虽然能Open for extension,但很难做到Closed for modification。所以今天解决OCP的主要方法是通过Composition-over-inheritance,即通过组合来做到扩展性,而不是通过继承。

Player.attack(monster) 还是 Monster.receiveDamage(Weapon, Player)?

在这个例子里,其实业务规则的逻辑到底应该写在哪里是有异议的:当我们去看一个对象和另一个对象之间的交互时,到底是Player去攻击Monster,还是Monster被Player攻击?目前的代码主要将逻辑写在Monster的类中,主要考虑是Monster会受伤降低Health,但如果是Player拿着一把双刃剑会同时伤害自己呢?是不是发现写在Monster类里也有问题?代码写在哪里的原则是什么?

多对象行为类似,导致代码重复

当我们有不同的对象,但又有相同或类似的行为时,OOP会不可避免的导致代码的重复。在这个例子里,如果我们去增加一个“可移动”的行为,需要在Player和Monster类中都增加类似的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
csharp复制代码public abstract class Player {
int x;
int y;
void move(int targetX, int targetY) {
// logic
}
}

public abstract class Monster {
int x;
int y;
void move(int targetX, int targetY) {
// logic
}
}

一个可能的解法是有个通用的父类:

1
2
3
4
5
6
7
8
9
10
scala复制代码public abstract class Movable {
int x;
int y;
void move(int targetX, int targetY) {
// logic
}
}

public abstract class Player extends Movable;
public abstract class Monster extends Movable;

但如果再增加一个跳跃能力Jumpable呢?一个跑步能力Runnable呢?如果Player可以Move和Jump,Monster可以Move和Run,怎么处理继承关系?要知道Java(以及绝大部分语言)是不支持多父类继承的,所以只能通过重复代码来实现。

问题总结

在这个案例里虽然从直觉来看OOP的逻辑很简单,但如果你的业务比较复杂,未来会有大量的业务规则变更时,简单的OOP代码会在后期变成复杂的一团浆糊,逻辑分散在各地,缺少全局视角,各种规则的叠加会触发bug。有没有感觉似曾相识?对的,电商体系里的优惠、交易等链路经常会碰到类似的坑。而这类问题的核心本质在于:

  • 业务规则的归属到底是对象的“行为”还是独立的”规则对象“?
  • 业务规则之间的关系如何处理?
  • 通用“行为”应该如何复用和维护?

在讲DDD的解法前,我们先去看看一套游戏里最近比较火的架构设计,Entity-Component-System(ECS)是如何实现的。

Entity-Component-System(ECS)架构简介

ECS介绍

ECS架构模式是其实是一个很老的游戏架构设计,最早应该能追溯到《地牢围攻》的组件化设计,但最近因为Unity的加入而开始变得流行(比如《守望先锋》就是用的ECS)。要很快的理解ECS架构的价值,我们需要理解一个游戏代码的核心问题:

  • 性能:游戏必须要实现一个高的渲染率(60FPS),也就是说整个游戏世界需要在1/60s(大概16ms)内完整更新一次(包括物理引擎、游戏状态、渲染、AI等)。而在一个游戏中,通常有大量的(万级、十万级)游戏对象需要更新状态,除了渲染可以依赖GPU之外,其他的逻辑都需要由CPU完成,甚至绝大部分只能由单线程完成,导致绝大部分时间复杂场景下CPU(主要是内存到CPU的带宽)会成为瓶颈。在CPU单核速度几乎不再增加的时代,如何能让CPU处理的效率提升,是提升游戏性能的核心。
  • 代码组织:如同第一章讲的案例一样,当我们用传统OOP的模式进行游戏开发时,很容易就会陷入代码组织上的问题,最终导致代码难以阅读,维护和优化。
  • 可扩展性:这个跟上一条类似,但更多的是游戏的特性导致:需要快速更新,加入新的元素。一个游戏的架构需要能通过低代码、甚至0代码的方式增加游戏元素,从而通过快速更新而留住用户。如果每次变更都需要开发新的代码,测试,然后让用户重新下载客户端,可想而知这种游戏很难在现在的竞争环境下活下来。

而ECS架构能很好的解决上面的几个问题,ECS架构主要分为:

  • Entity:用来代表任何一个游戏对象,但是在ECS里一个Entity最重要的仅仅是他的EntityID,一个Entity里包含多个Component
  • Component:是真正的数据,ECS架构把一个个的实体对象拆分为更加细化的组件,比如位置、素材、状态等,也就是说一个Entity实际上只是一个Bag of Components。
  • System(或者ComponentSystem,组件系统):是真正的行为,一个游戏里可以有很多个不同的组件系统,每个组件系统都只负责一件事,可以依次处理大量的相同组件,而不需要去理解具体的Entity。所以一个ComponentSystem理论上可以有更加高效的组件处理效率,甚至可以实现并行处理,从而提升CPU利用率。

ECS的一些核心性能优化包括将同类型组件放在同一个Array中,然后Entity仅保留到各自组件的pointer,这样能更好的利用CPU的缓存,减少数据的加载成本,以及SIMD的优化等。

一个ECS案例的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class Entity {
public Vector position; // 此处Vector是一个Component, 指向的是MovementSystem.list里的一个
}

public class MovementSystem {
List<Vector> list;

// System的行为
public void update(float delta) {
for(Vector pos : list) { // 这个loop直接走了CPU缓存,性能很高,同时可以用SIMD优化
pos.x = pos.x + delta;
pos.y = pos.y + delta;
}
}
}

@Test
public void test() {
MovementSystem system = new MovementSystem();
system.list = new List<>() { new Vector(0, 0) };
Entity entity = new Entity(list.get(0));
system.update(0.1);
assertTrue(entity.position.x == 0.1);
}

由于本文不是讲解ECS架构的,感兴趣的同学可以搜索Entity-Component-System或者看看Unity的ECS文档等。

ECS架构分析

重新回来分析ECS,其实它的本源还是几个很老的概念:

组件化

在软件系统里,我们通常将复杂的大系统拆分为独立的组件,来降低复杂度。比如网页里通过前端组件化降低重复开发成本,微服务架构通过服务和数据库的拆分降低服务复杂度和系统影响面等。但是ECS架构把这个走到了极致,即每个对象内部都实现了组件化。通过将一个游戏对象的数据和行为拆分为多个组件和组件系统,能实现组件的高度复用性,降低重复开发成本。

行为抽离

这个在游戏系统里有个比较明显的优势。如果按照OOP的方式,一个游戏对象里可能会包括移动代码、战斗代码、渲染代码、AI代码等,如果都放在一个类里会很长,且很难去维护。通过将通用逻辑抽离出来为单独的System类,可以明显提升代码的可读性。另一个好处则是抽离了一些和对象代码无关的依赖,比如上文的delta,这个delta如果是放在Entity的update方法,则需要作为入参注入,而放在System里则可以统一管理。在第一章的有个问题,到底是应该Player.attack(monster) 还是 Monster.receiveDamage(Weapon, Player)。在ECS里这个问题就变的很简单,放在CombatSystem里就可以了。

数据驱动

即一个对象的行为不是写死的而是通过其参数决定,通过参数的动态修改,就可以快速改变一个对象的具体行为。在ECS的游戏架构里,通过给Entity注册相应的Component,以及改变Component的具体参数的组合,就可以改变一个对象的行为和玩法,比如创建一个水壶+爆炸属性就变成了“爆炸水壶”、给一个自行车加上风魔法就变成了飞车等。在有些Rougelike游戏中,可能有超过1万件不同类型、不同功能的物品,如果这些不同功能的物品都去单独写代码,可能永远都写不完,但是通过数据驱动+组件化架构,所有物品的配置最终就是一张表,修改也极其简单。这个也是组合胜于继承原则的一次体现。

ECS的缺陷

虽然ECS在游戏界已经开始崭露头角,我发现ECS架构目前还没有在哪个大型商业应用中被使用过。原因可能很多,包括ECS比较新大家还不了解、缺少商业成熟可用的框架、程序员们还不够能适应从写逻辑脚本到写组件的思维转变等,但我认为其最大的一个问题是ECS为了提升性能,强调了数据/状态(State)和行为(Behaivor)分离,并且为了降低GC成本,直接操作数据,走到了一个极端。而在商业应用中,数据的正确性、一致性和健壮性应该是最高的优先级,而性能只是锦上添花的东西,所以ECS很难在商业场景里带来特别大的好处。但这不代表我们不能借鉴一些ECS的突破性思维,包括组件化、跨对象行为的抽离、以及数据驱动模式,而这些在DDD里也能很好的用起来。

基于DDD架构的一种解法

领域对象

回到我们原来的问题域上面,我们从领域层拆分一下各种对象:

实体类

在DDD里,实体类包含ID和内部状态,在这个案例里实体类包含Player、Monster和Weapon。Weapon被设计成实体类是因为两把同名的Weapon应该可以同时存在,所以必须要有ID来区分,同时未来也可以预期Weapon会包含一些状态,比如升级、临时的buff、耐久等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class Player implements Movable {
private PlayerId id;
private String name;
private PlayerClass playerClass; // enum
private WeaponId weaponId; // (Note 1)
private Transform position = Transform.ORIGIN;
private Vector velocity = Vector.ZERO;
}

public class Monster implements Movable {
private MonsterId id;
private MonsterClass monsterClass; // enum
private Health health;
private Transform position = Transform.ORIGIN;
private Vector velocity = Vector.ZERO;
}

public class Weapon {
private WeaponId id;
private String name;
private WeaponType weaponType; // enum
private int damage;
private int damageType; // 0 - physical, 1 - fire, 2 - ice
}

在这个简单的案例里,我们可以利用enum的PlayerClass、MonsterClass来代替继承关系,后续也可以利用Type Object设计模式来做到数据驱动。

Note 1: 因为 Weapon 是实体类,但是Weapon能独立存在,Player不是聚合根,所以Player只能保存WeaponId,而不能直接指向Weapon。

值对象的组件化

在前面的ECS架构里,有个MovementSystem的概念是可以复用的,虽然不应该直接去操作Component或者继承通用的父类,但是可以通过接口的方式对领域对象做组件化处理:

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
java复制代码public interface Movable {
// 相当于组件
Transform getPosition();
Vector getVelocity();

// 行为
void moveTo(long x, long y);
void startMove(long velX, long velY);
void stopMove();
boolean isMoving();
}

// 具体实现
public class Player implements Movable {
public void moveTo(long x, long y) {
this.position = new Transform(x, y);
}

public void startMove(long velocityX, long velocityY) {
this.velocity = new Vector(velocityX, velocityY);
}

public void stopMove() {
this.velocity = Vector.ZERO;
}

@Override
public boolean isMoving() {
return this.velocity.getX() != 0 || this.velocity.getY() != 0;
}
}

@Value
public class Transform {
public static final Transform ORIGIN = new Transform(0, 0);
long x;
long y;
}

@Value
public class Vector {
public static final Vector ZERO = new Vector(0, 0);
long x;
long y;
}

注意两点:

  • Moveable的接口没有Setter。一个Entity的规则是不能直接变更其属性,必须通过Entity的方法去对内部状态做变更。这样能保证数据的一致性。
  • 抽象Movable的好处是如同ECS一样,一些特别通用的行为(如在大地图里移动)可以通过统一的System代码去处理,避免了重复劳动。

装备行为

因为我们已经不会用Player的子类来决定什么样的Weapon可以装备,所以这段逻辑应该被拆分到一个单独的类里。这种类在DDD里被叫做领域服务(Domain Service)。

1
2
3
java复制代码public interface EquipmentService {
boolean canEquip(Player player, Weapon weapon);
}

在DDD里,一个Entity不应该直接参考另一个Entity或服务,也就是说以下的代码是错误的:

1
2
3
4
5
6
7
8
typescript复制代码public class Player {
@Autowired
EquipmentService equipmentService; // BAD: 不可以直接依赖

public void equip(Weapon weapon) {
// ...
}
}

这里的问题是Entity只能保留自己的状态(或非聚合根的对象)。任何其他的对象,无论是否通过依赖注入的方式弄进来,都会破坏Entity的Invariance,并且还难以单测。

正确的引用方式是通过方法参数引入(Double Dispatch):

1
2
3
4
5
6
7
8
9
10
typescript复制代码public class Player {

public void equip(Weapon weapon, EquipmentService equipmentService) {
if (equipmentService.canEquip(this, weapon)) {
this.weaponId = weapon.getId();
} else {
throw new IllegalArgumentException("Cannot Equip: " + weapon);
}
}
}

在这里,无论是Weapon还是EquipmentService都是通过方法参数传入,确保不会污染Player的自有状态。

Double Dispatch是一个使用Domain Service经常会用到的方法,类似于调用反转。

然后在EquipmentService里实现相关的逻辑判断,这里我们用了另一个常用的Strategy(或者叫Policy)设计模式:

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
typescript复制代码public class EquipmentServiceImpl implements EquipmentService {
private EquipmentManager equipmentManager;

@Override
public boolean canEquip(Player player, Weapon weapon) {
return equipmentManager.canEquip(player, weapon);
}
}

// 策略优先级管理
public class EquipmentManager {
private static final List<EquipmentPolicy> POLICIES = new ArrayList<>();
static {
POLICIES.add(new FighterEquipmentPolicy());
POLICIES.add(new MageEquipmentPolicy());
POLICIES.add(new DragoonEquipmentPolicy());
POLICIES.add(new DefaultEquipmentPolicy());
}

public boolean canEquip(Player player, Weapon weapon) {
for (EquipmentPolicy policy : POLICIES) {
if (!policy.canApply(player, weapon)) {
continue;
}
return policy.canEquip(player, weapon);
}
return false;
}
}

// 策略案例
public class FighterEquipmentPolicy implements EquipmentPolicy {

@Override
public boolean canApply(Player player, Weapon weapon) {
return player.getPlayerClass() == PlayerClass.Fighter;
}

/**
* Fighter能装备Sword和Dagger
*/
@Override
public boolean canEquip(Player player, Weapon weapon) {
return weapon.getWeaponType() == WeaponType.Sword
|| weapon.getWeaponType() == WeaponType.Dagger;
}
}

// 其他策略省略,见源码

这样设计的最大好处是未来的规则增加只需要添加新的Policy类,而不需要去改变原有的类。

攻击行为

在上文中曾经有提起过,到底应该是Player.attack(Monster)还是Monster.receiveDamage(Weapon, Player)?在DDD里,因为这个行为可能会影响到Player、Monster和Weapon,所以属于跨实体的业务逻辑。在这种情况下需要通过一个第三方的领域服务(Domain Service)来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public interface CombatService {
void performAttack(Player player, Monster monster);
}

public class CombatServiceImpl implements CombatService {
private WeaponRepository weaponRepository;
private DamageManager damageManager;

@Override
public void performAttack(Player player, Monster monster) {
Weapon weapon = weaponRepository.find(player.getWeaponId());
int damage = damageManager.calculateDamage(player, weapon, monster);
if (damage > 0) {
monster.takeDamage(damage); // (Note 1)在领域服务里变更Monster
}
// 省略掉Player和Weapon可能受到的影响
}
}

同样的在这个案例里,可以通过Strategy设计模式来解决damage的计算问题:

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
csharp复制代码// 策略优先级管理
public class DamageManager {
private static final List<DamagePolicy> POLICIES = new ArrayList<>();
static {
POLICIES.add(new DragoonPolicy());
POLICIES.add(new DragonImmunityPolicy());
POLICIES.add(new OrcResistancePolicy());
POLICIES.add(new ElfResistancePolicy());
POLICIES.add(new PhysicalDamagePolicy());
POLICIES.add(new DefaultDamagePolicy());
}

public int calculateDamage(Player player, Weapon weapon, Monster monster) {
for (DamagePolicy policy : POLICIES) {
if (!policy.canApply(player, weapon, monster)) {
continue;
}
return policy.calculateDamage(player, weapon, monster);
}
return 0;
}
}

// 策略案例
public class DragoonPolicy implements DamagePolicy {
public int calculateDamage(Player player, Weapon weapon, Monster monster) {
return weapon.getDamage() * 2;
}
@Override
public boolean canApply(Player player, Weapon weapon, Monster monster) {
return player.getPlayerClass() == PlayerClass.Dragoon &&
monster.getMonsterClass() == MonsterClass.Dragon;
}
}

特别需要注意的是这里的CombatService领域服务和3.2的EquipmentService领域服务,虽然都是领域服务,但实质上有很大的差异。上文的EquipmentService更多的是提供只读策略,且只会影响单个对象,所以可以在Player.equip方法上通过参数注入。但是CombatService有可能会影响多个对象,所以不能直接通过参数注入的方式调用。

单元测试

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
scss复制代码@Test
@DisplayName("Dragoon attack dragon doubles damage")
public void testDragoonSpecial() {
// Given
Player dragoon = playerFactory.createPlayer(PlayerClass.Dragoon, "Dart");
Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "Soul Eater", 60);
((WeaponRepositoryMock)weaponRepository).cache(sword);
dragoon.equip(sword, equipmentService);
Monster dragon = monsterFactory.createMonster(MonsterClass.Dragon, 100);

// When
combatService.performAttack(dragoon, dragon);

// Then
assertThat(dragon.getHealth()).isEqualTo(Health.ZERO);
assertThat(dragon.isAlive()).isFalse();
}

@Test
@DisplayName("Orc should receive half damage from physical weapons")
public void testFighterOrc() {
// Given
Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "My Sword");
((WeaponRepositoryMock)weaponRepository).cache(sword);
fighter.equip(sword, equipmentService);
Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);

// When
combatService.performAttack(fighter, orc);

// Then
assertThat(orc.getHealth()).isEqualTo(Health.of(100 - 10 / 2));
}

具体的代码比较简单,解释省略

移动系统

最后还有一种Domain Service,通过组件化,我们其实可以实现ECS一样的System,来降低一些重复性的代码:

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
java复制代码public class MovementSystem {

private static final long X_FENCE_MIN = -100;
private static final long X_FENCE_MAX = 100;
private static final long Y_FENCE_MIN = -100;
private static final long Y_FENCE_MAX = 100;

private List<Movable> entities = new ArrayList<>();

public void register(Movable movable) {
entities.add(movable);
}

public void update() {
for (Movable entity : entities) {
if (!entity.isMoving()) {
continue;
}

Transform old = entity.getPosition();
Vector vel = entity.getVelocity();
long newX = Math.max(Math.min(old.getX() + vel.getX(), X_FENCE_MAX), X_FENCE_MIN);
long newY = Math.max(Math.min(old.getY() + vel.getY(), Y_FENCE_MAX), Y_FENCE_MIN);
entity.moveTo(newX, newY);
}
}
}

单测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scss复制代码@Test
@DisplayName("Moving player and monster at the same time")
public void testMovement() {
// Given
Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
fighter.moveTo(2, 5);
fighter.startMove(1, 0);

Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);
orc.moveTo(10, 5);
orc.startMove(-1, 0);

movementSystem.register(fighter);
movementSystem.register(orc);

// When
movementSystem.update();

// Then
assertThat(fighter.getPosition().getX()).isEqualTo(2 + 1);
assertThat(orc.getPosition().getX()).isEqualTo(10 - 1);
}

在这里MovementSystem就是一个相对独立的Domain Service,通过对Movable的组件化,实现了类似代码的集中化、以及一些通用依赖/配置的中心化(如X、Y边界等)。

DDD领域层的一些设计规范

上面我主要针对同一个例子对比了OOP、ECS和DDD的3种实现,比较如下:

  • 基于继承关系的OOP代码:OOP的代码最好写,也最容易理解,所有的规则代码都写在对象里,但是当领域规则变得越来越复杂时,其结构会限制它的发展。新的规则有可能会导致代码的整体重构。
  • 基于组件化的ECS代码:ECS代码有最高的灵活性、可复用性、及性能,但极具弱化了实体类的内聚,所有的业务逻辑都写在了服务里,会导致业务的一致性无法保障,对商业系统会有较大的影响。
  • 基于领域对象 + 领域服务的DDD架构:DDD的规则其实最复杂,同时要考虑到实体类的内聚和保证不变性(Invariants),也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高。

所以下面,我会尽量通过一些设计规范,来降低DDD领域层的设计成本。领域层里的Value Object(Domain Primitive)设计规范请参考我之前的文章。

实体类(Entity)

大多数DDD架构的核心都是实体类,实体类包含了一个领域里的状态、以及对状态的直接操作。Entity最重要的设计原则是保证实体的不变性(Invariants),也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。所以几个设计原则如下:

创建即一致

在贫血模型里,通常见到的代码是一个模型通过手动new出来之后,由调用方一个参数一个参数的赋值,这就很容易产生遗漏,导致实体状态不一致。所以DDD里实体创建的方法有两种:

constructor参数要包含所有必要属性,或者在constructor里有合理的默认值。

比如,账号的创建:

1
2
3
4
5
6
7
8
9
10
11
typescript复制代码public class Account {
private String accountNumber;
private Long amount;
}

@Test
public void test() {
Account account = new Account();
account.setAmount(100L);
TransferService.transfer(account); // 报错了,因为Account缺少必要的AccountNumber
}

如果缺少一个强校验的constructor,就无法保障创建的实体的一致性。所以需要增加一个强校验的constructor:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class Account {
public Account(String accountNumber, Long amount) {
assert StringUtils.isNotBlank(accountNumber);
assert amount >= 0;
this.accountNumber = accountNumber;
this.amount = amount;
}
}

@Test
public void test() {
Account account = new Account("123", 100L); // 确保对象的有效性
}

使用Factory模式来降低调用方复杂度

另一种方法是通过Factory模式来创建对象,降低一些重复性的入参。比如:

1
2
3
4
5
6
typescript复制代码public class WeaponFactory {
public Weapon createWeaponFromPrototype(WeaponPrototype proto, String newName) {
Weapon weapon = new Weapon(null, newName, proto.getWeaponType(), proto.getDamage(), proto.getDamageType());
return weapon;
}
}

通过传入一个已经存在的Prototype,可以快速的创建新的实体。还有一些其他的如Builder等设计模式就不一一指出了。

尽量避免public setter

一个最容易导致不一致性的原因是实体暴露了public的setter方法,特别是set单一参数会导致状态不一致的情况。比如,一个订单可能包含订单状态(下单、已支付、已发货、已收货)、支付单、物流单等子实体,如果一个调用方能随意去set订单状态,就有可能导致订单状态和子实体匹配不上,导致业务流程走不通的情况。所以在实体里,需要通过行为方法来修改内部状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typescript复制代码@Data @Setter(AccessLevel.PRIVATE) // 确保不生成public setter
public class Order {
private int status; // 0 - 创建,1 - 支付,2 - 发货,3 - 收货
private Payment payment; // 支付单
private Shipping shipping; // 物流单

public void pay(Long userId, Long amount) {
if (status != 0) {
throw new IllegalStateException();
}
this.status = 1;
this.payment = new Payment(userId, amount);
}

public void ship(String trackingNumber) {
if (status != 1) {
throw new IllegalStateException();
}
this.status = 2;
this.shipping = new Shipping(trackingNumber);
}
}

【建议】在有些简单场景里,有时候确实可以比较随意的设置一个值而不会导致不一致性,也建议将方法名重新写为比较“行为化”的命名,会增强其语意。比如setPosition(x, y)可以叫做moveTo(x, y),setAddress可以叫做assignAddress等。

通过聚合根保证主子实体的一致性

在稍微复杂一点的领域里,通常主实体会包含子实体,这时候主实体就需要起到聚合根的作用,即:

  • 子实体不能单独存在,只能通过聚合根的方法获取到。任何外部的对象都不能直接保留子实体的引用
  • 子实体没有独立的Repository,不可以单独保存和取出,必须要通过聚合根的Repository实例化
  • 子实体可以单独修改自身状态,但是多个子实体之间的状态一致性需要聚合根来保障

常见的电商域中聚合的案例如主子订单模型、商品/SKU模型、跨子订单优惠、跨店优惠模型等。很多聚合根和Repository的设计规范在我前面一篇关于Repository的文章中已经详细解释过,可以拿来参考。

不可以强依赖其他聚合根实体或领域服务

一个实体的原则是高内聚、低耦合,即一个实体类不能直接在内部直接依赖一个外部的实体或服务。这个原则和绝大多数ORM框架都有比较严重的冲突,所以是一个在开发过程中需要特别注意的。这个原则的必要原因包括:对外部对象的依赖性会直接导致实体无法被单测;以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。

所以,正确的对外部依赖的方法有两种:

1 只保存外部实体的ID:这里我再次强烈建议使用强类型的ID对象,而不是Long型ID。强类型的ID对象不单单能自我包含验证代码,保证ID值的正确性,同时还能确保各种入参不会因为参数顺序变化而出bug。具体可以参考我的Domain Primitive文章。

2 针对于“无副作用”的外部依赖,通过方法入参的方式传入。比如上文中的equip(Weapon,EquipmentService)方法。

如果方法对外部依赖有副作用,不能通过方法入参的方式,只能通过Domain Service解决,见下文。

任何实体的行为只能直接影响到本实体(和其子实体)

这个原则更多是一个确保代码可读性、可理解的原则,即任何实体的行为不能有“直接”的”副作用“,即直接修改其他的实体类。这么做的好处是代码读下来不会产生意外。

另一个遵守的原因是可以降低未知的变更的风险。在一个系统里一个实体对象的所有变更操作应该都是预期内的,如果一个实体能随意被外部直接修改的话,会增加代码bug的风险。

领域服务(Domain Service)

在上文讲到,领域服务其实也分很多种,在这里根据上文总结出来三种常见的:

单对象策略型

这种领域对象主要面向的是单个实体对象的变更,但涉及到多个领域对象或外部依赖的一些规则。在上文中,EquipmentService即为此类:

  • 变更的对象是Player的参数
  • 读取的是Player和Weapon的数据,可能还包括从外部读取一些数据

在这种类型下,实体应该通过方法入参的方式传入这种领域服务,然后通过Double Dispatch来反转调用领域服务的方法,比如:

1
2
3
javascript复制代码Player.equip(Weapon, EquipmentService) {
EquipmentService.canEquip(this, Weapon);
}

为什么这种情况下不能先调用领域服务,再调用实体对象的方法,从而减少实体对领域服务的入参型依赖呢?比如,下面这个方法是错误的:

1
2
3
4
ini复制代码boolean canEquip = EquipmentService.canEquip(Player, Weapon);
if (canEquip) {
Player.equip(Weapon); // ❌,这种方法不可行,因为这个方法有不一致的可能性
}

其错误的主要原因是缺少了领域服务入参会导致方法有可能产生不一致的情况。

跨对象事务型

当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。

在上文里,虽然以下的代码虽然可以跑到通,但是是不建议的:

1
2
3
4
5
typescript复制代码public class Player {
void attack(Monster, CombatService) {
CombatService.performAttack(this, Monster); // ❌,不要这么写,会导致副作用
}
}

而我们真实调用应该直接调用CombatService的方法:

1
2
3
4
csharp复制代码public void test() {
//...
combatService.performAttack(mage, orc);
}

这个原则也映射了4.1.5 的原则,即Player.attack会直接影响到Monster,但这个调用Monster又没有感知。

通用组件型

这种类型的领域服务更像ECS里的System,提供了组件化的行为,但本身又不直接绑死在一种实体类上。具体案例可以参考上文中的MovementSystem实现。

策略对象(Domain Policy)

Policy或者Strategy设计模式是一个通用的设计模式,但是在DDD架构中会经常出现,其核心就是封装领域规则。

一个Policy是一个无状态的单例对象,通常需要至少2个方法:canApply 和 一个业务方法。其中,canApply方法用来判断一个Policy是否适用于当前的上下文,如果适用则调用方会去触发业务方法。通常,为了降低一个Policy的可测试性和复杂度,Policy不应该直接操作对象,而是通过返回计算后的值,在Domain Service里对对象进行操作。

在上文案例里,DamagePolicy只负责计算应该受到的伤害,而不是直接对Monster造成伤害。这样除了可测试外,还为未来的多Policy叠加计算做了准备。

除了本文里静态注入多个Policy以及手动排优先级之外,在日常开发中经常能见到通过Java的SPI机制或类SPI机制注册Policy,以及通过不同的Priority方案对Policy进行排序,在这里就不作太多的展开了。

加餐 - 副作用的处理方法 - 领域事件

在上文中,有一种类型的领域规则被我刻意忽略了,那就是”副作用“。一般的副作用发生在核心领域模型状态变更后,同步或者异步对另一个对象的影响或行为。在这个案例里,我们可以增加一个副作用规则:

  • 当Monster的生命值降为0后,给Player奖励经验值

这种问题有很多种解法,比如直接把副作用写在CombatService里:

1
2
3
4
5
6
7
8
9
typescript复制代码public class CombatService {
public void performAttack(Player player, Monster monster) {
// ...
monster.takeDamage(damage);
if (!monster.isAlive()) {
player.receiveExp(10); // 收到经验
}
}
}

但是这样写的问题是:很快CombatService的代码就会变得很复杂,比如我们再加一个副作用:

  • 当Player的exp达到100时,升一级

这时我们的代码就会变成:

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码public class CombatService {
public void performAttack(Player player, Monster monster) {
// ...
monster.takeDamage(damage);
if (!monster.isAlive()) {
player.receiveExp(10); // 收到经验
if (player.canLevelUp()) {
player.levelUp(); // 升级
}
}
}
}

如果再加上“升级后奖励XXX”呢?“更新XXX排行”呢?依此类推,后续这种代码将无法维护。所以我们需要介绍一下领域层最后一个概念:领域事件(Domain Event)。

领域事件介绍

领域事件是一个在领域里发生了某些事后,希望领域里其他对象能够感知到的通知机制。在上面的案例里,代码之所以会越来越复杂,其根本的原因是反应代码(比如升级)直接和上面的事件触发条件(比如收到经验)直接耦合,而且这种耦合性是隐性的。领域事件的好处就是将这种隐性的副作用“显性化”,通过一个显性的事件,将事件触发和事件处理解耦,最终起到代码更清晰、扩展性更好的目的。

所以,领域事件是在DDD里,比较推荐使用的跨实体“副作用”传播机制。

领域事件实现

和消息队列中间件不同的是,领域事件通常是立即执行的、在同一个进程内、可能是同步或异步。我们可以通过一个EventBus来实现进程内的通知机制,简单实现如下:

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
typescript复制代码// 实现者:瑜进 2019/11/28
public class EventBus {

// 注册器
@Getter
private final EventRegistry invokerRegistry = new EventRegistry(this);

// 事件分发器
private final EventDispatcher dispatcher = new EventDispatcher(ExecutorFactory.getDirectExecutor());

// 异步事件分发器
private final EventDispatcher asyncDispatcher = new EventDispatcher(ExecutorFactory.getThreadPoolExecutor());

// 事件分发
public boolean dispatch(Event event) {
return dispatch(event, dispatcher);
}

// 异步事件分发
public boolean dispatchAsync(Event event) {
return dispatch(event, asyncDispatcher);
}

// 内部事件分发
private boolean dispatch(Event event, EventDispatcher dispatcher) {
checkEvent(event);
// 1.获取事件数组
Set<Invoker> invokers = invokerRegistry.getInvokers(event);
// 2.一个事件可以被监听N次,不关心调用结果
dispatcher.dispatch(event, invokers);
return true;
}

// 事件总线注册
public void register(Object listener) {
if (listener == null) {
throw new IllegalArgumentException("listener can not be null!");
}
invokerRegistry.register(listener);
}

private void checkEvent(Event event) {
if (event == null) {
throw new IllegalArgumentException("event");
}
if (!(event instanceof Event)) {
throw new IllegalArgumentException("Event type must by " + Event.class);
}
}
}

调用方式:

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
csharp复制代码public class LevelUpEvent implements Event {
private Player player;
}

public class LevelUpHandler {
public void handle(Player player);
}

public class Player {
public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
EventBus.dispatch(event);
this.exp = 0;
}
}
}
@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);
assertThat(player.getLevel()).equals(2);
}

目前领域事件的缺陷和展望

从上面代码可以看出来,领域事件的很好的实施依赖EventBus、Dispatcher、Invoker这些属于框架级别的支持。同时另一个问题是因为Entity不能直接依赖外部对象,所以EventBus目前只能是一个全局的Singleton,而大家都应该知道全局Singleton对象很难被单测。这就容易导致Entity对象无法被很容易的被完整单测覆盖全。

另一种解法是侵入Entity,对每个Entity增加一个List:

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
csharp复制代码public class Player {
List<Event> events;

public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
events.add(event); // 把event加进去
this.exp = 0;
}
}
}

@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);

for(Event event: player.getEvents()) { // 在这里显性的dispatch事件
EventBus.dispatch(event);
}

assertThat(player.getLevel()).equals(2);
}

但是能看出来这种解法不但会侵入实体本身,同时也需要比较啰嗦的显性在调用方dispatch事件,也不是一个好的解决方案。

也许未来会有一个框架能让我们既不依赖全局Singleton,也不需要显性去处理事件,但目前的方案基本都有或多或少的缺陷,大家在使用中可以注意。

总结

在真实的业务逻辑里,我们的领域模型或多或少的都有一定的“特殊性”,如果100%的要符合DDD规范可能会比较累,所以最主要的是梳理一个对象行为的影响面,然后作出设计决策,即:
是仅影响单一对象还是多个对象,

  • 规则未来的拓展性、灵活性,
  • 性能要求,
  • 副作用的处理,等等

当然,很多时候一个好的设计是多种因素的取舍,需要大家有一定的积累,真正理解每个架构背后的逻辑和优缺点。一个好的架构师不是有一个正确答案,而是能从多个方案中选出一个最平衡的方案。

求案例,求简历

最后,求看到这里的读者能给我一些输入,我后续想写一下如何让一个程序员不要总写CRUD代码的主题,希望能从一些明显CRUD的代码案例中,能通过DDD架构的设计产出可扩展、可维护的架构来。目前我还缺少一些真实案例,希望读者同学们能将一些案例通过邮件的方式给我,包括(脱了敏的)代码和业务描述等。我保证会尽可能的回复每一个案例,并且会把一些经典案例放在文章中。我的邮箱:guangmiao.lgm@alibaba-inc.com,也可以加我的钉钉号:luangm(殷浩)

同时,我们团队也在持续招聘。我负责淘系的行业和导购团队,我们团队负责天猫和淘宝的四大行业(服饰、快消、消电、家装)的日常业务需求和创新业务(3D/AR、搭配、定制、尺码导购等)、前台场(iFashion、全球购、亲宝贝、美妆学院、酷玩星球等),以及手淘的横向导购场(有好货、好店、大赏、品牌导购等),总DAU(日均访问用户数)大概3000W左右。今年我们团队的核心目标是去重构行业的导购体验,给消费者带来不一样的、更人性化、有更强交互性、更能体现每个细分行业差异化特性、文化的导购场。欢迎感兴趣的同学加盟。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

GoFrame v115 发布,企业级 Go 开发框架 G

发表于 2020-12-31

大家好呀!自上次发布到现在,从初秋到深冬,我们也完全冇闲着哟,我们这次带来了爱心满满的GoFrame v1.15版本。此外,还有两件事:

  • GoFrame被OSC开源中国评选为了2020年度TOP30的开源项目:www.oschina.net/question/29… ,感谢大家的认可与支持!同时GoFrame也是Gitee GVP
    最有价值项目。
  • GoFrame官网船新改版,里挤需体验三番钟,里造会干我一样,爱上介款框架:goframe.org 。感谢Atlassian的赞助,提供的全产品线正版授权码!

经过多年的潜心修炼和稳定发展,GoFrame已经逐步成长为了一款企业级的Golang基础开发框架,她提供了项目开发规范、开发工具链、完善的基础模块、丰富的开发文档、高代码质量以及活跃的社区。为保证框架质量,我们为各个组件进行了大量的单元测试以保证逻辑的正确(2534例测试单元,9097项测试断言),同时维护了高质量的文档,至今,已有很多的大型/中小型互联网公司在生产环境使用GoFrame。

开源不易,有你们的理解和支持,幸福满满!感谢所有参与项目开发的小伙伴们,爱你们!GF, YES!

GoFrame

GF(Go Frame)是一款模块化、高性能、企业级的Go基础开发框架。实现了比较完善的基础设施建设以及开发工具链,提供了常用的基础开发模块,如:缓存、日志、队列、数组、集合、容器、定时器、命令行、内存锁、配置管理、资源管理、数据校验、定时任务、数据库ORM、TCP/UDP组件、进程管理/通信等等。并提供了Web服务开发的系列核心组件,如:Router、Cookie、Session、Middleware、服务注册、模板引擎等等,支持热重启、热更新、域名绑定、TLS/HTTPS、Rewrite等特性。

如果您初识Go语言,您可以将GoFrame类似于PHP中的Laravel,
Java中的SpringBoot或者Python中的Django。

特点

  • 模块化、松耦合设计;
  • 模块丰富、开箱即用;
  • 简便易用、易于维护;
  • 高代码质量、高单元测试覆盖率;
  • 社区活跃,大牛谦逊低调脾气好;
  • 详尽的开发文档及示例;
  • 完善的本地中文化支持;
  • 设计为团队及企业使用;

Change Logs

  1. ghttp
    • 改建HTTPClient的GET请求相关方法,当传递参数时不再作为Body参数提交,而是自动构造为QueryString提交。以保证与其他服务端的兼容能力。
    • 请求对象Request增加默认值设置特性:
    • 增加Request.SetCtx方法,用于自定义上下文变量,常用于中间件/拦截器中:
    • 模板解析中增加Request变量,用于获得客户端提交的请求参数,无论是QueryString/Form类型参数:
    • Cookie功能改进,如何设置与Session有效期保持一致的Cookie,请参考:Cookie#Cookie会话过期
    • 分组路由注册增加ALLMap方法,用于批量注册路由:分组路由#批量注册
    • 新增CRSF插件介绍文档:
    • 其他一些功能及细节改进。
  2. gdb
    • 增加Ctx方法,用于异步IO控制或传递自定义的上下文信息,特别是链路跟踪信息:
    • 增加Raw类型,用于原始SQL语句嵌入,该语句将会直接提交到底层数据库驱动,不会做任何处理:写入保存#RawSQL语句嵌入、更新删除#RawSQL语句嵌入
    • 改进Fields/Fields/Data方法,增加对输入map/struct参数与数据表字段的自动映射检测、过滤:
    • 增加InsertedAt/UpdatedAt/DeletedAt字段名称的配置,增加TimeMaintainDisabled配置可关闭时间填充及软删除特性:
    • 增加Counter更新特性,用于字段的增加/减少操作:更新删除#Counter更新特性
    • 改进ORM时区处理,详情请参考章节:
    • 其他关于性能和易用性的细节改进。
    • 一些细节问题改进。
    • 完善单元测试。
  3. gerror
    1. 增加Newf/NewSkipf方法创建错误对象:
    2. 增加对错误码特性的支持:
    3. 完善单元测试。
  4. gvalid
    • 增加phone-loose宽松手机号校验规则,只要满足 13/14/15/16/17/18/19开头的11位数字都可以通过验证。
    • 返回校验错误实现了gerror的Current() error接口,因此可以使用gerror.Current方法来获取当前第一条校验错误:
    • 其他细节改进。
    • 单元测试完善。
  5. gvar
    • 增加IsNil/IsEmpty方法,判断数据是否为nil/空。
    • 增加IsInt/IsUint/IsFloat/IsSlice/IsMap/IsStruct常用类型判断方法。
    • 标注废弃StructDeep/StructDeep方法,直接使用Struct/Structs即可。
    • 完善单元测试。
  6. ghtml
    • 增加SpecialCharsMapOrStruct方法,用于自动转换map/struct键值/属性中的HTML代码,防止XSS。
  7. gjson
    • 标记废弃To*转换方法,例如:ToStruct替换为Struct方法。
    • 一些细节问题改进。
    • 单元测试完善。
  8. internal
    • 改进并完善internal/empty包的空值判断。
    • 由于性能问题 github.com/gogf/gf/iss… ,临时删除了internal/json包中对第三方包 github.com/json-iterat… 的依赖,还原为标准库encoding/json。
    • 改进internal/structs包,由于该包在struct转换特性中使用比较频繁,因此去掉第三方包 github.com/gqcn/struct… 的依赖,简化了反射处理逻辑,提高了性能以及易用性,提高了长期维护性。
    • internal/utils包增加RemoveSymbols方法,用于删除字符串中的特殊字母。改进EqualFoldWithoutChars方法,去掉对字符串中字符串的正则过滤功能,极大提高了方法性能。不要小看这两个小函数,性能的点滴改进能提高框架中其他涉及到复杂类型转换模块的性能。internal包虽然不直接对外暴露方法,但是却影响着框架中的一些核心组件性能。
  9. gcfg
    • 改进单例名称的配置对象获取,增加自动检测文件类型功能:配置管理-单例对象#自动检索特性
    • 其他一些细节改进。
  10. gcmd
    • 改进默认参数解析获取方式:
    • 增加GetWithEnv方法,当命令行中不存在指定参数时,同时从环境变量中获取:
  11. genv
    • 增加SetMap方法,用于批量设置环境变量。
    • 增加GetWithCmd方法,当环境变量中不存在指定参数时,同时从命令行参数中获取:
  12. gfile
    • 标记废弃ReadByteLines方法,新增ReadLinesBytes方法。
    • 调整ReadLines/ReadLinesBytes方法回调函数定义,增加error返回。
  13. glog
    • 改进滚动更新功能。
    • 其他一些细节改进。
  14. gsession
    • 增加SetMap方法,用于批量设置键值对数据。
  15. gtimer
    • 常量名称改进,统一采用大驼峰方式。
  16. gview
    • 增加内置模板函数map,用于将参数转换为map[string]interface{}类型。
    • 增加内置模板函数maps,用于将参数转换为[]map[string]interface{}类型。
    • 增加内置模板函数json,用于将参数转换为JSON字符串类型。
    • 文档更新:
    • 其他一些细节改进。
  17. gconv
    • 性能改进。
    • 功能改进(细节改进有点多,实在不想写得太详细)。
    • 代码更加健壮。
    • 单元测试完善。
  18. gutil
    • 增加Keys方法,用于获取map/struct的键名/属性名称,构造成数组返回。
    • 增加Values方法,用于获取map/struct的键值/属性值,构造成数组返回。
    • 增加MapToSlice方法,例如:{"K1": "v1", "K2": "v2"} => ["K1", "v1", "K2", "v2"]
    • 增加StructToSlice方法,例如:{"K1": "v1", "K2": "v2"} => ["K1", "v1", "K2", "v2"]
    • 增加SliceToMap方法,例如: ["K1", "v1", "K2", "v2"] => {"K1": "v1", "K2": "v2"}
    • 单元测试完善。
    • 其他细节改进。

Bug Fix

  1. 修复garray/gmap/gtree的Clone方法并发安全判断问题。
  2. 修复当设置了过期方法,但gpool在元素项过期时没有自动调用过期方法处理的问题。
  3. 修复gfile.ReadLInes/ReadLineBytes在数据量大时的读取重复问题。
  4. 其他一些错误修复。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

解析面试难点Stringintern() 一字符串常量池

发表于 2020-12-30

网上对于intern方法的讨论很多是不对或者不严谨的,于是我参考了大量官方资料总结了该知识点,希望可以讲清楚。这个面试考点主要考察JVM的内存模型、对String类理解的深度以及字符串常量池的了解。

一.字符串常量池介绍

在JAVA语言中有8种基本类型和一种比较特殊的类型String,其实String是引用类型,把它们归为一类是因为这些类型在运行过程中为了速度更快,更节省内存,都提供了一种常量池的概念。常量池就类似一个JAVA系统级别提供的缓存。8种基本类型的常量池都是系统协调的,String类型的常量池(String类对应的叫作字符串常量池)比较特殊。jdk6中字符串常量池在永久代,从jdk7开始放到了堆空间中(是堆中又划分了一块区域,注意这个细节!)。

它的主要使用方法(或者说如何保证变量指向的是字符串常量池中的数据)有两种:

1.直接使用双引号声明出来的String对象会直接存储在常量池中。

String s = "abc";

2.如果不是用双引号声明的String对象,可以使用String提供的intern方法,这个下面会解释,先记住以下结论。

字符串常量池存的东西有两种情况:

1.字符串对象,比如上面的“abc”

2.堆对象的引用。

二.intern方法介绍

前置知识:

1.字符串拼接的时候只要其中有一个是变量(非final修饰),拼接出来的对象就在堆中,相当于在堆空间中new String(“XXX”)(不是在字符串常量池中)。变量拼接的原理是StringBuilder调用append方法然后再调用toString方法。

2.new String(“ab”)会创建几个对象?

两个对象,一个是在堆空间中,一个在字符串常量池中(字节码指令ldc)。有兴趣的小伙伴可以去看编译后的字节码文件。

3.new String(“a”)+new String(“b”)呢?

六个对象。有兴趣的小伙伴可以去看编译后的字节码文件。
通过看字节码文件可知:

参考自官方文档:在jdk8中,它的作用是如果字符串常量池已经包含一个等于(通过equals方法比较)此String对象的字符串,则返回字符串常量池中这个字符串的引用, 否则将当前String对象的引用地址(堆中对象的引用地址)添加(或者叫复制)到字符串常量池中并返回,这么做是为了节约堆空间,毕竟都在堆中。

例如:
String s = new String("s").intern();

解读该行代码:new String(“s”)代表创建了两个对象,一个是在堆空间中,一个在字符串常量池中。new String(“s”).intern()则代表返回字符串常量池中的那个对象的引用赋给s变量。

三.面试题解析

面试题如下,判断输出是true还是false。这里直接附上解析了,后面还有练习题。

1
2
3
4
5
6
7
8
9
10
11
ini复制代码String s = new String("1");
s.intern();//调用此方法之前,字符串常量池中已经存在了“1”
String s2 = "1";
System.out.println(s == s2);//jdk6:false jdk7/8:false

String s3 = new String("1") + new String("1");//s3变量记录的地址为:new String("11"),堆中。
//执行完上一行代码以后,字符串常量池中,不存在“11”!!!
s3.intern();//在字符串常量池中生成“11”。jdk6中,永久代中创建了一个新的对象“11”,也就有了新的地址。
//jdk7/jdk8中,此时常量中并没有创建“11”,而是添加一个指向堆空间中new String("11")的地址
String s4 = "11";//s4变量记录的地址:使用的是上一行代码执行时,在字符串常量池中生成的“11”的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true

示意图:

四.String的intern方法总结

五.巩固练习

1
2
3
4
5
vbnet复制代码String s = new String("a") + new String("b");//new String("ab")
String s2 = s.intern();//jdk6中,在串池中创建一个字符串“ab”
//jdk7/8中,串池中没有创建字符串“ab”,而是创建一个引用,指向new String("ab")
System.out.println(s2=="ab");//jdk6:true jdk8:true
System.out.println(s=="ab");//jdk6:false jdk8:true
1
2
3
4
ini复制代码String s1 = new String("ab");//会在字符串常量池中生成"ab"
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2);//jdk8:false

参考资料:

B站《尚硅谷JVM教程》

美团技术:tech.meituan.com/2014/03/06/…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

百万数据量关联查询的索引优化

发表于 2020-12-30

问题场景:简简单单数据量百万的单表,外关联几个十万级别的表,出现慢查询的的问题

某SpringBoot项目中,使用Mysql数据库,某列表关联查询的表众多,且本身数据量较大(百万级),首先想到的就是做数据查询索引优化

需要把握的几个要素

在关联查询中,需要JOIN的字段,数据类型必须绝对一致

如果数据类型不一致,将导致索引失效,全表扫描,这也是EXPLAIN检查执行计划后,出现all需要检查的一项内容

超过三个表禁止JOIN

效率下降太多了

多表关联时保证被关联的字段有索引

完成以上几点后,使用explain检查执行计划

保证除了主表没有全表扫描,这样的列表一般查询性能就不会太差了

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

php使用单元测试phpunit

发表于 2020-12-30

单元测试对项目维护是很重要滴,一个好的项目先写单元测试,再写业务代码。

使用步骤:

1、下载phpunit。

我是在thinkphp6.0的框架中,

1
bash复制代码composer require phpunit/phpunit

Laravel框架则是已经安装好了。

下载好后进入框架目录(本地环境,没有配置全局变量的情况下):vendor/bin

终端执行:

1
bash复制代码./phpunit --version

成功的看到phpunit版本信息

2、使用phpunit

在app目录下创建test目录内,新建文件 FirstTest.php

1
2
3
4
5
6
7
8
9
10
11
12
php复制代码<?php
namespace app\test;
require_once '../../vendor/autoload.php';
use PHPUnit\Framework\TestCase;

class FirstTest extends TestCase
{ public function testTure()
{
$a = [];
$this->assertNull(100);
}
}

在vendor/bin目录中终端执行这个php文件:

1
bash复制代码./phpunit ../../app/test/FirstTest

可以看到测试结果。

3、讲道理,每个接口都应该写单元测试。

在对接口单元测试的时候;需要引入该文件,new这个对象调用方法。

thinkphp6:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php复制代码<?php
namespace app\test;
require_once '../../vendor/autoload.php'
use app\admin\controller\Index;
use PHPUnit\Framework\TestCase;

class FirstTest extends TestCase
{
public function testTure()
{
$obj = new Index;
$this->assertEquals(0,$obj->index(2,3);
}
}

Laravel(比较清晰):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
php复制代码<?phpnamespace 
Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Illuminate\Support\Facades\Storage;
class ExampleTest extends TestCase{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$response = $this->json('POST','/api/loginConfirm',['loginInfo'=>['username'=>'chen', 'password'=>'peng']]);
$response->assertStatus(200)->assertJson(['code'=>200]);
}

public function testLogoutTest()
{
$response = $this->json('PUT','/api/logout');
$response->assertJson(['code'=>200]);
}
}

tests目录下有测试目录和文件

测试语法看文档。

learnku.com/docs/larave…

断言核心方法

assertArrayEquals(expecteds, actuals)

查看两个数组是否相等。

assertEquals(expected, actual)

查看两个对象是否相等。类似于字符串比较使用的equals()方法

assertNotEquals(first, second)

查看两个对象是否不相等。

assertNull(object)

查看对象是否为空。

assertNotNull(object)

查看对象是否不为空。

assertSame(expected, actual)

查看两个对象的引用是否相等。类似于使用“==”比较两个对象

assertNotSame(unexpected, actual)

查看两个对象的引用是否不相等。类似于使用“!=”比较两个对象

assertTrue(condition)

查看运行结果是否为true。

assertFalse(condition)

查看运行结果是否为false。

assertThat(actual, matcher)

查看实际值是否满足指定的条件

fail()

让测试失败

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JDK18新特性(二):为什么要关注JDK18 代码更少

发表于 2020-12-30

在这里插入图片描述

自1996年JDK1.0(Java1.0)发布以来,Java已经受到了学生、程序员、整个软件行业人员等一大批活跃用户的欢迎。这一语言极富活力,不断被用在大大小小的项目里。从Java1.1(1997年) 一直到Java 7(2011年),Java通过增加新功能,不断得到良好的升级。Java 8则是在2014年3月发布的……

版本的不断更新、升级,无非是对bug的修复、新功能的增加、优化等,在JDK版本中,JDK1.8变得备受关注,也成了各大公司面试中常常被问及的话题。

代码更少、更简洁

之所以备受关注的最原因是,JDK1.8所做的改变,在许多方面比Java历史上任何一次改变都深远。而且好消息是,这些改变会让你编起程来更容易,用不着再写类似下面这种啰嗦的程序了。(对peopleList中人的年龄进行排序)

1
2
3
4
5
6
7
8
9
csharp复制代码Collections.sort(peopleList, new Comparator<People>() {
public int compare(People o1, People o2) {
if (o1.getAge() > o2.getAge()) {
return 1;
} else {
return -1;
}
}
});

而在JDK1.8里,你可以编写如下更为简洁的代码:

1
less复制代码Collections.sort(peopleList, Comparator.comparingInt(People::getAge));

自从接触JDK1.8后,这样类似简洁的代码你将会非常喜欢的。

更好的利用多核处理器

JDK1.8对多核处理器有更好的处理:平时我们用的电脑或服务器的CPU都是多核的,但是,绝大多数现有的Java程序都只使用其中一个内核,而其他的都是处于闲置状态。

在JDK1.8之前,可能有人会告诉你,必须使用多线程才能使用多个内核。问题是,线程用起来比较难,也容易出现错误。从JDK的版本演变来看,它一直致力于让并发编程更容易、出错更少。JDK1.0里有线程和锁,甚至有一个内存模型——这是当时的最佳做法,但事实证明,不具备专门知识的项目团队很难可靠地使用这些基本模型。JDK1.5添加了如线程池和并发集合。JDK1.7添加了分支/合并(fork/join)框架,使得并行变得更实用,但仍然很困难。而JDK1.8中对并行有了一个更简单的新思路,但需要遵循一些规则。

JDK1.8提供了一个新的API(称为“流”,Stream),它支持许多处理数据的并行操作,其思路和在数据库查询语言中的思路类似:用更高级的方式表达想要的东西,而由“实现”(在这里
是Streams库)来选择最佳低级执行机制。这样就可以避免用synchronized编写代码,这一代码不仅容易出错,而且在多核CPU上执行所需的成本也比你想象的要高。

速度更快

如果你的开发环境装的就是JDK1.8,那么你就已经在无形中享用JDK1.8的新特性了。

JDK1.8对于底层的数据结构上做了些更新和改动,对垃圾回收机制(内存结构)也做了一定的改变,以及对于并行/并行流,并行的操作能够很容易的进行使用,对并行做了一些扩展和支持。

我们一起了解一下它是怎么让底层的数据结构“速度更快”呢?我们都知道底层数据结构最核心的一个就是HashMap,那么它对HashMap做了怎样的改动呢?

原来的HashMap是怎样的呢?(数组+链表)

1.8之后的HashMap是怎样的呢?(数组+链表+红黑树)

当链表长度太长(默认超过8)时,链表就转换为红黑树。红黑树的改进解决了什么问题呢?

HashMap碰撞处理的优化,针对超长链的检查,时间复杂度从O(n)降到了O(log2n)。

HashMap的优化,只是体现JDK1.8速度更快的典型代表之一,其他优化之处在此就不一一说明。

总结

看了上面这几点,你应该知道为什么要关注JDK1.8的原因了吧。因为它给我们开发、系统带来前所未有的好处,在后续的使用中,你会发现它的种种优点。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Django教程(一) Python、Django环境搭建

发表于 2020-12-30
  1. python的安装

1.1 进入官网 www.python.org/downloads/ 下载最新python 3安装包

1.2. 输入命令”python -V”查看版本号验证安装是否成功

1.3. pip 的使用
pip 是 Python 包管理工具,该工具提供了对Python 包的查找、下载、安装、卸载的功能。Python 3.4+ 以上版本都自带 pip 工具。

查看pip版本

  1. 安装django

使用pip安装最新版的 django版本

1
ini复制代码pip install Django==3.1.4

检查django是否安装成功,使用如下语句

1
scss复制代码python -c "import django; print(django.get_version())"

如下图所示,代表安装成功

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

spring:我是如何解决循环依赖的?

发表于 2020-12-29

1.由同事抛的一个问题开始

最近项目组的一个同事遇到了一个问题,问我的意见,一下子引起的我的兴趣,因为这个问题我也是第一次遇到。平时自认为对spring循环依赖问题还是比较了解的,直到遇到这个和后面的几个问题后,重新刷新了我的认识。

我们先看看当时出问题的代码片段:

1
2
3
4
5
6
7
8
9
10
java复制代码@Service
publicclass TestService1 {

@Autowired
private TestService2 testService2;

@Async
public void test1() {
}
}
1
2
3
4
5
6
7
8
9
java复制代码@Service
publicclass TestService2 {

@Autowired
private TestService1 testService1;

public void test2() {
}
}

这两段代码中定义了两个Service类:TestService1和TestService2,在TestService1中注入了TestService2的实例,同时在TestService2中注入了TestService1的实例,这里构成了循环依赖。

只不过,这不是普通的循环依赖,因为TestService1的test1方法上加了一个@Async注解。

大家猜猜程序启动后运行结果会怎样?

1
java复制代码org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'testService1': Bean with name 'testService1' has been injected into other beans [testService2] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.

报错了。。。原因是出现了循环依赖。

「不科学呀,spring不是号称能解决循环依赖问题吗,怎么还会出现?」

如果把上面的代码稍微调整一下:

1
2
3
4
5
6
7
8
9
java复制代码@Service
publicclass TestService1 {

@Autowired
private TestService2 testService2;

public void test1() {
}
}

把TestService1的test1方法上的@Async注解去掉,TestService1和TestService2都需要注入对方的实例,同样构成了循环依赖。

但是重新启动项目,发现它能够正常运行。这又是为什么?

带着这两个问题,让我们一起开始spring循环依赖的探秘之旅。

2.什么是循环依赖?

循环依赖:说白是一个或多个对象实例之间存在直接或间接的依赖关系,这种依赖关系构成了构成一个环形调用。

第一种情况:自己依赖自己的直接依赖

第二种情况:两个对象之间的直接依赖

第三种情况:多个对象之间的间接依赖

前面两种情况的直接循环依赖比较直观,非常好识别,但是第三种间接循环依赖的情况有时候因为业务代码调用层级很深,不容易识别出来。

3.循环依赖的N种场景

spring中出现循环依赖主要有以下场景:

单例的setter注入

这种注入方式应该是spring用的最多的,代码如下:

1
2
3
4
5
6
7
8
9
java复制代码@Service
publicclass TestService1 {

@Autowired
private TestService2 testService2;

public void test1() {
}
}
1
2
3
4
5
6
7
8
9
java复制代码@Service
publicclass TestService2 {

@Autowired
private TestService1 testService1;

public void test2() {
}
}

这是一个经典的循环依赖,但是它能正常运行,得益于spring的内部机制,让我们根本无法感知它有问题,因为spring默默帮我们解决了。

spring内部有三级缓存:

  • singletonObjects 一级缓存,用于保存实例化、注入、初始化完成的bean实例
  • earlySingletonObjects 二级缓存,用于保存实例化完成的bean实例
  • singletonFactories 三级缓存,用于保存bean创建工厂,以便于后面扩展有机会创建代理对象。

下面用一张图告诉你,spring是如何解决循环依赖的:

图1

细心的朋友可能会发现在这种场景中第二级缓存作用不大。
那么问题来了,为什么要用第二级缓存呢?
试想一下,如果出现以下这种情况,我们要如何处理?

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Service
publicclass TestService1 {

@Autowired
private TestService2 testService2;
@Autowired
private TestService3 testService3;

public void test1() {
}
}
1
2
3
4
5
6
7
8
9
java复制代码@Service
publicclass TestService2 {

@Autowired
private TestService1 testService1;

public void test2() {
}
}
1
2
3
4
5
6
7
8
9
java复制代码@Service
publicclass TestService3 {

@Autowired
private TestService1 testService1;

public void test3() {
}
}

TestService1依赖于TestService2和TestService3,而TestService2依赖于TestService1,同时TestService3也依赖于TestService1。

按照上图的流程可以把TestService1注入到TestService2,并且TestService1的实例是从第三级缓存中获取的。

假设不用第二级缓存,TestService1注入到TestService3的流程如图:

图2

TestService1注入到TestService3又需要从第三级缓存中获取实例,而第三级缓存里保存的并非真正的实例对象,而是ObjectFactory对象。说白了,两次从三级缓存中获取都是ObjectFactory对象,而通过它创建的实例对象每次可能都不一样的。

这样不是有问题?

为了解决这个问题,spring引入的第二级缓存。上面图1其实TestService1对象的实例已经被添加到第二级缓存中了,而在TestService1注入到TestService3时,只用从第二级缓存中获取该对象即可。

图3

还有个问题,第三级缓存中为什么要添加ObjectFactory对象,直接保存实例对象不行吗?

答:不行,因为假如你想对添加到三级缓存中的实例对象进行增强,直接用实例对象是行不通的。

针对这种场景spring是怎么做的呢?

答案就在AbstractAutowireCapableBeanFactory类doCreateBean方法的这段代码中:

它定义了一个匿名内部类,通过getEarlyBeanReference方法获取代理对象,其实底层是通过AbstractAutoProxyCreator类的getEarlyBeanReference生成代理对象。

多例的setter注入

这种注入方法偶然会有,特别是在多线程的场景下,具体代码如下:

1
2
3
4
5
6
7
8
9
10
java复制代码@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
publicclass TestService1 {

@Autowired
private TestService2 testService2;

public void test1() {
}
}
1
2
3
4
5
6
7
8
9
10
java复制代码@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Service
publicclass TestService2 {

@Autowired
private TestService1 testService1;

public void test2() {
}
}

很多人说这种情况spring容器启动会报错,其实是不对的,我非常负责任的告诉你程序能够正常启动。

为什么呢?

其实在AbstractApplicationContext类的refresh方法中告诉了我们答案,它会调用finishBeanFactoryInitialization方法,该方法的作用是为了spring容器启动的时候提前初始化一些bean。该方法的内部又调用了preInstantiateSingletons方法。

标红的地方明显能够看出:非抽象、单例 并且非懒加载的类才能被提前初始bean。
而多例即SCOPE_PROTOTYPE类型的类,非单例,不会被提前初始化bean,所以程序能够正常启动。

如何让他提前初始化bean呢?

只需要再定义一个单例的类,在它里面注入TestService1

1
2
3
4
5
6
java复制代码@Service
publicclass TestService3 {

@Autowired
private TestService1 testService1;
}

重新启动程序,执行结果:

1
java复制代码Requested bean is currently in creation: Is there an unresolvable circular reference?

果然出现了循环依赖。

注意:这种循环依赖问题是无法解决的,因为它没有用缓存,每次都会生成一个新对象。

构造器注入

这种注入方式是spring4.x以上的版本中官方推荐的方式,看看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Service
publicclass TestService1 {

public TestService1(TestService2 testService2) {
}
}
@Service
publicclass TestService2 {

public TestService2(TestService1 testService1) {
}
}

运行结果:

1
java复制代码Requested bean is currently in creation: Is there an unresolvable circular reference?

出现了循环依赖,为什么呢?

从图中的流程看出构造器注入只是添加了三级缓存,并没有使用缓存,所以也无法解决循环依赖问题。

单例的代理对象setter注入

这种注入方式其实也比较常用,比如平时使用:@Async注解的场景,会通过AOP自动生成代理对象。

我那位同事的问题也是这种情况。

1
2
3
4
5
6
7
8
9
10
java复制代码@Service
publicclass TestService1 {

@Autowired
private TestService2 testService2;

@Async
public void test1() {
}
}
1
2
3
4
5
6
7
8
9
java复制代码@Service
publicclass TestService2 {

@Autowired
private TestService1 testService1;

public void test2() {
}
}

从前面得知程序启动会报错,出现了循环依赖:

1
java复制代码org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'testService1': Bean with name 'testService1' has been injected into other beans [testService2] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.

为什么会循环依赖呢?

答案就在下面这张图中:

说白了,bean初始化完成之后,后面还有一步去检查:第二级缓存 和 原始对象 是否相等。由于它对前面流程来说无关紧要,所以前面的流程图中省略了,但是在这里是关键点,我们重点说说:

那位同事的问题正好是走到这段代码,发现第二级缓存 和 原始对象不相等,所以抛出了循环依赖的异常。

如果这时候把TestService1改个名字,改成:TestService6,其他的都不变。

1
2
3
4
5
6
7
8
9
10
java复制代码@Service
publicclass TestService6 {

@Autowired
private TestService2 testService2;

@Async
public void test1() {
}
}

再重新启动一下程序,神奇般的好了。

what? 这又是为什么?

这就要从spring的bean加载顺序说起了,默认情况下,spring是按照文件完整路径递归查找的,按路径+文件名排序,排在前面的先加载。所以TestService1比TestService2先加载,而改了文件名称之后,TestService2比TestService6先加载。

为什么TestService2比TestService6先加载就没问题呢?

答案在下面这张图中:

这种情况testService6中其实第二级缓存是空的,不需要跟原始对象判断,所以不会抛出循环依赖。

DependsOn循环依赖

还有一种有些特殊的场景,比如我们需要在实例化Bean A之前,先实例化Bean B,这个时候就可以使用@DependsOn注解。

1
2
3
4
5
6
7
8
9
10
java复制代码@DependsOn(value = "testService2")
@Service
publicclass TestService1 {

@Autowired
private TestService2 testService2;

public void test1() {
}
}
1
2
3
4
5
6
7
8
9
10
java复制代码@DependsOn(value = "testService1")
@Service
publicclass TestService2 {

@Autowired
private TestService1 testService1;

public void test2() {
}
}

程序启动之后,执行结果:

1
java复制代码Circular depends-on relationship between 'testService2' and 'testService1'

这个例子中本来如果TestService1和TestService2都没有加@DependsOn注解是没问题的,反而加了这个注解会出现循环依赖问题。

这又是为什么?

答案在AbstractBeanFactory类的doGetBean方法的这段代码中:

它会检查dependsOn的实例有没有循环依赖,如果有循环依赖则抛异常。

4.出现循环依赖如何解决?

项目中如果出现循环依赖问题,说明是spring默认无法解决的循环依赖,要看项目的打印日志,属于哪种循环依赖。目前包含下面几种情况:

生成代理对象产生的循环依赖

这类循环依赖问题解决方法很多,主要有:

  1. 使用@Lazy注解,延迟加载
  2. 使用@DependsOn注解,指定加载先后关系
  3. 修改文件名称,改变循环依赖类的加载顺序

使用@DependsOn产生的循环依赖

这类循环依赖问题要找到@DependsOn注解循环依赖的地方,迫使它不循环依赖就可以解决问题。

多例循环依赖

这类循环依赖问题可以通过把bean改成单例的解决。

构造器循环依赖

这类循环依赖问题可以通过使用@Lazy注解解决。

当然最好的解决循环依赖问题最佳方案是从代码设计上规避,但是复杂的系统中有可能没法避免。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。
关注公众号:苏三说技术,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

解决vscode中golang插件安装失败方法

发表于 2020-12-29

首先Windows用户打开Powershell,一个蓝色的界面,注意不是cmd!不知道的直接打开window下面的搜索,然后输入powershell,搜索出来就可以了。

$env:GO111MODULE=”on”

$env:GOPROXY=”goproxy.io“

go env -w GOPROXY=goproxy.io,direct

go env -w GOPRIVATE=*.corp.example.com

然后我们打开VsCode界面,下面会提示安装插件,我们选择Install ALL,就会安装成功

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…745746747…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%