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

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


  • 首页

  • 归档

  • 搜索

重学 Java 设计模式:实战组合模式(营销差异化人群发券,

发表于 2020-06-08

作者:小傅哥

博客:bugstack.cn

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言

小朋友才做选择题,成年人我都要

头几年只要群里一问我该学哪个开发语言,哪个语言最好。群里肯定聊的特别火热,有人支持PHP、有人喊号Java、也有C++和C#。但这几年开始好像大家并不会真的刀枪棍棒、斧钺钩叉般讨论了,大多数时候都是开玩笑的闹一闹。于此同时在整体的互联网开发中很多时候是一些开发语言公用的,共同打造整体的生态圈。而大家选择的方式也是更偏向于不同领域下选择适合的架构,而不是一味地追求某个语言。这可以给很多初学编程的新人一些提议,不要刻意的觉得某个语言好,某个语言不好,只是在适合的场景下选择最需要的。而你要选择的那个语言可以参考招聘网站的需求量和薪资水平决定。

编程开发不是炫技

总会有人喜欢在整体的项目开发中用上点新特性,把自己新学的知识实践试试。不能说这样就是不好,甚至可以说这是一部分很热爱学习的人,喜欢创新,喜欢实践。但编程除了用上新特性外,还需要考虑整体的扩展性、可读性、可维护、易扩展等方面的考虑。就像你家里雇佣了一伙装修师傅,有那么一个小工喜欢炫技搞花活,在家的淋浴下🚿安装了马桶🚽。

即使是写CRUD也应该有设计模式

往往很多大需求都是通过增删改查堆出来的,今天要一个需求if一下,明天加个内容else扩展一下。日积月累需求也就越来越大,扩展和维护的成本也就越来越高。往往大部分研发是不具备产品思维和整体业务需求导向的,总以为写好代码完成功能即可。但这样的不考虑扩展性的实现,很难让后续的需求都快速迭代,久而久之就会被陷入恶性循环,每天都有bug要改。

二、开发环境

  1. JDK 1.8
  2. Idea + Maven
  3. 涉及工程三个,可以通过关注公众号:bugstack虫洞栈,回复源码下载获取(打开获取的链接,找到序号18)
工程 描述
itstack-demo-design-8-01 使用一坨代码实现业务需求
itstack-demo-design-8-02 通过设计模式优化改造代码,产生对比性从而学习

三、组合模式介绍

组合模式,图片来自 refactoringguru.cn

从上图可以看到这有点像螺丝🔩和螺母,通过一堆的链接组织出一棵结构树。而这种通过把相似对象(也可以称作是方法)组合成一组可被调用的结构树对象的设计思路叫做组合模式。

这种设计方式可以让你的服务组节点进行自由组合对外提供服务,例如你有三个原子校验功能(A:身份证、B:银行卡、C:手机号)服务并对外提供调用使用。有些调用方需要使用AB组合,有些调用方需要使用到CBA组合,还有一些可能只使用三者中的一个。那么这个时候你就可以使用组合模式进行构建服务,对于不同类型的调用方配置不同的组织关系树,而这个树结构你可以配置到数据库中也可以不断的通过图形界面来控制树结构。

所以不同的设计模式用在恰当好处的场景可以让代码逻辑非常清晰并易于扩展,同时也可以减少团队新增人员对项目的学习成本。

四、案例场景模拟

场景模式;营销决策树

以上是一个非常简化版的营销规则决策树,根据性别、年龄来发放不同类型的优惠券,来刺激消费起到精准用户促活的目的。

虽然一部分小伙伴可能并没有开发过营销场景,但你可能时时刻刻的被营销着。比如你去经常浏览男性喜欢的机械键盘、笔记本电脑、汽车装饰等等,那么久给你推荐此类的优惠券刺激你消费。那么如果你购物不多,或者钱不在自己手里。那么你是否打过车,有一段时间经常有小伙伴喊,为什么同样的距离他就10元,我就15元呢?其实这些都是被营销的案例,一般对于不常使用软件的小伙伴,经常会进行稍微大力度的促活,增加用户粘性。

那么在这里我们就模拟一个类似的决策场景,体现出组合模式在其中起到的重要性。另外,组合模式不只是可以运用于规则决策树,还可以做服务包装将不同的接口进行组合配置,对外提供服务能力,减少开发成本。

五、用一坨坨代码实现

这里我们举一个关于ifelse诞生的例子,介绍小姐姐与程序员👨‍💻‍之间的故事导致的事故。

日期 需求 紧急程度 程序员(话外音)
星期一.早上 猿哥哥,老板说要搞一下营销拉拉量,给男生女生发不同的优惠券,促活消费。 很紧急,下班就要 行吧,也不难,加下判断就上线
星期二.下午 小哥哥,咱们上线后非常好。要让咱们按照年轻、中年、成年,不同年龄加下判断,准确刺激消费。 超紧急,明天就要 也不难,加就加吧
星期三.晚上 喂,小哥哥!睡了吗!老板说咱们这次活动很成功,可以不可以在细分下,把单身、结婚、有娃的都加上不同判断。这样更能刺激用户消费。 贼紧急,最快上线。 已经意识到ifelse越来越多了
星期四.凌晨 哇!小哥哥你们太棒了,上的真快。嘻嘻!有个小请求,需要调整下年龄段,因为现在学生处对象的都比较早,有对象的更容易买某某某东西。要改下值!辛苦辛苦! 老板,在等着呢! 一大片的值要修改,哎!这么多ifelse了
星期五.半夜 歪歪喂!巴巴,坏了,怎么发的优惠券不对了,有客诉了,很多女生都来投诉。你快看看。老板,他… (一头汗),哎,值粘错位置了! 终究还是一个人扛下了所有

1. 工程结构

1
2
3
4
5
6
复制代码itstack-demo-design-8-01
└── src
└── main
└── java
└── org.itstack.demo.design
└── EngineController.java
  • 公司里要都是这样的程序员绝对省下不少成本,根本不要搭建微服务,一个工程搞定所有业务!
  • 但千万不要这么干!酒肉穿肠过,佛祖心中留。世人若学我,如同进魔道。

2. 代码实现

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

private Logger logger = LoggerFactory.getLogger(EngineController.class);

public String process(final String userId, final String userSex, final int userAge) {

logger.info("ifelse实现方式判断用户结果。userId:{} userSex:{} userAge:{}", userId, userSex, userAge);

if ("man".equals(userSex)) {
if (userAge < 25) {
return "果实A";
}

if (userAge >= 25) {
return "果实B";
}
}

if ("woman".equals(userSex)) {
if (userAge < 25) {
return "果实C";
}

if (userAge >= 25) {
return "果实D";
}
}

return null;

}

}
  • 除了我们说的扩展性和每次的维护以外,这样的代码实现起来是最快的。而且从样子来看也很适合新人理解。
  • 但是我劝你别写,写这样代码不是被扣绩效就是被开除。

3. 测试验证

3.1 编写测试类

1
2
3
4
5
6
复制代码@Test
public void test_EngineController() {
EngineController engineController = new EngineController();
String process = engineController.process("Oli09pLkdjh", "man", 29);
logger.info("测试结果:{}", process);
}
  • 这里我们模拟了一个用户ID,并传输性别:man、年龄:29,我们的预期结果是:果实B。实际对应业务就是给头秃的程序员发一张枸杞优惠券。

3.2 测试结果

1
2
3
4
复制代码22:10:12.891 [main] INFO  o.i.demo.design.EngineController - ifelse实现方式判断用户结果。userId:Oli09pLkdjh userSex:man userAge:29
22:10:12.898 [main] INFO org.itstack.demo.design.test.ApiTest - 测试结果:果实B

Process finished with exit code 0
  • 从测试结果上看我们的程序运行正常并且符合预期,只不过实现上并不是我们推荐的。接下来我们会采用组合模式来优化这部分代码。

六、组合模式重构代码

接下来使用组合模式来进行代码优化,也算是一次很小的重构。

接下来的重构部分代码改动量相对来说会比较大一些,为了让我们可以把不同类型的决策节点和最终的果实组装成一棵可被运行的决策树,需要做适配设计和工厂方法调用,具体会体现在定义接口以及抽象类和初始化配置决策节点(性别、年龄)上。建议这部分代码多阅读几次,最好实践下。

1. 工程结构

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
复制代码itstack-demo-design-8-02
└── src
├── main
│ └── java
│ └── org.itstack.demo.design.domain
│ ├── model
│ │ ├── aggregates
│ │ │ └── TreeRich.java
│ │ └── vo
│ │ ├── EngineResult.java
│ │ ├── TreeNode.java
│ │ ├── TreeNodeLink.java
│ │ └── TreeRoot.java
│ └── service
│ ├── engine
│ │ ├── impl
│ │ │ └── TreeEngineHandle.java
│ │ ├── EngineBase.java
│ │ ├── EngineConfig.java
│ │ └── IEngine.java
│ └── logic
│ ├── impl
│ │ ├── LogicFilter.java
│ │ └── LogicFilter.java
│ └── LogicFilter.java
└── test
└── java
└── org.itstack.demo.design.test
└── ApiTest.java

组合模式模型结构

组合模式模型结构

  • 首先可以看下黑色框框的模拟指导树结构;1、11、12、111、112、121、122,这是一组树结构的ID,并由节点串联组合出一棵关系树树。
  • 接下来是类图部分,左侧是从LogicFilter开始定义适配的决策过滤器,BaseLogic是对接口的实现,提供最基本的通用方法。UserAgeFilter、UserGenerFilter,是两个具体的实现类用于判断年龄和性别。
  • 最后则是对这颗可以被组织出来的决策树,进行执行的引擎。同样定义了引擎接口和基础的配置,在配置里面设定了需要的模式决策节点。
+ 
1
2
3
4
5
复制代码static {
logicFilterMap = new ConcurrentHashMap<>();
logicFilterMap.put("userAge", new UserAgeFilter());
logicFilterMap.put("userGender", new UserGenderFilter());
}
  • 接下来会对每一个类进行细致的讲解,如果感觉没有读懂一定是我作者的表述不够清晰,可以添加我的微信(fustack)与我交流。

2. 代码实现

2.1 基础对象

包路径 类 介绍
model.aggregates TreeRich 聚合对象,包含组织树信息
model.vo EngineResult 决策返回对象信息
model.vo TreeNode 树节点;子叶节点、果实节点
model.vo TreeNodeLink 树节点链接链路
model.vo TreeRoot 树根信息
  • 以上这部分简单介绍,不包含逻辑只是各项必要属性的get/set,整个源代码可以通过关注微信公众号:bugstack虫洞栈,回复源码下载打开链接获取。

2.2 树节点逻辑过滤器接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码public interface LogicFilter {

/**
* 逻辑决策器
*
* @param matterValue 决策值
* @param treeNodeLineInfoList 决策节点
* @return 下一个节点Id
*/
Long filter(String matterValue, List<TreeNodeLink> treeNodeLineInfoList);

/**
* 获取决策值
*
* @param decisionMatter 决策物料
* @return 决策值
*/
String matterValue(Long treeId, String userId, Map<String, String> decisionMatter);

}
  • 这一部分定义了适配的通用接口,逻辑决策器、获取决策值,让每一个提供决策能力的节点都必须实现此接口,保证统一性。

2.3 决策抽象类提供基础服务

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 abstract class BaseLogic implements LogicFilter {

@Override
public Long filter(String matterValue, List<TreeNodeLink> treeNodeLinkList) {
for (TreeNodeLink nodeLine : treeNodeLinkList) {
if (decisionLogic(matterValue, nodeLine)) return nodeLine.getNodeIdTo();
}
return 0L;
}

@Override
public abstract String matterValue(Long treeId, String userId, Map<String, String> decisionMatter);

private boolean decisionLogic(String matterValue, TreeNodeLink nodeLink) {
switch (nodeLink.getRuleLimitType()) {
case 1:
return matterValue.equals(nodeLink.getRuleLimitValue());
case 2:
return Double.parseDouble(matterValue) > Double.parseDouble(nodeLink.getRuleLimitValue());
case 3:
return Double.parseDouble(matterValue) < Double.parseDouble(nodeLink.getRuleLimitValue());
case 4:
return Double.parseDouble(matterValue) <= Double.parseDouble(nodeLink.getRuleLimitValue());
case 5:
return Double.parseDouble(matterValue) >= Double.parseDouble(nodeLink.getRuleLimitValue());
default:
return false;
}
}

}
  • 在抽象方法中实现了接口方法,同时定义了基本的决策方法;1、2、3、4、5,等于、小于、大于、小于等于、大于等于的判断逻辑。
  • 同时定义了抽象方法,让每一个实现接口的类都必须按照规则提供决策值,这个决策值用于做逻辑比对。

2.4 树节点逻辑实现类

年龄节点

1
2
3
4
5
6
7
8
复制代码public class UserAgeFilter extends BaseLogic {

@Override
public String matterValue(Long treeId, String userId, Map<String, String> decisionMatter) {
return decisionMatter.get("age");
}

}

性别节点

1
2
3
4
5
6
7
8
复制代码public class UserGenderFilter extends BaseLogic {

@Override
public String matterValue(Long treeId, String userId, Map<String, String> decisionMatter) {
return decisionMatter.get("gender");
}

}
  • 以上两个决策逻辑的节点获取值的方式都非常简单,只是获取用户的入参即可。实际的业务开发可以从数据库、RPC接口、缓存运算等各种方式获取。

2.5 决策引擎接口定义

1
2
3
4
5
复制代码public interface IEngine {

EngineResult process(final Long treeId, final String userId, TreeRich treeRich, final Map<String, String> decisionMatter);

}
  • 对于使用方来说也同样需要定义统一的接口操作,这样的好处非常方便后续拓展出不同类型的决策引擎,也就是可以建造不同的决策工厂。

2.6 决策节点配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码public class EngineConfig {

static Map<String, LogicFilter> logicFilterMap;

static {
logicFilterMap = new ConcurrentHashMap<>();
logicFilterMap.put("userAge", new UserAgeFilter());
logicFilterMap.put("userGender", new UserGenderFilter());
}

public Map<String, LogicFilter> getLogicFilterMap() {
return logicFilterMap;
}

public void setLogicFilterMap(Map<String, LogicFilter> logicFilterMap) {
this.logicFilterMap = logicFilterMap;
}

}
  • 在这里将可提供服务的决策节点配置到map结构中,对于这样的map结构可以抽取到数据库中,那么就可以非常方便的管理。

2.7 基础决策引擎功能

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
复制代码public abstract class EngineBase extends EngineConfig implements IEngine {

private Logger logger = LoggerFactory.getLogger(EngineBase.class);

@Override
public abstract EngineResult process(Long treeId, String userId, TreeRich treeRich, Map<String, String> decisionMatter);

protected TreeNode engineDecisionMaker(TreeRich treeRich, Long treeId, String userId, Map<String, String> decisionMatter) {
TreeRoot treeRoot = treeRich.getTreeRoot();
Map<Long, TreeNode> treeNodeMap = treeRich.getTreeNodeMap();
// 规则树根ID
Long rootNodeId = treeRoot.getTreeRootNodeId();
TreeNode treeNodeInfo = treeNodeMap.get(rootNodeId);
//节点类型[NodeType];1子叶、2果实
while (treeNodeInfo.getNodeType().equals(1)) {
String ruleKey = treeNodeInfo.getRuleKey();
LogicFilter logicFilter = logicFilterMap.get(ruleKey);
String matterValue = logicFilter.matterValue(treeId, userId, decisionMatter);
Long nextNode = logicFilter.filter(matterValue, treeNodeInfo.getTreeNodeLinkList());
treeNodeInfo = treeNodeMap.get(nextNode);
logger.info("决策树引擎=>{} userId:{} treeId:{} treeNode:{} ruleKey:{} matterValue:{}", treeRoot.getTreeName(), userId, treeId, treeNodeInfo.getTreeNodeId(), ruleKey, matterValue);
}
return treeNodeInfo;
}

}
  • 这里主要提供决策树流程的处理过程,有点像通过链路的关系(性别、年龄)在二叉树中寻找果实节点的过程。
  • 同时提供一个抽象方法,执行决策流程的方法供外部去做具体的实现。

2.8 决策引擎的实现

1
2
3
4
5
6
7
8
9
10
11
复制代码public class TreeEngineHandle extends EngineBase {

@Override
public EngineResult process(Long treeId, String userId, TreeRich treeRich, Map<String, String> decisionMatter) {
// 决策流程
TreeNode treeNode = engineDecisionMaker(treeRich, treeId, userId, decisionMatter);
// 决策结果
return new EngineResult(userId, treeId, treeNode.getTreeNodeId(), treeNode.getNodeValue());
}

}
  • 这里对于决策引擎的实现就非常简单了,通过传递进来的必要信息;决策树信息、决策物料值,来做具体的树形结构决策。

3. 测试验证

3.1 组装树关系

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
复制代码@Before
public void init() {
// 节点:1
TreeNode treeNode_01 = new TreeNode();
treeNode_01.setTreeId(10001L);
treeNode_01.setTreeNodeId(1L);
treeNode_01.setNodeType(1);
treeNode_01.setNodeValue(null);
treeNode_01.setRuleKey("userGender");
treeNode_01.setRuleDesc("用户性别[男/女]");
// 链接:1->11
TreeNodeLink treeNodeLink_11 = new TreeNodeLink();
treeNodeLink_11.setNodeIdFrom(1L);
treeNodeLink_11.setNodeIdTo(11L);
treeNodeLink_11.setRuleLimitType(1);
treeNodeLink_11.setRuleLimitValue("man");
// 链接:1->12
TreeNodeLink treeNodeLink_12 = new TreeNodeLink();
treeNodeLink_12.setNodeIdTo(1L);
treeNodeLink_12.setNodeIdTo(12L);
treeNodeLink_12.setRuleLimitType(1);
treeNodeLink_12.setRuleLimitValue("woman");
List<TreeNodeLink> treeNodeLinkList_1 = new ArrayList<>();
treeNodeLinkList_1.add(treeNodeLink_11);
treeNodeLinkList_1.add(treeNodeLink_12);
treeNode_01.setTreeNodeLinkList(treeNodeLinkList_1);
// 节点:11
TreeNode treeNode_11 = new TreeNode();
treeNode_11.setTreeId(10001L);
treeNode_11.setTreeNodeId(11L);
treeNode_11.setNodeType(1);
treeNode_11.setNodeValue(null);
treeNode_11.setRuleKey("userAge");
treeNode_11.setRuleDesc("用户年龄");
// 链接:11->111
TreeNodeLink treeNodeLink_111 = new TreeNodeLink();
treeNodeLink_111.setNodeIdFrom(11L);
treeNodeLink_111.setNodeIdTo(111L);
treeNodeLink_111.setRuleLimitType(3);
treeNodeLink_111.setRuleLimitValue("25");
// 链接:11->112
TreeNodeLink treeNodeLink_112 = new TreeNodeLink();
treeNodeLink_112.setNodeIdFrom(11L);
treeNodeLink_112.setNodeIdTo(112L);
treeNodeLink_112.setRuleLimitType(5);
treeNodeLink_112.setRuleLimitValue("25");
List<TreeNodeLink> treeNodeLinkList_11 = new ArrayList<>();
treeNodeLinkList_11.add(treeNodeLink_111);
treeNodeLinkList_11.add(treeNodeLink_112);
treeNode_11.setTreeNodeLinkList(treeNodeLinkList_11);
// 节点:12
TreeNode treeNode_12 = new TreeNode();
treeNode_12.setTreeId(10001L);
treeNode_12.setTreeNodeId(12L);
treeNode_12.setNodeType(1);
treeNode_12.setNodeValue(null);
treeNode_12.setRuleKey("userAge");
treeNode_12.setRuleDesc("用户年龄");
// 链接:12->121
TreeNodeLink treeNodeLink_121 = new TreeNodeLink();
treeNodeLink_121.setNodeIdFrom(12L);
treeNodeLink_121.setNodeIdTo(121L);
treeNodeLink_121.setRuleLimitType(3);
treeNodeLink_121.setRuleLimitValue("25");
// 链接:12->122
TreeNodeLink treeNodeLink_122 = new TreeNodeLink();
treeNodeLink_122.setNodeIdFrom(12L);
treeNodeLink_122.setNodeIdTo(122L);
treeNodeLink_122.setRuleLimitType(5);
treeNodeLink_122.setRuleLimitValue("25");
List<TreeNodeLink> treeNodeLinkList_12 = new ArrayList<>();
treeNodeLinkList_12.add(treeNodeLink_121);
treeNodeLinkList_12.add(treeNodeLink_122);
treeNode_12.setTreeNodeLinkList(treeNodeLinkList_12);
// 节点:111
TreeNode treeNode_111 = new TreeNode();
treeNode_111.setTreeId(10001L);
treeNode_111.setTreeNodeId(111L);
treeNode_111.setNodeType(2);
treeNode_111.setNodeValue("果实A");
// 节点:112
TreeNode treeNode_112 = new TreeNode();
treeNode_112.setTreeId(10001L);
treeNode_112.setTreeNodeId(112L);
treeNode_112.setNodeType(2);
treeNode_112.setNodeValue("果实B");
// 节点:121
TreeNode treeNode_121 = new TreeNode();
treeNode_121.setTreeId(10001L);
treeNode_121.setTreeNodeId(121L);
treeNode_121.setNodeType(2);
treeNode_121.setNodeValue("果实C");
// 节点:122
TreeNode treeNode_122 = new TreeNode();
treeNode_122.setTreeId(10001L);
treeNode_122.setTreeNodeId(122L);
treeNode_122.setNodeType(2);
treeNode_122.setNodeValue("果实D");
// 树根
TreeRoot treeRoot = new TreeRoot();
treeRoot.setTreeId(10001L);
treeRoot.setTreeRootNodeId(1L);
treeRoot.setTreeName("规则决策树");
Map<Long, TreeNode> treeNodeMap = new HashMap<>();
treeNodeMap.put(1L, treeNode_01);
treeNodeMap.put(11L, treeNode_11);
treeNodeMap.put(12L, treeNode_12);
treeNodeMap.put(111L, treeNode_111);
treeNodeMap.put(112L, treeNode_112);
treeNodeMap.put(121L, treeNode_121);
treeNodeMap.put(122L, treeNode_122);
treeRich = new TreeRich(treeRoot, treeNodeMap);
}

树形结构的组织关系

  • 重要,这一部分是组合模式非常重要的使用,在我们已经建造好的决策树关系下,可以创建出树的各个节点,以及对节点间使用链路进行串联。
  • 及时后续你需要做任何业务的扩展都可以在里面添加相应的节点,并做动态化的配置。
  • 关于这部分手动组合的方式可以提取到数据库中,那么也就可以扩展到图形界面的进行配置操作。

3.2 编写测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码@Test
public void test_tree() {
logger.info("决策树组合结构信息:\r\n" + JSON.toJSONString(treeRich));

IEngine treeEngineHandle = new TreeEngineHandle();
Map<String, String> decisionMatter = new HashMap<>();
decisionMatter.put("gender", "man");
decisionMatter.put("age", "29");

EngineResult result = treeEngineHandle.process(10001L, "Oli09pLkdjh", treeRich, decisionMatter);

logger.info("测试结果:{}", JSON.toJSONString(result));
}
  • 在这里提供了调用的通过组织模式创建出来的流程决策树,调用的时候传入了决策树的ID,那么如果是业务开发中就可以方便的解耦决策树与业务的绑定关系,按需传入决策树ID即可。
  • 此外入参我们还提供了需要处理;男(man)、年龄(29岁),的参数信息。

3.3 测试结果

1
2
3
4
5
复制代码23:35:05.711 [main] INFO  o.i.d.d.d.service.engine.EngineBase - 决策树引擎=>规则决策树 userId:Oli09pLkdjh treeId:10001 treeNode:11 ruleKey:userGender matterValue:man
23:35:05.712 [main] INFO o.i.d.d.d.service.engine.EngineBase - 决策树引擎=>规则决策树 userId:Oli09pLkdjh treeId:10001 treeNode:112 ruleKey:userAge matterValue:29
23:35:05.715 [main] INFO org.itstack.demo.design.test.ApiTest - 测试结果:{"nodeId":112,"nodeValue":"果实B","success":true,"treeId":10001,"userId":"Oli09pLkdjh"}

Process finished with exit code 0
  • 从测试结果上看这与我们使用ifelse是一样的,但是目前这与的组合模式设计下,就非常方便后续的拓展和修改。
  • 整体的组织关系框架以及调用决策流程已经搭建完成,如果阅读到此没有完全理解,可以下载代码观察结构并运行调试。

七、总结

  • 从以上的决策树场景来看,组合模式的主要解决的是一系列简单逻辑节点或者扩展的复杂逻辑节点在不同结构的组织下,对于外部的调用是仍然可以非常简单的。
  • 这部分设计模式保证了开闭原则,无需更改模型结构你就可以提供新的逻辑节点的使用并配合组织出新的关系树。但如果是一些功能差异化非常大的接口进行包装就会变得比较困难,但也不是不能很好的处理,只不过需要做一些适配和特定化的开发。
  • 很多时候因为你的极致追求和稍有倔强的工匠精神,即使在面对同样的业务需求,你能完成出最好的代码结构和最易于扩展的技术架构。不要被远不能给你指导提升能力的影响到放弃自己的追求!。

八、推荐阅读

  • 1. 重学 Java 设计模式:实战工厂方法模式(多种类型商品发奖场景)
  • 2. 重学 Java 设计模式:实战抽象工厂模式(替换Redis双集群升级场景)
  • 3. 重学 Java 设计模式:实战建造者模式(装修物料组合套餐选配场景)
  • 4. 重学 Java 设计模式:实战原型模式(多套试每人题目和答案乱序场景)
  • 5. 重学 Java 设计模式:实战桥接模式(多支付渠道「微信、支付宝」与多支付模式「刷脸、指纹」场景)

本文转载自: 掘金

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

看了同事的代码,我忍不住写了这份代码指南

发表于 2020-06-08

前言

写出整洁的代码,是每个程序员的追求。《clean code》指出,要想写出好的代码,首先得知道什么是肮脏代码、什么是整洁代码;然后通过大量的刻意练习,才能真正写出整洁的代码。

WTF/min是衡量代码质量的唯一标准,Uncle Bob在书中称糟糕的代码为沼泽(wading),这只突出了我们是糟糕代码的受害者。国内有一个更适合的词汇:屎山,虽然不是很文雅但是更加客观,程序员既是受害者也是加害者。

对于什么是整洁的代码,书中给出了大师们的总结:

  • Bjarne Stroustrup:优雅且高效;直截了当;减少依赖;只做好一件事
  • Grady booch:简单直接
  • Dave thomas:可读,可维护,单元测试
  • Ron Jeffries:不要重复、单一职责,表达力(Expressiveness)

其中,我最喜欢的是表达力(Expressiveness)这个描述,这个词似乎道出了好代码的真谛:用简单直接的方式描绘出代码的功能,不多也不少。

命名的艺术

坦白的说,命名是一件困难的事情,要想出一个恰到好处的命名需要一番功夫,尤其我们的母语还不是编程语言所通用的英语。不过这一切都是值得了,好的命名让你的代码更直观,更有表达力。

好的命名应该有下面的特征:

名副其实

好的变量名告诉你:是什么东西,为什么存在,该怎么使用

如果需要通过注释来解释变量,那么就先得不那么名副其实了。

下面是书中的一个示例代码,展示了命名对代码质量的提升

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码# bad code
def getItem(theList):
ret = []
for x in theList:
if x[0] == 4:
ret.append(x)
return ret

# good code
def getFlaggedCell(gameBoard):
'''扫雷游戏,flagged:翻转'''
flaggedCells = []
for cell in gameBoard:
if cell.IsFlagged():
flaggedCells.append(cell)
return flaggedCells

避免误导

  • 不要挂羊头卖狗肉
  • 不要覆盖惯用缩略语

这里不得不吐槽前两天才看到的一份代码,居然使用了 l 作为变量名;而且,user居然是一个list(单复数都没学好!!)

有意义的区分

代码是写给机器执行,也是给人阅读的,所以概念一定要有区分度

1
2
3
4
5
6
复制代码# bad
def copy(a_list, b_list):
pass
# good
def copy(source, destination):
pass

使用读的出来的单词

如果名称读不出来,那么讨论的时候就会像个傻鸟

使用方便搜索的命名

名字长短应与其作用域大小相对应

避免思维映射

比如在代码中写一个temp,那么读者就得每次看到这个单词的时候翻译成其真正的意义

注释

有表达力的代码是无需注释的。

❝The proper use of comments is to compensate for our failure to express ourself in code.

❞

注释的适当作用在于弥补我们用代码表达意图时遇到的失败,这听起来让人沮丧,但事实确实如此。The truth is in the code, 注释只是二手信息,二者的不同步或者不等价是注释的最大问题。

书中给出了一个非常形象的例子来展示:用代码来阐述,而非注释

1
2
3
4
5
复制代码bad
// check to see if the employee is eligible for full benefit
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))
good
if (employee.isEligibleForFullBenefits())

因此,当想要添加注释的时候,可以想想是否可以通过修改命名,或者修改函数(代码)的抽象层级来展示代码的意图。

当然,也不能因噎废食,书中指出了以下一些情况属于好的注释

  • 法务信息
  • 对意图的注释,为什么要这么做
  • 警示
  • TODO注释
  • 放大看似不合理之物的重要性

其中个人最赞同的是第2点和第5点,做什么很容易通过命名表达,但为什么要这么做则并不直观,特别涉及到专业知识、算法的时候。另外,有些第一感觉“不那么优雅”的代码,也许有其特殊愿意,那么这样的代码就应该加上注释,说明为什么要这样,比如为了提升关键路径的性能,可能会牺牲部分代码的可读性。

最坏的注释就是过时或者错误的注释,这对于代码的维护者(也许就是几个月后的自己)是巨大的伤害,可惜除了code review,并没有简单易行的方法来保证代码与注释的同步。

函数

函数的单一职责

一个函数应该只做一件事,这件事应该能通过函数名就能清晰的展示。判断方法很简单:看看函数是否还能再拆出一个函数。

函数要么做什么do_sth, 要么查询什么query_sth。最恶心的就是函数名表示只会query_sth, 但事实上却会do_sth, 这使得函数产生了副作用。比如书中的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class UserValidator {
private Cryptographer cryptographer;
public boolean checkPassword(String userName, String password) {
User user = UserGateway.findByName(userName);
if (user != User.NULL) {
String codedPhrase = user.getPhraseEncodedByPassword();
String phrase = cryptographer.decrypt(codedPhrase, password);
if ("Valid Password".equals(phrase)) {
Session.initialize();
return true;
}
}
return false;
}
}

函数的抽象层级

每个函数一个抽象层次,函数中的语句都要在同一个抽象层级,不同的抽象层级不能放在一起。比如我们想把大象放进冰箱,应该是这个样子的:

1
2
3
4
复制代码def pushElephantIntoRefrige():
openRefrige()
pushElephant()
closeRefrige()

函数里面的三句代码在同一个层级(高度)描述了要完成把大象放进冰箱这件事顺序相关的三个步骤。显然,pushElephant这个步骤又可能包含很多子步骤,但是在pushElephantIntoRefrige这个层级,是无需知道太多细节的。

当我们想通过阅读代码的方式来了解一个新的项目时,一般都是采取广度优先的策略,自上而下的阅读代码,先了解整体结构,然后再深入感兴趣的细节。如果没有对实现细节进行良好的抽象(并凝练出一个名副其实的函数),那么阅读者就容易迷失在细节的汪洋里。

某种程度看来,这个跟金字塔原理也很像

image每一个层级都是为了论证其上一层级的观点,同时也需要下一层级的支持;同一层级之间的多个论点又需要以某种逻辑关系排序。pushElephantIntoRefrige就是中心论点,需要多个子步骤的支持,同时这些子步骤之间也有逻辑先后顺序。

函数参数

函数的参数越多,组合出的输入情况就愈多,需要的测试用例也就越多,也就越容易出问题。

输出参数相比返回值难以理解,这点深有同感,输出参数实在是很不直观。从函数调用者的角度,一眼就能看出返回值,而很难识别输出参数。输出参数通常逼迫调用者去检查函数签名,这个实在不友好。

向函数传入Boolean(书中称之为 Flag Argument)通常不是好主意。尤其是传入True or False后的行为并不是一件事情的两面,而是两件不同的事情时。这很明显违背了函数的单一职责约束,解决办法很简单,那就是用两个函数。

Dont repear yourself

在函数这个层级,是最容易、最直观实现复用的,很多IDE也难帮助我们讲一段代码重构出一个函数。

不过在实践中,也会出现这样一种情况:一段代码在多个方法中都有使用,但是又不完全一样,如果抽象成一个通用函数,那么就需要加参数、加if else区别。这样就有点尴尬,貌似可以重构,但又不是很完美。

造成上述问题的某种情况是因为,这段代码也违背了单一职责原则,做了不只一件事情,这才导致不好复用,解决办法是进行方法的细分,才能更好复用。也可以考虑template method来处理差异的部分。

测试

非常惭愧的是,在我经历的项目中,测试(尤其是单元测试)一直都没有得到足够的重视,也没有试行过TDD。正因为缺失,才更感良好测试的珍贵。

我们常说,好的代码需要有可读性、可维护性、可扩展性,好的代码、架构需要不停的重构、迭代,但自动化测试是保证这一切的基础,没有高覆盖率的、自动化的单元测试、回归测试,谁都不敢去修改代码,只能任其腐烂。

即使针对核心模块写了单元测试,一般也很随意,认为这只是测试代码,配不上生产代码的地位,以为只要能跑通就行了。这就导致测试代码的可读性、可维护性非常差,然后导致测试代码很难跟随生产代码一起更新、演化,最后导致测试代码失效。所以说,脏测试 - 等同于 - 没测试。

因此,测试代码的三要素:可读性,可读性,可读性。

对于测试的原则、准则如下:

  • You are not allowed to write any production code unless it is to make a failing unit test pass. 没有测试之前不要写任何功能代码
  • You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures. 只编写恰好能够体现一个失败情况的测试代码
  • You are not allowed to write any more production code than is sufficient to pass the one failing unit test. 只编写恰好能通过测试的功能代码

测试的FIRST准则:

  • 快速(Fast)测试应该够快,尽量自动化。
  • 独立(Independent) 测试应该应该独立。不要相互依赖
  • 可重复(Repeatable) 测试应该在任何环境上都能重复通过。
  • 自我验证(Self-Validating) 测试应该有bool输出。不要通过查看日志这种低效率方式来判断测试是否通过
  • 及时(Timely) 测试应该及时编写,在其对应的生产代码之前编写

BLOG地址:www.liangsonghua.com

关注微信公众号:松华说,获取更多精彩!

公众号介绍:分享在京东工作的技术感悟,还有JAVA技术和业内最佳实践,大部分都是务实的、能看懂的、可复现的

本文转载自: 掘金

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

【译】【14K+ Star】 Kotlin 新秀 Coil

发表于 2020-06-08

前言

  • 原标题: Coil vs Picasso vs Glide: Get Ready… Go!
  • 原地址: proandroiddev.com/coil-vs-pic…
  • 原作者:Miguel Ángel Ruiz López
  • 译者:hi-dhl
  • 本文已收录于仓库 Technical-Article-Translation

Coil 作为图片加载库的新秀,和 Glide、Picasso 这些老牌图片库相比,它们的优缺点是什么以及 Coil 未来的展望?先来了解一下什么是 Coil。

Coil 是基于 Kotlin 开发的首个图片加载库,来自 Instacart 团队,来看看官网对它的最新的介绍。

  • R8:Coil 下完全兼容 R8,所以不需要添加任何与 Coil 相关的混淆器规则。
  • Fast:Coil 进行了许多优化,包括内存和磁盘缓存,对内存中的图片进行采样,重新使用位图,自动暂停/取消请求等等。
  • Lightweight:Coil 为你的 APK 增加了 2000 个方法(对于已经使用了 OkHttp 和协程的应用程序),这与 Picasso 相当,明显少于 Glide 和 Fresco。
  • Easy to use:Coil 利用了 Kotlin 的语言特性减少了样版代码。
  • Modern:使用了大量的高级特性,例如协程、OkHttp、和 androidX lifecycle 跟踪生命周期状态的,Coil 是目前唯一支持 androidX lifecycle 的库。

通过这篇文章你将学习到以下内容,将在译者思考部分会给出相应的答案

  • Kotlin 如何使用一行代码交换两个变量?
  • Coil 和 Glide 和 Picasso 比较,它们优缺点是什么?
  • Kotlin 作为 Android 开发的首选语言,我们该如何进行选择 Coil、Glide 和 Picasso?
  • 如何在项目中使用 Coil?
  • Coil、Glide 和 Picasso 使用上大比拼?
  • Coil 的动态图片采样是什么?

接下来演示中表格中的数字,可能很难理解,但是为了更好地理解,在最后的部分,会以柱状图清晰的展示每个库的性能的对比,在译者思考部分会更深入的分析这三个图片加载库的性能,请耐心多读几遍,应该可以从中学到很多技巧。

译文

Coil 作为图片库的新秀,越来越受欢迎了,但是为什么会引起这么多人的关注?在当今主流的图片加载库环境中 Coil 是一股清流,它是轻量级的,因为它使用了许多 Android 开发者已经在他们的项目中包含的其他库(协程、Okhttp)。

当我第一次看到这个库时,我认为这些都是很好的改进,但是我很想知道和其他主流的图片加载库相比,这个库能带来哪些好处,这篇文章的主要目的是分析一下 Coil 的性能,接下来我们来对比一下 Coil、 Glide 和 Picasso。

我们如何测量

为了弄清楚这些库的性能如何,我们分别用它们实现了一个应用程序,这是一个简单的应用程序,它下载 10 张图片并以网格布局显示它们,如下图所示。

所有图片都是在同一时间可见的,几乎是在同一时间被请求的,因此,可以认为它们是并行加载。

  • 测试加载每张图片所需的时间:为了获得更准确的数据,图片被下载了十次将取平均值。
  • 计算加载图片列表所需的时间:这个数字很重要,因为图片是并行加载的,所以不能从单个图片推断。这个测试也做了十次测试将取平均值。

另一个需要测试的重要点是第一次加载图片和从缓存中加载它们所花费的时间,已经进行了多次测试,覆盖了上面的场景。

从网络下载

我们开始第一个场景,当缓存为空时,从网络中下载图片。

Glide

在下面的表中,您可以看到当缓存为空时,从网络中下载图片所用的时间。注意,这些时间是测试 10 次之后的平均值。

下表展示了加载完整的图片列表所需的时间,以及平均时间。

Picasso

Picasso 的测试和 Glide 相同,当缓存为空时,从网络中下载图片,测试 10 次左右所用的时间的平均值。

以及加载完整的图片列表所需要的时间,以及平均时间。

Coil

现在来看一下今天主角 Coil ,和 Picasso、Glide 做相同的测试,当缓存为空时,从网络下载 10 次左右所用的时间的平均值。

以及加载完整的图片列表所需的时间,以及平均时间。

从缓存中加载

现在开始另外一个场景测试,当缓存不为空时,加载图片所需要的时间。

Glide

从缓存中加载图片所用时间,如下表所示。

从缓存中加载完整的图片列表所需的时间,以及平均时间。

Picasso

测试用例和 Glide 相同,从缓存中加载图片所用时间,如下表所示。

以及从缓存中加载完整的图片列表所需的时间,以及平均时间。

Coil

最后我们来看一下 Coil 如何呢,从缓存中加载图片所用时间,如下表所示。

同样的从缓存中加载完整的图片列表所需的时间,以及平均时间。

结论

为了更好地理解我们在测试中得到的结果,我们可以从下面图表中看到这些数字,反应了从网络下载每个图片时的结果。

Glide 最快 Picasso 和 Coil 几乎相同。

但是当我们从缓存中加载的时候,正如你在下面的图片中看到的,在大多数情况下,Glide 最快,Coil 其次,Picasso 最慢。

另一个重要的测试加载完整的图片列表所花费的时间,这些数字非常重要,因为这是用户等待看到整个图片列表的时间。当图片从网络加载时,Glide 是最快的,其次是Picasso,Coil是最慢的。

从缓存加载的结果是不同的。Glide 和 Coil 几乎相同,Picasso 是最慢的。

从这些数字中我们可以得出几个结论:

  • 有许多场景需要测试,例如,下载大图片,调整图片大小以适应容器等等。因此,我不能说其他情况下的结果可能会有很大的不同。
  • 正如您所看到的,统计数据是一门包含大量数据的科学,因此对每个场景进行 10 次测试不足以具体的说明那个库最好,但是我们可以粗略地了解性能。
  • Glide 似乎在大多数情况下更快,但数量一般不是很大情况。如果你需要很好地执行,或者你正在下载很多图片,这可能对你来说是非常有用。此外,如果我们使用大图片,这些测试的结果可能会改变。
  • Coil 作为图片加载库的新秀,未来它的性能可能会有很大提高,现在只是我们将它与成熟的图片加载库进行比较的结果。

译者思考

作者从以下场景对 Coil、Glide、Picasso 做了全面的测试。

  • 当缓存为空时,从网络中下载图片的平均时间。
+ 从网络中下载图片所用的时间。
结果:Glide 最快 Picasso 和 Coil 几乎相同。
+ 加载完整的图片列表所用的时间,以及平均时间。
结果:Glide 是最快的,其次是Picasso,Coil是最慢的。
  • 当缓存不为空时,从缓存中加载图片的平均时间。
+ 从缓存中加载图片所用的时间。
结果:Glide 最快,Coil 其次,Picasso 最慢。
+ 加载完整的图片列表所用的时间,以及平均时间。
结果:Glide 和 Coil 几乎相同,Picasso 是最慢的。

图片加载库的选择是我们应用程序中最重要的部分之一,根据以上结果,如果你的应用程序中没有大量使用图片的时候,我认为使用 Coil 更好,原因有以下几点:

  • 与 Glide 和 Fresco 类似,Coil 支持位图池,位图池是一种重新使用不再使用的位图对象的技术,这可以显著提高内存性能(特别是在oreo之前的设备上),但是它会造成一些 API 限制。
  • Coil 是基于 Kotlin 开发的,为 Kotlin 使用而设计的,所以代码通常更简洁更干净。
  • Kotlin 作为 Android 首选语言,Coil 是为 Kotlin 而设计的,Coil 在未来肯定会大方光彩。
  • 从 Glide、Picasso 迁移到 Coil 是非常的容易,API 非常的相似。
  • Coil 支持 androidX lifecycle 跟踪生命周期状态,也是是目前唯一支持 androidX lifecycle 的网络图片加载库。
  • Coil 支持动态图片采样,假设本地有一个 500x500 的图片,当从磁盘读取 500x500 的映像时,我们将使用 100x100 的映像作为占位符。

如果你的是图片类型的应用,应用程序中包含了大量的图片,图片加载的速度是整个应用的核心指标之一,那么现在还不适合使用 Coil。

Coil 涵盖了 Glide、Picasso 等等图片加载库所支持的功能,除此之外 Coil 还有一个功能 动态图片采样。

动态图片采样

更多关于图片采样信息可以访问 Coil ,这里简单的说明一下,假设本地有一个 500x500 的图片,当从磁盘读取 500x500 的图片时,将使用 100x100 的映像作为占位符,等待加载完成之后才会完全显示,用官方的一张动图显示过程如下。

img

这种淡入动画效果,在视觉上体验非常舒适,占位符在主线程上设置,这样可以防止在 ImageView 为空的情况下出现白色闪烁,接下来让我们来看看如何使用 Coil?Coil、Glide 和 Picasso 使用上大比拼?

如何使用 Coil

添加 Coil 依赖

1
arduino复制代码implementation "io.coil-kt:coil:0.11.0"

在 App moudule 下 build.gradle 文件中添加如下代码

1
2
3
ini复制代码kotlinOptions {
jvmTarget = "1.8"
}

在项目中调用你需要的代码,这里汇总了 Coil 所有使用方式

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
scss复制代码
// 将图片加载到 ImageView 中, 并开启图片采样
// 用到了 Kotlin 的高级特性扩展,调用更加简单
imageView.load("https://www.example.com/image.jpg"){
crossfade(true)
}

inline fun ImageView.load(
uri: String?,
imageLoader: ImageLoader = Coil.imageLoader(applicationContext),
builder: LoadRequestBuilder.() -> Unit = {}
): RequestDisposable {
return imageLoader.load(context, uri) {
target(this@load)
builder()
}
}

// Coil 支持 Uri,File, String,HttpUrl, Bitmap, Drawable, DrawableId
imageView.load(R.drawable.ic_launcher_background)
imageView.load(File("/path/to/image.jpg"))
imageView.load("content://com.android.externalstorage/image.jpg")
// ......

// Coil 提供了四种转换: 模糊,圆形剪裁,灰度和圆角
imageView.load("https://www.example.com/image.jpg") {
crossfade(true)
placeholder(R.drawable.ic_launcher_background)
transformations(CircleCropTransformation())
}

// Coil 是一个 object 单例
val imageLoader1 = Coil.imageLoader(applicationContext)

// 可以创建 或者 调用第三方库(Koin) 注入自己的实例。
val imageLoader2 = ImageLoader(applicationContext)

// 在某些情况下,需要远程下载然后进行回调
var request = LoadRequest.Builder(applicationContext)
.data("https://www.example.com/image.jpg")
.target { drawable ->
// Handle the successful result.
}
.build()
imageLoader2.execute(request)

Coil、Glide 和 Picasso 使用上大比拼

Coil 基于 Kotlin 而设计,自然也拥有了 Kotlin 的高级函数的特性,使用比 Glide 和 Picasso 要简单很多。

基本使用

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码// Coil - 用到了 Kotlin 的高级特性扩展,一行代码加载图片
imageView.load(url)

// Glide
Glide.with(context)
.load(url)
.into(imageView)

// Picasso
Picasso.get()
.load(url)
.into(imageView)

后台线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
scss复制代码// Coil:无阻塞和线程安全
val imageLoader = Coil.imageLoader(context)
val request = GetRequest.Builder(context)
.data(url)
.size(width, height)
.build()
val drawable = imageLoader.execute(request).drawable

// Glide:阻塞当前线程,一定不能从主线程调用
val drawable = Glide.with(context)
.load(url)
.submit(width, height)
.get()

// Picasso:阻塞当前线程,一定不能从主线程调用
val drawable = Picasso.get()
.load(url)
.resize(width, height)
.get()

自动检测 scaleType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scss复制代码imageView.scaleType = ImageView.ScaleType.FIT_CENTER

// Coil:自动检测 scaleType
imageView.load(url) {
placeholder(placeholder)
}

// Glide
Glide.with(context)
.load(url)
.placeholder(placeholder)
.fitCenter()
.into(imageView)

// Picasso
Picasso.get()
.load(url)
.placeholder(placeholder)
.fit()
.into(imageView)

Coil 实战

Jetpack 实战项目 PokemonGo(神奇宝贝)基于 MVVM 架构和 Repository 设计模式开发的一个小型的 App 项目,涉及到技术:Paging3(network + db),Dagger-Hilt,App Startup,DataBinding,Room,Motionlayout,Kotlin Flow,Coil 等等。

在这个项目中加载图片使用了 Coil ,可以去下载体验一下。

仓库地址:https://github.com/hi-dhl/PokemonGo

Kotlin 小技巧

如何实现一行代码交换两个变量?我们先来回顾一下 JAVA 的做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
css复制代码int a = 1;
int b = 2;

// JAVA - 中间变量
int temp = a;
a = b;
b = temp;
System.out.println("a = "+a +" b = "+b); // a = 2 b = 1

// JAVA - 加减运算
a = a + b;
b = a - b;
a = a - b;
System.out.println("a = " + a + " b = " + b); // a = 2 b = 1

// JAVA - 位运算
a = a ^ b;
b = a ^ b;
a = a ^ b;
System.out.println("a = " + a + " b = " + b); // a = 2 b = 1

// Kotlin
a = b.also { b = a }
println("a = ${a} b = ${b}") // a = 2 b = 1

全文到这里就结束了, 如果有帮助 点个赞 就是对我最大的鼓励!

参考文献

  • proandroiddev.com/coil……

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译相关的文章,目前正在翻译一系列欧美精选文章,请持续关注,除了翻译还有对每篇欧美文章思考,如果对你有帮助,请帮我点个赞,感谢!!!期待与你一起成长。

计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请帮我点个赞,我会陆续完成更多 Jetpack 新成员的项目实践。

算法

由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序

  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……

每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长

Android 10 源码系列

正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库

  • 0xA01 Android 10 源码分析:APK 是如何生成的
  • 0xA02 Android 10 源码分析:APK 的安装流程
  • 0xA03 Android 10 源码分析:APK 加载流程之资源加载
  • 0xA04 Android 10 源码分析:APK 加载流程之资源加载(二)
  • 0xA05 Android 10 源码分析:Dialog 加载绘制流程以及在 Kotlin、DataBinding 中的使用
  • 0xA06 Android 10 源码分析:WindowManager 视图绑定以及体系结构
  • 0xA07 Android 10 源码分析:Window 的类型 以及 三维视图层级分析
  • 更多

Android 应用系列

  • 如何高效获取视频截图
  • 如何在项目中封装 Kotlin + Android Databinding
  • 再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度

精选译文

  • [译][Google工程师] 刚刚发布了 Fragment 的新特性 “Fragment 间传递数据的新方式” 以及源码分析
  • [译][Google工程师] 详解 FragmentFactory 如何优雅使用 Koin 以及部分源码分析
  • [译][2.4K Start] 放弃 Dagger 拥抱 Koin
  • [译][5k+] Kotlin 的性能优化那些事
  • [译] 解密 RxJava 的异常处理机制

工具系列

  • 为数不多的人知道的 AndroidStudio 快捷键(一)
  • 为数不多的人知道的 AndroidStudio 快捷键(二)
  • 关于 adb 命令你所需要知道的
  • 10分钟入门 Shell 脚本编程

逆向系列

  • 基于 Smali 文件 Android Studio 动态调试 APP
  • 解决在 Android Studio 3.2 找不到 Android Device Monitor 工具

本文转载自: 掘金

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

看完这篇Redis缓存三大问题,保你面试能造火箭,工作能拧螺

发表于 2020-06-07

前言

日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题。

一旦涉及大数据量的需求,如一些商品抢购的情景,或者主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度问题有严重的性能弊端,详细的磁盘读写原理请参考这一片[]。

在这一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。

为了克服上述的问题,项目通常会引入NoSQL技术,这是一种基于内存的数据库,并且提供一定的持久化功能。

Redis技术就是NoSQL技术中的一种。Redis缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。

但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么就不能使用缓存。

另外的一些典型问题就是,缓存穿透、缓存击穿和缓存雪崩。本篇文章从实际代码操作,来提出解决这三个缓存问题的方案,毕竟Redis的缓存问题是实际面试中高频问点,理论和实操要兼得。

缓存穿透

缓存穿透是指查询一条数据库和缓存都没有的一条数据,就会一直查询数据库,对数据库的访问压力就会增大,缓存穿透的解决方案,有以下两种:

  1. 缓存空对象:代码维护较简单,但是效果不好。
  2. 布隆过滤器:代码维护复杂,效果很好。

缓存空对象

缓存空对象是指当一个请求过来缓存中和数据库中都不存在该请求的数据,第一次请求就会跳过缓存进行数据库的访问,并且访问数据库后返回为空,此时也将该空对象进行缓存。

若是再次进行访问该空对象的时候,就会直接击中缓存,而不是再次数据库,缓存空对象实现的原理图如下:

缓存空对象的实现代码如下:

1
2
3
4
5
复制代码public class UserServiceImpl {
@Autowired
UserDAO userDAO;
@Autowired
RedisCache redisCache;

复制代码 public User findUser(Integer id) {
Object object = redisCache.get(Integer.toString(id));
// 缓存中存在,直接返回
if(object != null) {
// 检验该对象是否为缓存空对象,是则直接返回null
if(object instanceof NullValueResultDO) {
return null;
}
return (User)object;
} else {
// 缓存中不存在,查询数据库
User user = userDAO.getUser(id);
// 存入缓存
if(user != null) {
redisCache.put(Integer.toString(id),user);
} else {
// 将空对象存进缓存
redisCache.put(Integer.toString(id), new NullValueResultDO());
}
return user;
}
}

1
2

`}`

缓存空对象的实现代码很简单,但是缓存空对象会带来比较大的问题,就是缓存中会存在很多空对象,占用内存的空间,浪费资源,一个解决的办法就是设置空对象的较短的过期时间,代码如下:

1
2
复制代码// 再缓存的时候,添加多一个该空对象的过期时间60秒
redisCache.put(Integer.toString(id), new NullValueResultDO(),60);

布隆过滤器

布隆过滤器是一种基于概率的数据结构,主要用来判断某个元素是否在集合内,它具有运行速度快(时间效率),占用内存小的优点(空间效率),但是有一定的误识别率和删除困难的问题。它只能告诉你某个元素一定不在集合内或可能在集合内。

在计算机科学中有一种思想:空间换时间,时间换空间。一般两者是不可兼得,而布隆过滤器运行效率和空间大小都兼得,它是怎么做到的呢?

在布隆过滤器中引用了一个误判率的概念,即它可能会把不属于这个集合的元素认为可能属于这个集合,但是不会把属于这个集合的认为不属于这个集合,布隆过滤器的特点如下:

  1. 一个非常大的二进制位数组 (数组里只有0和1)
  2. 若干个哈希函数
  3. 空间效率和查询效率高
  4. 不存在漏报(False Negative):某个元素在某个集合中,肯定能报出来。
  5. 可能存在误报(False Positive):某个元素不在某个集合中,可能也被爆出来。
  6. 不提供删除方法,代码维护困难。
  7. 位数组初始化都为0,它不存元素的具体值,当元素经过哈希函数哈希后的值(也就是数组下标)对应的数组位置值改为1。

实际布隆过滤器存储数据和查询数据的原理图如下:

可能很多读者看完上面的特点和原理图,还是看不懂,别急下面通过图解一步一步的讲解布隆过滤器,总而言之一句简单的话概括就是布隆过滤器是一个很大二进制的位数组,数组里面只存0和1。

初始化的布隆过滤器的结构图如下:

以上只是画了布隆过滤器的很小很小的一部分,实际布隆过滤器是非常大的数组(这里的大是指它的长度大,并不是指它所占的内存空间大)。

那么一个数据是怎么存进布隆过滤器的呢?

当一个数据进行存入布隆过滤器的时候,会经过如干个哈希函数进行哈希(若是对哈希函数还不懂的请参考这一片[]),得到对应的哈希值作为数组的下标,然后将初始化的位数组对应的下标的值修改为1,结果图如下:


当再次进行存入第二个值的时候,修改后的结果的原理图如下:

所以每次存入一个数据,就会哈希函数的计算,计算的结果就会作为下标,在布隆过滤器中有多少个哈希函数就会计算出多少个下标,布隆过滤器插入的流程如下:

  1. 将要添加的元素给m个哈希函数
  2. 得到对应于位数组上的m个位置
  3. 将这m个位置设为1

那么为什么会有误判率呢?

假设在我们多次存入值后,在布隆过滤器中存在x、y、z这三个值,布隆过滤器的存储结构图如下所示:

当我们要查询的时候,比如查询a这个数,实际中a这个数是不存在布隆过滤器中的,经过2哥哈希函数计算后得到a的哈希值分别为2和13,结构原理图如下:

经过查询后,发现2和13位置所存储的值都为1,但是2和13的下标分别是x和z经过计算后的下标位置的修改,该布隆过滤器中实际不存在a,那么布隆过滤器就会误判改值可能存在,因为布隆过滤器不存元素值,所以存在误判率。

那么具体布隆过布隆过滤的判断的准确率和一下两个因素有关:

  1. 布隆过滤器大小:越大,误判率就越小,所以说布隆过滤器一般长度都是非常大的。
  2. 哈希函数的个数:哈希函数的个数越多,那么误判率就越小。

那么为什么不能删除元素呢?

原因很简单,因为删除元素后,将对应元素的下标设置为零,可能别的元素的下标也引用改下标,这样别的元素的判断就会收到影响,原理图如下:

当你删除z元素之后,将对应的下标10和13设置为0,这样导致x和y元素的下标受到影响,导致数据的判断不准确,所以直接不提供删除元素的api。

以上说的都是布隆过滤器的原理,只有理解了原理,在实际的运用才能如鱼得水,下面就来实操代码,手写一个简单的布隆过滤器。

对于要手写一个布隆过滤器,首先要明确布隆过滤器的核心:

  • 若干哈希函数
  • 存值得Api
  • 判断值得Api

实现得代码如下:

1
2
3
4
5
6
7
8
9
复制代码public class MyBloomFilter {
// 布隆过滤器长度
private static final int SIZE = 2 << 10;
// 模拟实现不同的哈希函数
private static final int[] num= new int[] {5, 19, 23, 31,47, 71};
// 初始化位数组
private BitSet bits = new BitSet(SIZE);
// 用于存储哈希函数
private MyHash[] function = new MyHash[num.length];

复制代码// 初始化哈希函数
public MyBloomFilter() {
for (int i = 0; i < num.length; i++) {
function [i] = new MyHash(SIZE, num[i]);
}
}

// 存值Api
public void add(String value) {
// 对存入得值进行哈希计算
for (MyHash f: function) {
// 将为数组对应的哈希下标得位置得值改为1
bits.set(f.hash(value), true);
}
}

// 判断是否存在该值得Api
public boolean contains(String value) {
if (value == null) {
return false;
}
boolean result= true;
for (MyHash f : func) {
result= result&& bits.get(f.hash(value));
}
return result;
}

1
2

`}`

哈希函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public static class MyHash {
private int cap;
private int seed;
// 初始化数据
public MyHash(int cap, int seed) {
this.cap = cap;
this.seed = seed;
}
// 哈希函数
public int hash(String value) {
int result = 0;
int len = value.length();
for (int i = 0; i < len; i++) {
result = seed * result + value.charAt(i);
}
return (cap - 1) & result;
}
}

布隆过滤器测试代码如下:

1
2
3
4
5
6
7
复制代码    public static void test {
String value = "4243212355312";
MyBloomFilter filter = new MyBloomFilter();
System.out.println(filter.contains(value));
filter.add(value);
System.out.println(filter.contains(value));
}

以上就是手写了一个非常简单得布隆过滤器,但是实际项目中可能事由牛人或者大公司已经帮你写好的,如谷歌的Google Guava,只需要在项目中引入一下依赖:

1
2
3
4
5
复制代码<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>27.0.1-jre</version>
</dependency>

实际项目中具体的操作代码如下:

1
复制代码public static void MyBloomFilterSysConfig {

复制代码 @Autowired
OrderMapper orderMapper

// 1.创建布隆过滤器 第二个参数为预期数据量10000000,第三个参数为错误率0.00001
BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName(“utf-8”)),10000000, 0.00001);
// 2.获取所有的订单,并将订单的id放进布隆过滤器里面
List<Order> orderList = orderMapper.findAll()
for (Order order;orderList ) {
Long id = order.getId();
bloomFilter.put(“” + id);
}

1
2

`}`

在实际项目中会启动一个系统任务或者定时任务,来初始化布隆过滤器,将热点查询数据的id放进布隆过滤器里面,当用户再次请求的时候,使用布隆过滤器进行判断,改订单的id是否在布隆过滤器中存在,不存在直接返回null,具体操作代码:

1
2
复制代码// 判断订单id是否在布隆过滤器中存在
bloomFilter.mightContain("" + id)

布隆过滤器的缺点就是要维持容器中的数据,因为订单数据肯定是频繁变化的,实时的要更新布隆过滤器中的数据为最新。

缓存击穿

缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。

缓存击穿这里强调的是并发,造成缓存击穿的原因有以下两个:

  1. 该数据没有人查询过 ,第一次就大并发的访问。(冷门数据)
  2. 添加到了缓存,reids有设置数据失效的时间 ,这条数据刚好失效,大并发访问(热点数据)

对于缓存击穿的解决方案就是加锁,具体实现的原理图如下:

当用户出现大并发访问的时候,在查询缓存的时候和查询数据库的过程加锁,只能第一个进来的请求进行执行,当第一个请求把该数据放进缓存中,接下来的访问就会直接集中缓存,防止了缓存击穿。

业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁,单机的话用普通的锁(synchronized、Lock)就够了。

下面以一个获取商品库存的案例进行代码的演示,单机版的锁实现具体实现的代码如下:

1
2
3
4
5
6
复制代码// 获取库存数量
public String getProduceNum(String key) {
try {
synchronized (this) { //加锁
// 缓存中取数据,并存入缓存中
int num= Integer.parseInt(redisTemplate.opsForValue().get(key));

复制代码 if (num> 0) {
//没查一次库存-1
redisTemplate.opsForValue().set(key, (num- 1) + “”);
System.out.println(“剩余的库存为num:” + (num- 1));
} else {
System.out.println(“库存为0”);
}
}
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
}
return “OK”;

1
2

`}`

分布式的锁实现具体实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码public String getProduceNum(String key) {
// 获取分布式锁
RLock lock = redissonClient.getLock(key);
try {
// 获取库存数
int num= Integer.parseInt(redisTemplate.opsForValue().get(key));
// 上锁
lock.lock();
if (num> 0) {
//减少库存,并存入缓存中
redisTemplate.opsForValue().set(key, (num - 1) + "");
System.out.println("剩余库存为num:" + (num- 1));
} else {
System.out.println("库存已经为0");
}
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
//解锁
lock.unlock();
}
return "OK";
}

缓存雪崩

缓存雪崩 是指在某一个时间段,缓存集中过期失效。此刻无数的请求直接绕开缓存,直接请求数据库。

造成缓存雪崩的原因,有以下两种:

  1. reids宕机
  2. 大部分数据失效

比如天猫双11,马上就要到双11零点,很快就会迎来一波抢购,这波商品在23点集中的放入了缓存,假设缓存一个小时,那么到了凌晨24点的时候,这批商品的缓存就都过期了。

而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰,对数据库造成压力,甚至压垮数据库。

缓存雪崩的原理图如下,当正常的情况下,key没有大量失效的用户访问原理图如下:

当某一时间点,key大量失效,造成的缓存雪崩的原理图如下:

对于缓存雪崩的解决方案有以下两种:

  1. 搭建高可用的集群,防止单机的redis宕机。
  2. 设置不同的过期时间,防止同意之间内大量的key失效。

针对业务系统,永远都是具体情况具体分析,没有最好,只有最合适。于缓存其它问题,缓存满了和数据丢失等问题,我们后面继续深入的学习。最后也提一下三个词LRU、RDB、AOF,通常我们采用LRU策略处理溢出,Redis的RDB和AOF持久化策略来保证一定情况下的数据安全。

本文转载自: 掘金

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

因为 MongoDB 没入门,我丢了一份实习工作

发表于 2020-06-07

有时候不得不感慨一下,系统升级真的是好处多多,不仅让我有机会重构了之前的烂代码,也满足了我积极好学的虚荣心。你看,Redis 入门了、Elasticsearch 入门了,这次又要入门 MongoDB,感觉自己变秃的同时,也变强大了。

小伙伴们在继续阅读之前,我必须要声明一点,我对 MongoDB 并没有进行很深入的研究,仅仅是因为要用,就学一下。但作为一名负责任的技术博主,我是花了心思的,这篇入门教程,小伙伴们读完后绝对会感到满意,忍不住点赞。

当然了,小伙伴们遇到文章中有错误的地方,不要手下留情,可以组团过来捶我,但要保证一点,不要打脸,我怕毁容。

01、MongoDB 是什么

MongoDB 是一个基于分布式的文件存储数据库,旨在为 Web 应用提供可扩展的高性能数据存储解决方案。

以上引用来自于官方,不得不说,解释得文绉绉的。那就让我来换一种通俗的说法给小伙伴们解释一下,MongoDB 将数据存储为一个文档(类似于 JSON 对象),数据结构由键值对组成,类似于 Java 中的 Map,通过 key 的方式访问起来效率就高得多,对吧?这也是 MongoDB 最重要的特点。

MongoDB 提供了企业版(功能更强大)和社区版,对于我们开发者来说,拿社区版来学习和使用就足够了。MongoDB 的驱动包很多,常见的编程语言都有覆盖到,比如说 Java、JavaScript、C++、C#、Python 等等。

很多知名的互联网公司都在用 MongoDB,比如说谷歌、Facebook、eBay 等等。总之,值得信赖,小伙伴们放心入门,技多不压身啊,就当是给自己一次学习的机会。

02、安装 MongoDB

MongoDB 针对不同的操作系统有不同的安装包,我们这篇入门的文章就以 Windows 为例吧。

官网下载地址如下:

www.mongodb.com/download-ce…

最新的版本是 4.2.6,我选择的是安装版,msi 格式的,264M 左右。下载完就可以双击运行安装,傻瓜式的。

建议选择「Custom」自定义安装,如下图所示。

以服务模式运行,并配置好数据和日志目录,如下图所示。

建议取消勾选安装 MongoDB 的图形化客户端工具,否则安装速度慢到你想要去扣会手机。

安装完成后进入到 bin 目录下,双击 mongo.exe 文件就可以连接到 MongoDB 服务了。

1)MongoDB 的默认端口号为 27017。

2)MongoDB 的版本号为 4.2.6。

默认会连接到 test 文档(相当于数据),可以通过 db 命令查询。

还可以运行一些简单的算术运算:

那如何停止服务呢?可以直接点击右上角的 X 号——粗暴、壁咚。

03、安装 Robo 3T

Robo 3T 提供了对 MongoDB 和 SCRAM-SHA-256(升级的 mongo shell)的支持,是一款轻量级的 MongoDB 客户端工具。

下载地址如下:

robomongo.org/download

最新的版本是 1.3,选择 zip 格式进行下载,23M 左右。下载完成后,解压就行了。

包目录不再一一解释了,进入 bin 目录下,双击运行 robo3t.exe 文件,启动 Robo 3T 客户端。

点击「Create」创建一个 MongoDB 的连接。

连接成功后,就可以操作 MongoDB 了。

(不过,小伙伴们这时候也不太知道该怎么操作,毕竟 MongoDB 的一些相关概念还不清楚,无从下手啊)

04、MongoDB 的相关概念

随着互联网的极速发展,用户数据也越来越庞大,NoSQL 数据库的发展能够很好地处理这些大的数据,MongoDB 是 NoSQL 数据库中的一个典型的代表。

说到这,可能有些小伙伴们还不知道 NoSQL 是啥意思,我简单解释一下。NoSQL 可不是没有 SQL 的意思,它实际的含义是 Not Only SQL,也就是“不仅仅是 SQL”,指的是非关系型数据库,和传统的关系型数据库 MySQL、Oracle 不同。

MongoDB 命名源于英文单词 humongous,意思是「巨大无比」,可以看得出 MongoDB 的野心。MongoDB 的数据以类似于 JSON 格式的二进制文档存储:

1
2
3
4
5
复制代码{
name: "沉默王二",
age: 18,
hobbies: ["写作", "敲代码"]
}

在进行下一步之前,需要先来理解 MongoDB 中的几个关键概念,比如说什么是集合,什么是文档,什么是字段等等。MongoDB 虽然是非关系型数据库,但和关系型数据库非常相似。

看完上面这幅图(图片来源于好朋友 macrozheng 的文章),是不是瞬间就清晰了?

05、在 Java 中使用 MongoDB

有些小伙伴可能会问,“二哥,我是一名 Java 程序员,我该如何在 Java 中使用 MongoDB 呢?”这个问题问得好,这就来,这就来。

第一步,在项目中添加 MongoDB 驱动依赖:

1
2
3
4
5
复制代码<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-sync</artifactId>
<version>4.0.3</version>
</dependency>

第二步,新建测试类 MongoDBTest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public class MongoDBTest {
public static void main(String[] args) {
MongoClient mongoClient = MongoClients.create();
MongoDatabase database = mongoClient.getDatabase("mydb");
MongoCollection<Document> collection = database.getCollection("test");

Document doc = new Document("name", "沉默王二")
.append("age", "18")
.append("hobbies", Arrays.asList("写作", "敲代码"));
collection.insertOne(doc);

System.out.println("集合大小:" +collection.countDocuments());

Document myDoc = collection.find().first();
System.out.println("文档内容:" + myDoc.toJson());
}
}

1)MongoClient 为 MongoDB 提供的客户端连接对象,不指定主机名和端口号的话,默认就是“localhost”和“27017”。

如果小伙伴想自定义主机名和端口号的话,也可以通过字符串的形式:

1
复制代码MongoClient mongoClient = MongoClients.create("mongodb://localhost:27017");

是不是感觉和 MySQL 的连接字符串挺像的?

2)getDatabase() 方法用于获取指定名称的数据库,如果数据库已经存在,则直接返回该 DB 对象(MongoDatabase),否则就创建一个再返回(省去了判空的操作,非常人性化)。

3)getCollection() 方法用于获取指定名称的文档对象,如果文档已经存在,则直接返回该 Document 的集合对象,否则就创建一个再返回(和 getDatabase() 方法类似)。

有了文档对象(MongoCollection)后,就可以往里面添加具体的文档内容了。

1
2
3
复制代码 Document doc = new Document("name", "沉默王二")
.append("age", "18")
.append("hobbies", Arrays.asList("写作", "敲代码"));

Document 对象来源于 org.bson 包下,可以在实例化该对象之后通过 append() 方法添加对应的键值对,非常方便,就像 String 类的 append() 方法一样。

有了文档对象后,就可以通过 insertOne() 方法将文档添加到集合当中了。

4)countDocuments() 方法用于获取集合中的文档数目。

5)要查询文档,可以通过 find() 方法,它返回一个 FindIterable 对象,first() 方法可以返回当前集合中的第一个文档对象。

好了,来看一下程序的输出结果:

1
2
复制代码集合大小:1
文档内容:{"_id": {"$oid": "5ebcaa76465cab3f18b93e1a"}, "name": "沉默王二", "age": "18", "hobbies": ["写作", "敲代码"]}

完全符合我们的预期,perfect!

也可以通过 Robo 3T 查看“mydb”数据库,结果如下图所示。

06、鸣谢

好了,我亲爱的小伙伴们,以上就是本文的全部内容了,是不是看完后很想实操一把 MongoDB,赶快行动吧!如果你在学习的过程中遇到了问题,欢迎随时和我交流,虽然我也是个菜鸟,但我有热情啊。

另外,如果你想写入门级别的文章,这篇就是最好的范例。

我是沉默王二,一枚有趣的程序员。如果觉得文章对你有点帮助,请微信搜索「 沉默王二 」第一时间阅读,回复【666】更有我为你精心准备的 500G 高清教学视频(已分门别类)。

本文 GitHub 已经收录,有大厂面试完整考点,欢迎 Star。

原创不易,莫要白票,请你为本文点个赞吧,这将是我写作更多优质文章的最强动力。

本文转载自: 掘金

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

性能测试学习之多协议接口性能测试(七)

发表于 2020-06-06

“
你试图以离开来引起别人的注意,却不知道你是真的离开了。并没有任何人记住你。

“

测试需要了解接口是什么尤为重要,其实很多章节都提到过,接口时前后端以及各种业务定义的数据格式,学习接口,方便我们测试,诊断问题~

接口性能测试的目标

  • 发现应用程序的性能瓶颈
  • 发现数据库的性能瓶颈

接口性能测试的范围

  • 应用程序各项性能指标
  • 数据库各项性能指标

常见接口协议

  • HTTP 超文本传输协议
  • HTTPS 安全超文本传输协议
  • FTP 文件传输协议
  • TCP 网络控制协议
  • IP 互联网协议
  • UDP 用户数据协议
  • 此处省略N多协议

详解HTTP协议

  • 理解https协议
    • 默认端口: 443
    • 安全性的sI加密传输协议
    • 以安全为目标的http协议通道,可以理解为http协议的安全版
    • Https协议栈中的位置
      HTTPS
      ↑
      SSL/TLS
      ↑
      TPC / IP
      ↑
      数据链路层
  • 理解ftp协议
    • 默认端口: 21和20
    • http-与https都是面向网页的,而ftp是面向文件的
    • ftp使用两个并行的tcp连接来进行文件传输
    • ftp使用的两个并行tcp连接为控制连接和数据连接
    • 控制连接负责两个主机之间传输控制信息,如用户表示、口令,发送的命令等,运行端口为21
    • 数据连接用于实际传输一一个文件,运行端口为20 (主动模式)

实战JMeter如何进行ftp协议接口测试

  • 搭建环境FTP服务
    参考:jingyan.baidu.com/article/380…
    安装完毕后查看是否在运行 netstat -ntlp
1
复制代码tcp60 0 :::21 : ::*L ISTEN 30083/vsftpd

案例:本地liunx上新建一个文件

  • Jemter下载操作

1.添加FTP请求

2.配置填写

3.执行察看结果树

  • Jemter实现上传文件

1.配置

MIME参考手册:www.w3school.com.cn/media/media…

性能测试用例设计

要素

  • 被测系统及版本
  • 测试数据
  • 测试场景(包含异常场景)
  • 被测系统及版本
  • 预期结果
  • 预期性能指标
  • 被测系统
  • 版本号
  • 运行环境、配置
  • 测试场景(并发定义)
  • 预期性能指标
  • 实际结果
  • 测试结论
  • 测试人员
  • 如不通过说明瓶颈
  • 如通过说明最大性能指标
  • 容量规划

好了各位,以上就是这篇文章的全部内容了,能看到这里人啊,都是人才。

如果这个文章写得还不错,觉得「王采臣」我有点东西的话 求点赞👍求关注❤️求分享👥 对耿男我来说真的非常有用!!!

白嫖不好,创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!

王采臣 | 文 【原创】
如果本篇博客有任何错误,请批评指教,不胜感激 !
微信公众号:

本文转载自: 掘金

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

最近面试,一个SpringBoot居然问我了30个问题!

发表于 2020-06-06

❝
从最开始的,SSH到SpringMVC,随着Spring的发展,使得开发越来越容易了,SpringBoot已经成为Java程序员必会的一项,以下给小伙伴整理了30道相关面试题,也可以作为知识点,学习收藏起来。

❞

1.什么是SpringBoot?

通过Spring Boot,可以轻松地创建独立的,基于生产级别的Spring的应用程序,您可以“运行”它们。大多数Spring Boot应用程序需要最少的Spring配置。

2.SpringBoot的特征?

  • 创建独立的Spring应用程序
  • 直接嵌入Tomcat,Jetty或Undertow(无需部署WAR文件)
  • 提供固化的“starter”依赖项,以简化构建配置
  • 尽可能自动配置Spring和3rd Party库
  • 提供可用于生产的功能,例如指标,运行状况检查和外部化配置
  • 完全没有代码生成,也不需要XML配置

3.如何快速构建一个SpringBoot项目?

  • 通过Web界面使用。http://start.spring.io
  • 通过Spring Tool Suite使用。
  • 通过IntelliJ IDEA使用。
  • 使用Spring Boot CLI使用。

4.SpringBoot启动类注解?它是由哪些注解组成?

@SpringBootApplication

  • @SpringBootConfiguration:组合了 @Configuration 注解,实现配置文件的功能。
  • @EnableAutoConfiguration:打开自动配置的功能,也可以关闭某个自动配置的选项。
  • @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
  • @ComponentScan:Spring组件扫描

5.什么是yaml?

YAML(/ˈjæməl/,尾音类似camel骆驼)是一个可读性高,用来表达数据序列化的格式。YAML参考了其他多种语言,包括:C语言、Python、Perl。更具有结构性。

6.SpringBoot支持配置文件的格式?

1.properties

1
复制代码java.xiaokaxiu.name = xiaoka

2.yml

1
2
3
复制代码java:
xiaokaxiu:
name: xiaoka

7.SpringBoot启动方式?

  1. main方法
  2. 命令行 java -jar 的方式
  3. mvn/gradle

8.SpringBoot需要独立的容器运行?

不需要,内置了 Tomcat/Jetty。

9.SpringBoot配置途径?

  1. 命令行参数
  2. java:comp/env里的JNDI属性
  3. JVM系统属性
  4. 操作系统环境变量
  5. 随机生成的带random.*前缀的属性(在设置其他属性时,可以引用它们,比如${random. long})
  6. 应用程序以外的application.properties或者appliaction.yml文件
  7. 打包在应用程序内的application.properties或者appliaction.yml文件
  8. 通过@PropertySource标注的属性源
  9. 默认属性

tips:这个列表按照优先级排序,也就是说,任何在高优先级属性源里设置的属性都会覆盖低优先级的相同属性。

10.application.properties和application.yml文件可放位置?优先级?

  1. 外置,在相对于应用程序运行目录的/config子目录里。
  2. 外置,在应用程序运行的目录里。
  3. 内置,在config包内。
  4. 内置,在Classpath根目录。

这个列表按照优先级排序,优先级高的会覆盖优先级低的。

当然我们可以自己指定文件的位置来加载配置文件。

1
复制代码java -jar xiaoka.jar ———spring.config.location=/home/application.yml

11.SpringBoot自动配置原理?

@EnableAutoConfiguration (开启自动配置)
该注解引入了AutoConfigurationImportSelector,该类中的方法会扫描所有存在META-INF/spring.factories的jar包。

12.SpringBoot热部署方式?

  • spring-boot-devtools
  • Spring Loaded
  • Jrebel
  • 模版热部署

13.「bootstrap.yml」 和「application.yml」?

bootstrap.yml 优先于application.yml

14.SpringBoot如何修改端口号?

yml中:

1
2
复制代码server :
port : 8888

properties:

1
复制代码server.port = 8888

命令1:

1
复制代码java -jar xiaoka.jar ——— server.port=8888

命令2:

1
复制代码java - Dserver.port=8888 -jar xiaoka.jar

15.开启SpringBoot特性的几种方式?

  1. 继承spring-boot-starter-parent项目
  2. 导入spring-boot-dependencies项目依赖

16.SpringBoot如何兼容Spring项目?

在启动类加:

@ImportResource(locations = {“classpath:spring.xml”})

17.SpringBoot配置监控?

1
2
3
4
复制代码<dependency> 
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

18.获得Bean装配报告信息访问哪个端点?

/beans 端点

19.关闭应用程序访问哪个端点?

/shutdown

该端点默认是关闭的,如果开启,需要如下设置。

1
2
3
复制代码 endpoints:
shutdown:
enabled: true

或者properties格式也是可以的。

20.查看发布应用信息访问哪个端点?

/info

21.针对请求访问的几个组合注解?

@PatchMapping

@PostMapping

@GetMapping

@PutMapping

@DeleteMapping

22.SpringBoot 中的starter?

可以理解成对依赖的一种合成,starter会把一个或一套功能相关依赖都包含进来,避免了自己去依赖费事,还有各种包的冲突问题。大大的提升了开发效率。

并且相关配置会有一个默认值,如果我们自己去配置,就会覆盖默认值。

23.SpringBoot集成Mybatis?

mybatis-spring-boot-starter

24.什么是SpringProfiles?

一般来说我们从开发到生产,经过开发(dev)、测试(test)、上线(prod)。不同的时刻我们会用不同的配置。Spring Profiles 允许用户根据配置文件(dev,test,prod 等)来注册 bean。它们可以让我们自己选择什么时候用什么配置。

25.不同的环境的配置文件?

可以是 application-{profile}.properties/yml ,但默认是启动主配置文件application.properties,一般来说我们的不同环境配置如下。

  • application.properties:主配置文件
  • application-dev.properties:开发环境配置文件
  • application-test.properties:测试环境配置文件
  • application.prop-properties:生产环境配置文件

26.如何激活某个环境的配置?

比如我们激活开发环境。

yml:

1
2
3
复制代码spring:
profiles:
active: dev

properties:

1
复制代码spring.profiles.active=dev

命令行:

1
复制代码java -jar xiaoka-v1.0.jar ———spring.profiles.active=dev

27.编写测试用例的注解?

@SpringBootTest

28.SpringBoot异常处理相关注解?

@ControllerAdvice

@ExceptionHandler

29.SpringBoot 1.x 和 2.x区别?·······

  1. SpringBoot 2基于Spring5和JDK8,Spring 1x用的是低版本。
  2. 配置变更,参数名等。
  3. SpringBoot2相关的插件最低版本很多都比原来高
  4. 2.x配置中的中文可以直接读取,不用转码
  5. Actuator的变化
  6. CacheManager 的变化

30.SpringBoot读取配置相关注解有?

  • @PropertySource
  • @Value
  • @Environment
  • @ConfigurationProperties

参考:

  • 《SpringBoot实战(第4版)》
  • 《Spring Boot编程思想》
  • 《深入浅出Spring Boot 2.x》
  • https://spring.io/projects/spring-boot
  • 百度百科

本文转载自: 掘金

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

面试官:小伙子,你给我说一下HashMap 为什么线程不安全

发表于 2020-06-05

前言:我们都知道HashMap是线程不安全的,在多线程环境中不建议使用,但是其线程不安全主要体现在什么地方呢,本文将对该问题进行解密。

1.jdk1.7中的HashMap

在jdk1.8中对HashMap做了很多优化,这里先分析在jdk1.7中的问题,相信大家都知道在jdk1.7多线程环境下HashMap容易出现死循环,这里我们先用代码来模拟出现死循环的情况:

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
复制代码 1 public class HashMapTest {
2
3 public static void main(String[] args) {
4 HashMapThread thread0 = new HashMapThread();
5 HashMapThread thread1 = new HashMapThread();
6 HashMapThread thread2 = new HashMapThread();
7 HashMapThread thread3 = new HashMapThread();
8 HashMapThread thread4 = new HashMapThread();
9 thread0.start();
10 thread1.start();
11 thread2.start();
12 thread3.start();
13 thread4.start();
14 }
15 }
16
17 class HashMapThread extends Thread {
18 private static AtomicInteger ai = new AtomicInteger();
19 private static Map<Integer, Integer> map = new HashMap<>();
20
21 @Override
22 public void run() {
23 while (ai.get() < 1000000) {
24 map.put(ai.get(), ai.get());
25 ai.incrementAndGet();
26 }
27 }
28 }

上述代码比较简单,就是开多个线程不断进行put操作,并且HashMap与AtomicInteger都是全局共享的。在多运行几次该代码后,出现如下死循环情形:

其中有几次还会出现数组越界的情况:

i

这里我们着重分析为什么会出现死循环的情况,通过jps和jstack命名查看死循环情况,结果如下:

i

从堆栈信息中可以看到出现死循环的位置,通过该信息可明确知道死循环发生在HashMap的扩容函数中,根源在transfer函数中,jdk1.7中HashMap的transfer函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码 1    void transfer(Entry[] newTable, boolean rehash) {
2 int newCapacity = newTable.length;
3 for (Entry<K,V> e : table) {
4 while(null != e) {
5 Entry<K,V> next = e.next;
6 if (rehash) {
7 e.hash = null == e.key ? 0 : hash(e.key);
8 }
9 int i = indexFor(e.hash, newCapacity);
10 e.next = newTable[i];
11 newTable[i] = e;
12 e = next;
13 }
14 }
15 }

总结下该函数的主要作用:

在对table进行扩容到newTable后,需要将原来数据转移到newTable中,注意10-12行代码,这里可以看出在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转,这里也是形成死循环的关键点。下面进行详细分析。

1.1 扩容造成死循环分析过程

前提条件:

这里假设

#1.hash算法为简单的用key mod链表的大小。

#2.最开始hash表size=2,key=3,7,5,则都在table[1]中。

#3.然后进行resize,使size变成4。

未resize前的数据结构如下:

如果在单线程环境下,最后的结果如下:

这里的转移过程,不再进行详述,只要理解transfer函数在做什么,其转移过程以及如何对链表进行反转应该不难。

然后在多线程环境下,假设有两个线程A和B都在进行put操作。线程A在执行到transfer函数中第11行代码处挂起,因为该函数在这里分析的地位非常重要,因此再次贴出来。

此时线程A中运行结果如下:

线程A挂起后,此时线程B正常执行,并完成resize操作,结果如下:

这里需要特别注意的点:由于线程B已经执行完毕,根据Java内存模型,现在newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null。

此时切换到线程A上,在线程A挂起时内存中值如下:e=3,next=7,newTable[3]=null,代码执行过程如下:

1
2
复制代码newTable[3]=e ----> newTable[3]=3
e=next ----> e=7

此时结果如下:

继续循环:

1
2
3
4
5
复制代码e=7
next=e.next ----> next=3【从主存中取值】
e.next=newTable[3] ----> e.next=3【从主存中取值】
newTable[3]=e ----> newTable[3]=7
e=next ----> e=3

结果如下:

再次进行循环:

1
2
3
4
5
复制代码e=3
next=e.next ----> next=null
e.next=newTable[3] ----> e.next=7 即:3.next=7
newTable[3]=e ----> newTable[3]=3
e=next ----> e=null

注意此次循环:e.next=7,而在上次循环中7.next=3,出现环形链表,并且此时e=null循环结束。

结果如下:


在后续操作中只要涉及轮询hashmap的数据结构,就会在这里发生死循环,造成悲剧。

1.2 扩容造成数据丢失分析过程

遵照上述分析过程,初始时:

线程A和线程B进行put操作,同样线程A挂起:

此时线程A的运行结果如下:

此时线程B已获得CPU时间片,并完成resize操作:

同样注意由于线程B执行完成,newTable和table都为最新值:5.next=null。

此时切换到线程A,在线程A挂起时:e=7,next=5,newTable[3]=null。

执行newtable[i]=e,就将**7放在了table[3]**的位置,此时next=5。接着进行下一次循环:

1
2
3
4
5
复制代码e=5
next=e.next ----> next=null,从主存中取值
e.next=newTable[1] ----> e.next=5,从主存中取值
newTable[1]=e ----> newTable[1]=5
e=next ----> e=null

将5放置在table[1]位置,此时e=null循环结束,3元素丢失,并形成环形链表。并在后续操作hashmap时造成死循环。

2.jdk1.8中HashMap

在jdk1.8中对HashMap进行了优化,在发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全,这里我们看jdk1.8中HashMap的put操作源码:

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
复制代码 1  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
2 boolean evict) {
3 Node<K,V>[] tab; Node<K,V> p; int n, i;
4 if ((tab = table) == null || (n = tab.length) == 0)
5 n = (tab = resize()).length;
6 if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
7 tab[i] = newNode(hash, key, value, null);
8 else {
9 Node<K,V> e; K k;
10 if (p.hash == hash &&
11 ((k = p.key) == key || (key != null && key.equals(k))))
12 e = p;
13 else if (p instanceof TreeNode)
14 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
15 else {
16 for (int binCount = 0; ; ++binCount) {
17 if ((e = p.next) == null) {
18 p.next = newNode(hash, key, value, null);
19 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
20 treeifyBin(tab, hash);
21 break;
22 }
23 if (e.hash == hash &&
24 ((k = e.key) == key || (key != null && key.equals(k))))
25 break;
26 p = e;
27 }
28 }
29 if (e != null) { // existing mapping for key
30 V oldValue = e.value;
31 if (!onlyIfAbsent || oldValue == null)
32 e.value = value;
33 afterNodeAccess(e);
34 return oldValue;
35 }
36 }
37 ++modCount;
38 if (++size > threshold)
39 resize();
40 afterNodeInsertion(evict);
41 return null;
42 }

这是jdk1.8中HashMap中put操作的主函数, 注意第6行代码,如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入第6行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。

这里只是简要分析下jdk1.8中HashMap出现的线程不安全问题的体现,后续将会对java的集合框架进行总结,到时再进行具体分析。

总结

首先HashMap是线程不安全的,其主要体现:

#1.在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。

#2.在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

本文转载自: 掘金

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

基于 Go + MySQL + ES 实现一个 Tag AP

发表于 2020-06-05

Tag 是一个很常见的功能,这篇文章将使用 Go + MySQL + ES 实现一个 500 多行的 tag API 服务,支持 创建/搜索 标签、标签关联到实体 和 查询实体所关联的标签列表。

初始化环境

MySQL

1
复制代码brew install mysql

ES

这里直接通过 docker 来启动 ES:

1
复制代码docker run -d --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch

启动后可以通过 curl 检查是否已经启动和获取版本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码curl localhost:9200
{
"name" : "5059f2c85a1d",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "T5EjufvlSdCcZXVDJFi2cA",
"version" : {
"number" : "7.7.1",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "ad56dce891c901a492bb1ee393f12dfff473a423",
"build_date" : "2020-05-28T16:30:01.040088Z",
"build_snapshot" : false,
"lucene_version" : "8.5.1",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}

注意上面的部署仅用于开发环境,如果需要在生产部署通过 docker 部署,请参考官方文档: Install Elasticsearch with Docker。

设计存储结构

先在 MySQL 里面创建一个 test 数据库:

1
2
复制代码create database test;
use test;

创建 tag_tbl 表:

1
2
3
4
5
6
7
复制代码CREATE TABLE `tag_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(40) NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`) USING HASH
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

tag_tbl 用于存储标签,注意这里给我们给 name 字段加上了一个唯一键,并使用 hash 作为索引方法,关于 hash 索引,可以参考官方文档:Comparison of B-Tree and Hash Indexes。

再创建 entity_tag_tbl 用于存储实体关联的 tag:

1
2
3
4
5
6
7
8
复制代码CREATE TABLE `entity_tag_tbl` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`entity_id` int(10) unsigned NOT NULL,
`tag_id` int(10) unsigned NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `entity_id` (`entity_id`,`tag_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计 API

创建标签

Request:

1
2
3
4
复制代码POST /api/tag
{
"name": "your tag name"
}

Response:

1
2
3
复制代码{
"tag_id": 1
}

搜索标签

Request:

1
2
3
4
复制代码GET /api/tag/search
{
"keyword": "cat"
}

Response:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码{
"matchs": [
{
"tag_id": 5,
"name": "cat"
},
{
"tag_id": 6,
"name": "cat pictures"
}
]
}

关联标签到实体

Request:

1
2
3
4
5
复制代码POST /api/tag/link_entity
{
"entity_id": 1,
"tag_id": 3
}

Response:

1
2
3
复制代码{
"link_id": 1
}

查询实体关联的标签列表

Request:

1
2
3
4
复制代码GET /api/tag/entity_tags
{
"entity_id": 1
}

Response:

1
2
3
4
5
6
7
8
复制代码{
"tags": [
{
"tag_id": 3,
"name": "美食"
}
]
}

编码实现

初始化:

1
2
3
复制代码mkdir tag-server
cd tag-server
go mod init github.com/3vilive/tag-server

安装将要用到依赖项:

1
复制代码go get github.com/go-sql-driver/mysql github.com/jmoiron/sqlx github.com/gin-gonic/gin github.com/elastic/go-elasticsearch/v7

创建 cmd/api-server/main.go 并编写脚手架代码:

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
复制代码package main

import (
"net/http"

"github.com/gin-gonic/gin"
)

func OnNewTag(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"tag_id": 0,
})
}

func OnSearchTag(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"matches": []struct{}{},
})
}

func OnLinkEntity(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"link_id": 0,
})
}

func OnEntityTags(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"tags": []struct{}{},
})
return
}

func main() {
r := gin.Default()

r.POST("/api/tag", OnNewTag)
r.GET("/api/tag/search", OnSearchTag)
r.POST("/api/tag/link_entity", OnLinkEntity)
r.GET("/api/tag/entity_tags", OnEntityTags)

r.Run(":9800")
}

实现创建标签的 API

连接数据库:

1
2
3
4
5
6
7
8
9
10
复制代码import "github.com/jmoiron/sqlx"
import _ "github.com/go-sql-driver/mysql" // mysql driver

var (
mysqlDB *sqlx.DB
)

func init() {
mysqlDB = sqlx.MustOpen("mysql", "test:test@tcp(localhost:3306)/test?parseTime=True&loc=Local&multiStatements=true&charset=utf8mb4")
}

定义 Tag 结构:

1
2
3
4
复制代码type Tag struct {
TagID int `db:"id"`
Name string `db:"name"`
}

编写创建标签的逻辑:

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
复制代码// NewTagReqBody 创建标签的请求体
type NewTagReqBody struct {
Name string `json:"name"`
}

// OnNewTag 创建标签
func OnNewTag(c *gin.Context) {
var reqBody NewTagReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": bindErr.Error(),
})
return
}

// 判断传入的 tag 名称是否为空
tagName := strings.TrimSpace(reqBody.Name)
if tagName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "invalid name",
})
return
}

var queryTag Tag
queryErr := mysqlDB.Get(&queryTag, "select id, name from tag_tbl where name = ?", tagName)
if queryErr == nil {
// tag 已经存在
c.JSON(http.StatusOK, gin.H{
"tag_id": queryTag.TagID,
})
return
}

// 查询 mysql 出现错误
if queryErr != nil && queryErr != sql.ErrNoRows {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": queryErr.Error(),
})
return
}

// tag 不存在,创建 tag
result, execErr := mysqlDB.Exec("insert into tag_tbl (name) values (?) on duplicate key update created_at = now()", tagName)
if execErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": execErr.Error(),
})
return
}

tagID, err := result.LastInsertId()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"tag_id": tagID,
})
}

启动测试一下:

1
2
3
4
5
6
7
8
9
10
复制代码go run cmd/api-server/main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST /api/tag --> main.OnNewTag (3 handlers)
[GIN-debug] POST /api/tag/search --> main.OnSearchTag (3 handlers)
[GIN-debug] Listening and serving HTTP on :9800

创建一个名为 test 的标签:

1
2
3
4
5
6
复制代码curl --request POST \
--url http://localhost:9800/api/tag \
--header 'content-type: application/json' \
--data '{
"name": "test"
}'

响应:

1
2
3
复制代码{
"tag_id": 1
}

再创建一个叫做 测试 的标签:

1
2
3
4
5
6
复制代码curl --request POST \
--url http://localhost:9800/api/tag \
--header 'content-type: application/json' \
--data '{
"name": "测试"
}'

响应:

1
2
3
复制代码{
"tag_id": 2
}

重新运行一遍创建 test 标签的请求:

1
2
3
4
5
6
复制代码curl --request POST \
--url http://localhost:9800/api/tag \
--header 'content-type: application/json' \
--data '{
"name": "test"
}'

响应:

1
2
3
复制代码{
"tag_id": 1
}

测试结果符合预期,当前完整文件内容如下:

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
复制代码package main

import (
"database/sql"
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"

_ "github.com/go-sql-driver/mysql" // mysql driver
)

var (
mysqlDB *sqlx.DB
)

func init() {
mysqlDB = sqlx.MustOpen("mysql", "test:test@tcp(localhost:3306)/test?parseTime=True&loc=Local&multiStatements=true&charset=utf8mb4")
}

// Tag 标签结构定义
type Tag struct {
TagID int `db:"id"`
Name string `db:"name"`
}

// NewTagReqBody 创建标签的请求体
type NewTagReqBody struct {
Name string `json:"name"`
}

// OnNewTag 创建标签
func OnNewTag(c *gin.Context) {
var reqBody NewTagReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": bindErr.Error(),
})
}

// 判断传入的 tag 名称是否为空
tagName := strings.TrimSpace(reqBody.Name)
if tagName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "invalid name",
})
return
}

var queryTag Tag
queryErr := mysqlDB.Get(&queryTag, "select id, name from tag_tbl where name = ?", tagName)
if queryErr == nil {
// tag 已经存在
c.JSON(http.StatusOK, gin.H{
"tag_id": queryTag.TagID,
})
return
}

// 查询 mysql 出现错误
if queryErr != nil && queryErr != sql.ErrNoRows {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": queryErr.Error(),
})
return
}

// tag 不存在,创建 tag
result, execErr := mysqlDB.Exec("insert into tag_tbl (name) values (?) on duplicate key update created_at = now()", tagName)
if execErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": execErr.Error(),
})
return
}

tagID, err := result.LastInsertId()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"tag_id": tagID,
})
}

// OnSearchTag 搜索标签
func OnSearchTag(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"matches": []struct{}{},
})
}

func main() {
r := gin.Default()

r.POST("/api/tag", OnNewTag)
r.POST("/api/tag/search", OnSearchTag)

r.Run(":9800")
}

实现搜索标签的 API

导入 elasticsearch 包:

1
2
3
4
复制代码import (
...
elasticsearch7 "github.com/elastic/go-elasticsearch/v7"
)

声明 esClient 变量:

1
2
3
4
复制代码var (
mysqlDB *sqlx.DB
esClient *elasticsearch7.Client
)

在 init 函数中初始化 esClient:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码func init() {
// 初始化 mysql
mysqlDB = sqlx.MustOpen("mysql", "test:test@tcp(localhost:3306)/test?parseTime=True&loc=Local&multiStatements=true&charset=utf8mb4")

// 初始化 ES
esConf := elasticsearch7.Config{
Addresses: []string{"http://localhost:9200"},
}
es, err := elasticsearch7.NewClient(esConf)
if err != nil {
panic(err)
}

res, err := es.Info()
if err != nil {
panic(err)
}

if res.IsError() {
panic(res.String())
}

esClient = es
}

把标签添加至 ES 索引

为了能在 ES 上搜到标签,我们需要在添加标签的时候,把标签添加至 ES 索引中。

先修改 Tag 结构,增加 JSON Tag, 并添加转换成 JSON 字符串的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码// Tag 标签结构定义
type Tag struct {
TagID int `db:"id" json:"tag_id"`
Name string `db:"name" json:"name"`
}

// MustToJSON 将结构转换成 JSON
func (t *Tag) MustToJSON() string {
bs, err := json.Marshal(t)
if err != nil {
panic(err)
}
return string(bs)
}

然后添加一个上报 Tag 到 ES 索引的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码// ReportTagToES 上报 Tag 到 ES
func ReportTagToES(tag *Tag) {
req := esapi.IndexRequest{
Index: "test",
DocumentType: "tag",
DocumentID: strconv.Itoa(tag.TagID),
Body: strings.NewReader(tag.MustToJSON()),
Refresh: "true",
}

resp, err := req.Do(context.Background(), esClient)
if err != nil {
log.Printf("ESIndexRequestErr: %s", err.Error())
return
}

defer resp.Body.Close()
if resp.IsError() {
log.Printf("ESIndexRequestErr: %s", resp.String())
} else {
log.Printf("ESIndexRequestOk: %s", resp.String())
}
}

在 OnNewTag 函数的底部增加上报的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码func OnNewTag(c *gin.Context) {

...

tagID, err := result.LastInsertId()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
return
}

// 添加到 ES 索引
newTag := &Tag{TagID: int(tagID), Name: tagName}
go ReportTagToES(newTag)

c.JSON(http.StatusOK, gin.H{
"tag_id": tagID,
})
}

重新启动服务,然后测试创建 Tag,观察日志:

1
复制代码2020/06/05 11:29:11 ESIndexRequestOk: [201 Created] {"_index":"test","_type":"tag","_id":"4","_version":1,"result":"created","forced_refresh":true,"_shards":{"total":2,"successful":1,"failed":0},"_seq_no":3,"_primary_term":1}

再调用 ES 的 API 验证一下:

1
2
3
复制代码curl -XGET "localhost:9200/test/tag/4"

{"_index":"test","_type":"tag","_id":"4","_version":1,"_seq_no":3,"_primary_term":1,"found":true,"_source":{"tag_id":4,"name":"测试手段"}}

完善搜索逻辑

新增一个 SearchTagReqBody 结构,作为搜索标签的请求体

1
2
3
复制代码type SearchTagReqBody struct {
Keyword string `json:"keyword"`
}

在 OnSearchTag 函数里面增加一些基本的参数校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码func OnSearchTag(c *gin.Context) {
var reqBody SearchTagReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": bindErr.Error(),
})
return
}

searchKeyword := strings.TrimSpace(reqBody.Keyword)
if searchKeyword == "" {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "invalid keyword",
})
return
}

c.JSON(http.StatusOK, gin.H{
"matches": []struct{}{},
})
}

增加一个 O 结构作为 map[string]interface{} 的别名,并且为这个结构添加一个 MustToJSONBytesBuffer() *bytes.Buffer 的方法:

1
2
3
4
5
6
7
8
9
10
复制代码type O map[string]interface{}

func (o *O) MustToJSONBytesBuffer() *bytes.Buffer {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(o); err != nil {
panic(err)
}

return &buf
}

定义这个 O 是为了等会构建 ES 查询提供一点便利。

增加 SearchTagsFromES 函数,从 ES 上搜索 Tags:

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
复制代码func SearchTagsFromES(keyword string) ([]*Tag, error) {
// 构建查询
query := O{
"query": O{
"match_phrase_prefix": O{
"name": keyword,
"max_expansions": 50,
},
},
}
jsonBuf := query.MustToJSONBytesBuffer()

// 发出查询请求
resp, err := esClient.Search(
esClient.Search.WithContext(context.Background()),
esClient.Search.WithIndex("test"),
esClient.Search.WithBody(jsonBuf),
)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.IsError() {
return nil, errors.New(resp.Status())
}

js, err := simplejson.NewFromReader(resp.Body)
if err != nil {
return nil, err
}

hitsJS := js.GetPath("hits", "hits")
hits, err := hitsJS.Array()
if err != nil {
return nil, err
}

hitsLen := len(hits)
if hitsLen == 0 {
return []*Tag{}, nil
}

tags := make([]*Tag, 0, len(hits))
for idx := 0; idx < hitsLen; idx++ {
sourceJS := hitsJS.GetIndex(idx).Get("_source")

tagID, err := sourceJS.Get("tag_id").Int()
if err != nil {
return nil, err
}

tagName, err := sourceJS.Get("name").String()
if err != nil {
return nil, err
}

tagEntity := &Tag{TagID: tagID, Name: tagName}
tags = append(tags, tagEntity)
}

return tags, nil
}

修改 OnSearchTag 函数,加入搜索的逻辑:

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
复制代码func OnSearchTag(c *gin.Context) {
var reqBody SearchTagReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": bindErr.Error(),
})
return
}

searchKeyword := strings.TrimSpace(reqBody.Keyword)
if searchKeyword == "" {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "invalid keyword",
})
return
}

tags, err := SearchTagsFromES(reqBody.Keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"matches": tags,
})
}

重新启动服务,然后添加一个美食标签,然后再搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码curl --request GET \
--url http://localhost:9800/api/tag/search \
--header 'content-type: application/json' \
--data '{
"keyword": "美食"
}'

// response:

{
"matches": [
{
"tag_id": 5,
"name": "美食"
}
]
}

搜索 API 最终效果

先清空一下 MySQL 的历史数据,之前添加标签的时候,还没有添加到 ES 的索引里面:

1
复制代码truncate tag_tbl;

同时也清理一下 ES 索引:

1
复制代码curl -XDELETE "localhost:9200/test"

接下来添加一批 Tag:

1
2
3
4
5
6
7
8
9
复制代码美食
美食街
美食节
美食节趣闻
美食节三剑客
美食天堂
美食的诱惑
美食在中国
美食街都有啥

搜索 “美食”:

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
复制代码{
"matches": [
{
"tag_id": 1,
"name": "美食"
},
{
"tag_id": 2,
"name": "美食街"
},
{
"tag_id": 3,
"name": "美食节"
},
{
"tag_id": 6,
"name": "美食天堂"
},
{
"tag_id": 4,
"name": "美食节趣闻"
},
{
"tag_id": 7,
"name": "美食的诱惑"
},
{
"tag_id": 8,
"name": "美食在中国"
},
{
"tag_id": 5,
"name": "美食节三剑客"
},
{
"tag_id": 9,
"name": "美食街都有啥"
}
]
}

搜索 “美食街”:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码{
"matches": [
{
"tag_id": 2,
"name": "美食街"
},
{
"tag_id": 9,
"name": "美食街都有啥"
}
]
}

搜索 “美食节”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码{
"matches": [
{
"tag_id": 3,
"name": "美食节"
},
{
"tag_id": 4,
"name": "美食节趣闻"
},
{
"tag_id": 5,
"name": "美食节三剑客"
}
]
}

实现关联标签到实体 API

定义实体关联 Tag 的结构:

1
2
3
4
5
复制代码type EntityTag struct {
LinkID int `db:"id" json:"-"`
EntityID int `db:"entity_id" json:"entity_id"`
TagID int `db:"tag_id" json:"tag_id"`
}

定义请求体:

1
2
3
4
复制代码type LinkEntityReqBody struct {
EntityID int `json:"entity_id"`
TagID int `json:"tag_id"`
}

开始编写 OnLinkEntity 里面的逻辑,首先先做基本的参数校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码var reqBody LinkEntityReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": bindErr.Error(),
})
return
}

if reqBody.EntityID == 0 || reqBody.TagID == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "request params error",
})
return
}

查询是否标签已经关联过该实体,如果已经关联过,则直接返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码var entityTag EntityTag
queryErr := mysqlDB.Get(
&entityTag,
"select id, entity_id, tag_id from entity_tag_tbl where entity_id = ? and tag_id = ?",
reqBody.EntityID, reqBody.TagID,
)

if queryErr == nil {
// 已经存在关联
c.JSON(http.StatusOK, gin.H{
"link_id": entityTag.LinkID,
})
return
}

if queryErr != sql.ErrNoRows {
// 查询错误
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": queryErr.Error(),
})
return
}

判断一下 Tag 是否存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码var tag Tag
queryErr = mysqlDB.Get(
&tag,
"select id, name from tag_tbl where id = ?",
reqBody.TagID,
)
if queryErr != nil {
if queryErr != sql.ErrNoRows {
// 查询错误
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": queryErr.Error(),
})
return
}

// Tag 不存在
c.JSON(http.StatusNotFound, gin.H{
"status": http.StatusNotFound,
"message": "tag not found",
})
return
}

记录关联信息并返回关联 ID:

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
复制代码execResult, execErr := mysqlDB.Exec(
"insert into entity_tag_tbl (entity_id, tag_id) values (?, ?) on duplicate key update created_at = now()",
reqBody.EntityID, reqBody.TagID,
)
if execErr != nil {
// 插入失败
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": execErr.Error(),
})
return
}

linkID, err := execResult.LastInsertId()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"link_id": int(linkID),
})

重启服务,创建一些关联:

1
2
3
4
5
6
7
复制代码curl --request POST \
--url http://localhost:9800/api/tag/link_entity \
--header 'content-type: application/json' \
--data '{
"entity_id": 1,
"tag_id": 5
}'

可以通过数据库来验证一下:

1
2
3
4
5
6
7
8
9
10
11
复制代码mysql> select * from entity_tag_tbl;
+----+-----------+--------+---------------------+
| id | entity_id | tag_id | created_at |
+----+-----------+--------+---------------------+
| 1 | 1 | 3 | 2020-06-05 15:03:00 |
| 2 | 1 | 1 | 2020-06-05 15:39:42 |
| 3 | 1 | 4 | 2020-06-05 15:39:47 |
| 4 | 1 | 2 | 2020-06-05 15:39:52 |
| 5 | 1 | 7 | 2020-06-05 15:55:59 |
| 6 | 1 | 5 | 2020-06-05 15:56:01 |
+----+-----------+--------+---------------------+

实现查询实体关联的标签列表 API

定义查询实体关联的标签列表的请求体:

1
2
3
复制代码type EntityTagReqBody struct {
EntityID int `json:"entity_id"`
}

编写 OnEntityTags 逻辑,和之前一样做参数校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码var reqBody EntityTagReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": bindErr.Error(),
})
return
}

if reqBody.EntityID == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "request params error",
})
return
}

查询出实体关联的标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码entityTags := []*EntityTag{}
selectErr := mysqlDB.Select(&entityTags, "select id, entity_id, tag_id from entity_tag_tbl where entity_id = ? order by id", reqBody.EntityID)
if selectErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": selectErr.Error(),
})
return
}

if len(entityTags) == 0 {
c.JSON(http.StatusOK, gin.H{
"tags": []*Tag{},
})
return
}

查询出标签列表,并返回:

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
复制代码tagIDs := make([]int, 0, len(entityTags))
tagIndex := make(map[int]int, len(entityTags))
for index, entityTag := range entityTags {
tagIndex[entityTag.TagID] = index
tagIDs = append(tagIDs, entityTag.TagID)
}

queryTags, args, err := sqlx.In("select id, name from tag_tbl where id in (?)", tagIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
return
}

tags := []*Tag{}
selectErr = mysqlDB.Select(&tags, queryTags, args...)
if selectErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": selectErr.Error(),
})
return
}

sort.Slice(tags, func(i, j int) bool {
return tagIndex[tags[i].TagID] < tagIndex[tags[j].TagID]
})

c.JSON(http.StatusOK, gin.H{
"tags": tags,
})

重启服务测试一下:

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
复制代码curl --request GET \
--url http://localhost:9800/api/tag/entity_tags \
--header 'content-type: application/json' \
--data '{
"entity_id": 1
}'

// response

{
"tags": [
{
"tag_id": 3,
"name": "美食节"
},
{
"tag_id": 1,
"name": "美食"
},
{
"tag_id": 4,
"name": "美食节趣闻"
},
{
"tag_id": 2,
"name": "美食街"
},
{
"tag_id": 7,
"name": "美食的诱惑"
},
{
"tag_id": 5,
"name": "美食节三剑客"
}
]
}

最后

完整的代码可以在 Github 上找到:

github.com/3vilive/bui…

参考资料:

  1. Tags-Database-schemas
  2. Tagsystems-performance-tests
  3. Elasticsearch: 权威指南

本文转载自: 掘金

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

【Redis面试题】如何使用Redis实现微信步数排行榜?

发表于 2020-06-05
  1. 前言

之前写过一篇博客,讲解的是Redis的5种数据结构及其常用命令,当时有读者评论,说希望了解下这5种数据结构各自的使用场景,不过一直也没来得及写。

碰巧,在3月份找工作面试时,有个面试官先问了我Redis有哪几种数据结构,在我讲完后,面试官又问了我以下问题:

如何用Redis实现微信步数排行榜?

相信很多小伙伴都知道,可以使用Redis的有序集合ZSET来实现,本篇博客就基于此面试题,来讲解下ZSET的使用场景,以及微信步数排行榜的大致实现思路。

  1. ZSET的使用场景

ZSET的经典使用场景是用来实现排行榜,举几个常见的例子,比如百度热榜、微博热搜榜、以及我们每天都用到的微信步数排行榜:

3个场景的实现思路基本一致,接下来,我们以微信步数排行榜为例,了解下如何使用Redis的ZSET,实现微信步数排行榜。

  1. 微信步数排行榜的大致实现思路

**注意事项:**本文的重点是Redis的ZSET的使用,因此只是分析了微信步数排行榜的大致实现思路,实际实现肯定比文中分析的复杂的多。

首先,我们来分析下微信步数排行榜的需求:

  1. 排行榜是以日期为单位的,历史日期的排行榜是可以查看的
  2. 排行榜可能并不会显示所有好友的步数,比如我的微信有349位好友,但排行榜从来没有显示过这么多,假设最多只显示步数前200的好友
  3. 步数是异步更新的,所以每隔一段时间步数同步后,排行榜都会变化
  4. 排行榜中,好友头像和微信昵称可以理解为不变的(变动的几率小,就像热搜榜中的标题和Url),但步数和点赞数是可变的

基于以上分析的需求,大致实现思路如下:

  1. 使用Redis的ZSET数据结构
  2. 设置key时,基于微信号和日期,比如我的微信是zwwhnly,今天的日期是2020-06-01,那么key就可以设计为:StepNumberRanking:zwwhnly:20200601
  3. 设置value时,将好友的昵称作为成员member,将好友的步数作为分值score,如下所示:


4. 使用Redis的HASH数据结构,其中key为第2步的key+第3步的成员member,value分别存储好友头像、昵称、步数、点赞数,如下所示:


5. 获取微信步数排行榜时,分为以下2步:

1)先查询出微信步数排行榜中的好友昵称,也就是查询StepNumberRanking:zwwhnly:20200601的值

2)根据获取到的好友昵称,查询好友步数信息,也就是查询StepNumberRanking:zwwhnly:20200601:yst的值

  1. 使用到的Redis命令

上面分析出了大致的实现思路,接下来我们讲解下使用到的Redis命令。

4.1 ZADD

执行如下命令初始化微信步数排行榜,以上面图片中的9个好友为例,分2次初始化:

1
2
3
shell复制代码ZADD StepNumberRanking:zwwhnly:20200602 25452 yst 23683 zq 23599 ljx 20391 yyq 19628 XxZz

ZADD StepNumberRanking:zwwhnly:20200602 18261 lxx 16636 zcc 16555 clc 16098 fl

执行完的效果如下图所示:

可以看到,默认是以score正序排列的,也就是步数从少到多排列。

4.2 HMSET

因为展示步数排行榜时,需要展示昵称、头像、步数、点赞数,所以可以借助于Redis的HASH 数据结构来存储,这时就要用到HMSET命令:

执行完的效果如下图所示:

4.3 ZINCRBY

每隔一段时间,好友的步数是会更新的,此时可以使用ZINCRBY命令来更新好友步数,假设我们只更新步数位于前2位好友的步数,给他们的步数增加10,就可以执行以下命令:

1
2
3
shell复制代码ZINCRBY StepNumberRanking:zwwhnly:20200602 10 yst

ZINCRBY StepNumberRanking:zwwhnly:20200602 10 zq

执行完的效果如下图所示:

更新完排行榜里的步数后,不要忘记执行HMSET命令更新好友的步数:

4.4 HINCRBY

当我们在步数排行榜里给好友点赞时,可以使用HINCRBY命令,把上图中的likeNum加1:

1
shell复制代码HINCRBY StepNumberRanking:zwwhnly:20200602:zq likeNum 1

4.5 ZRANGE

在所有的数据就绪后,剩下的就是查询了,我们可以使用ZRANGE命令获取排行榜里的好友信息:

1
shell复制代码ZRANGE StepNumberRanking:zwwhnly:20200602 0 -1

可以看出,查询出的好友信息是按步数从少到多排序的,而排行榜应该按步数从多到少排序,这就用到了下面的ZREVRANGE命令。

4.6 ZREVRANGE

ZREVRANGE命令和ZRANGE命令类似,不过是按score倒序的,刚好符合排行榜的场景。

比如执行命令:

1
shell复制代码ZREVRANGE StepNumberRanking:zwwhnly:20200602 0 -1 WITHSCORES

可以看出,查询出的好友信息按步数从大到小排序,刚好就是在排行榜要展示的顺序。

不过,排行榜一般都不展示所有的数据,这里我们的数据比较少,如果只获取步数top5的好友,就可以执行如下命令:

1
shell复制代码ZREVRANGE StepNumberRanking:zwwhnly:20200602 0 4 WITHSCORES

如果你要获取top200,就将上面的4修改为199。

4.7 HGETALL

获取到了排行榜里的好友信息,最后一步就是获取这些好友的步数、点赞数、头像、昵称这些信息,也就是我们之前使用HASH数据结构存储的信息,此时我们可以使用HGETALL命令,如下所示:

1
shell复制代码HGETALL StepNumberRanking:zwwhnly:20200602:yst


如果对这些命令不是很熟悉,可以看下我之前发布的一篇博客:Redis系列(二):Redis的5种数据结构及其常用命令。

  1. 总结

Redis的ZSET数据结构非常适合用在排行榜的场景,比如百度热搜、微博热搜榜、游戏排行榜、微信步数排行榜,面试官肯定不会问你ZSET都有哪些命令,每个命令的细节等等,但问你如何使用Redis实现微信步数排行榜,就可以了解到你对Redis数据结构的掌握程度。

所以,学习好Redis的5种数据结构的基础很重要,但更重要的是要知道这些数据结构如何使用,每种数据结构用在什么场景最为合适,毕竟要学以致用嘛。

注:如果觉得本篇博客有任何错误或者更好的建议,欢迎留言,我会及时跟进并更正博客内容!

本文转载自: 掘金

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

1…806807808…956

开发者博客

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