图-最短路径-Floyd

使用 Floyd 算法求任一点到其他所有点 (任意两点) 之间的最短距离: 对于每个顶点 v,和任一顶点对 (i, j), i!=j, v!=i, v!=j,如果 A[i][j] > A[i][v]+A[v][j],则将 A[i][j] 更新为 A[i][v]+A[v][j] 的值,并且将 Prev[i][j] 改为 Path[v][j]:

  • 距离表 (初始化为图的邻接矩阵)
  • 前驱表 (初始化为每点到其他任一点的前驱为自己)
  • 三层循环:
    • 第一层: 中间点 [A, B, C, D, E, F, G]
    • 第二层: 出发点 [A, B, C, D, E, F, G]
    • 第三层: 终结点 [A, B, C, D, E, F, G]
    • 出发点通过中间点到终结点的距离、出发点直连终结点的距离选最小值更新距离表: min(Lik+Lkj, Lij),同时更新前驱表

第一轮: 以 A 为中间节点,点 X 通过 A 到点 Y 的距离为 min(XA+AY, XY): BAA, BAB, BAC, BAD, BAE, BAF, BAG, CAA, CAB, …
第二轮: 以 B 为中间节点,…

1
2
3
4
5
6
7
8
9
10
11
12
// 核心: Floyd 算法计算任意 2 点之间的最短距离
final int len = distance.length;
for (int v = 0; v < len; v++) { // 第一层: 中间点
for (int i = 0; i < len; i++) { // 第二层: 出发点
for (int j = 0; j < len; j++) { // 第三层: 终结点
if (distance[i][v] + distance[v][j] < distance[i][j]) {
distance[i][j] = distance[i][v] + distance[v][j];
path[i][j] = path[v][j];
}
}
}
}

图-最短路径-Dijkstra

求图中一点到其他点的最短路径可使用 Dijkstra 算法 (使用广度优先策略):

  • 使用优先级队列实现找最小权重的点 (数组遍历也可以)
  • 前驱节点数组
  • 权重节点数组 (已访问节点)
  • 连接表的存储: Map<String, List<String>>: key 为节点名字,List 为邻接表

图-最小生成树-Kruskal

最小生成树 (Minumum Cost Spanning Tree,简称 MST) 的 2 个经典算法:

  • Prim (普里姆算法): 从顶点出发
  • Kruskal (克鲁斯卡尔算法): 从边出发

网: 带权无向图
最小生成树: 在包含 n 个顶点的连通图中,找出只有 (n-1) 条边,包含所有 n 个顶点的连通子图,也就是所谓的极小连通子图

下面图解 Prim 算法生成最小生成树的过程:

图-最小生成树-Prim

最小生成树 (Minumum Cost Spanning Tree,简称 MST) 的 2 个经典算法:

  • Prim (普里姆算法): 从顶点出发
  • Kruskal (克鲁斯卡尔算法): 从边出发

网: 带权无向图
最小生成树: 在包含 n 个顶点的连通图中,找出只有 (n-1) 条边,包含所有 n 个顶点的连通子图,也就是所谓的极小连通子图

普里姆 (Prim) 算法求最小生成树算法如如下:

  1. 设 G = (V, E) 是联通网,T = (U, D) 是最小生成树,V, U 是顶点集合,E, D 是边的集合
  2. 若从顶点 u 开始构造最小生成树,则从集合 V 中取出顶点 u 放入集合 U 中,标记顶点 u 被访问过了: visited[u] = 1
  3. 若集合 U 中顶点 ui 与集合 V-U 中的顶点 vj 之间存在边,则寻找这些边中权值最小的边,但不能构成回路,将顶点 vj 加入集合 U 中,将边 (ui, vj) 加入集合 D 中,标记 visited[vj] = 1
  4. 重复步骤 3,直到 U 与 V 相等,即所有顶点都被标记为访问过,此时 D 中有 n-1 条边

不管从哪一个顶点开始构建最小生成树,最后得到的最小生成树的边的权值加起来都相等。

下面图解 Prim 算法生成最小生成树的过程,其中:

  • 黑色节点表示未访问过节点
  • 黄色节点表示已访问过节点
  • 红色节点表示未访问过,但是将选择为访问的节点
  • 红色的边为最小生成树中的边
  • 灰色的边为不需要在判断的边,因为它的 2 个顶点都访问过了
  • 黄色的边,其有 1 个顶点被访问过了,另一个顶点未被访问

图-创建图

为了方便创建图,可以把图的边按照格式 startVertex1-endVertex1:weight1,startVertex2-endVertex2:weight2 保存为一个字符串,例如 A-B:16,B-C:10,C-D:3,D-E:4,E-F:8,F-A:14,B-G:7,C-G:6,E-G:2,F-G:9,A-G:12,C-E:5,解析字符串得到图的所有边,使用邻接表存储图的数据。

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
package graph;

import com.alibaba.fastjson.JSON;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.apache.commons.lang3.StringUtils;

import java.util.*;
import java.util.stream.Collectors;

/**
* 图
*/
@Getter
@Setter
@Accessors(chain = true)
public class Graph {
Map<String, List<Edge>> adjacentList = new HashMap<>(); // 邻接表存储图: key 为顶点,Edge 为此顶点和临接点构成的边

/**
* 获取图的顶点
*
* @return 返回顶点的集合
*/
public Set<String> getVertices() {
return adjacentList.keySet();
}

/**
* 获取图的边,无向图中 2 条相同的边只输出一条
*
* @return 返回边的集合
*/
public Set<Edge> getEdges() {
return adjacentList.values().stream().flatMap(List::stream).collect(Collectors.toSet());
}

/**
* 获取传入的顶点的所有边
*
* @param vertex 顶点
* @return 返回边的数组
*/
public List<Edge> getVertexEdges(String vertex) {
return adjacentList.get(vertex);
}

/**
* 使用图的边构建图,边的格式为 start-end:weight,边之间使用逗号分隔,例如 A-B:10,A-G:5
*
* @param edges 图的所有边
* @return 返回图的对象
*/
public static Graph build(String edges) {
Graph graph = new Graph();

for (String edgeContent : StringUtils.split(edges, ",")) {
// 边: A-B:10
int indexOfDash = edgeContent.indexOf("-");
int indexOfColon = edgeContent.indexOf(":");
String vertex1 = edgeContent.substring(0, indexOfDash);
String vertex2 = edgeContent.substring(indexOfDash+1, indexOfColon);
double weight = Double.parseDouble(edgeContent.substring(indexOfColon+1));

// 找到顶点 vertex1 的边集,添加它的边
graph.adjacentList.putIfAbsent(vertex1, new LinkedList<>());
graph.adjacentList.get(vertex1).add(new Edge(vertex1, vertex2, weight));

// 找到顶点 vertex2 的边集,添加它的边
graph.adjacentList.putIfAbsent(vertex2, new LinkedList<>());
graph.adjacentList.get(vertex2).add(new Edge(vertex2, vertex1, weight));
}

return graph;
}

/**
* 图的边,由起点、终点和权重构成
*/
@Getter
@Setter
@Accessors(chain = true)
public static class Edge {
String start; // 边的起点
String end; // 边的终点
double weight; // 边的权重

public Edge(String start, String end, double weight) {
this.start = start;
this.end = end;
this.weight = weight;
}

/**
* 2 个顶点相同的边则为同一条边
*/
@Override
public boolean equals(Object obj) {
if (obj.getClass() != getClass()) {
return false;
}

Edge other = (Edge) obj;

if (this.start.equals(other.start) && this.end.equals(other.end)) {
return true;
}

if (this.start.equals(other.end) && this.end.equals(other.start)) {
return true;
}

return false;
}

@Override
public int hashCode() {
// start, end 从小到大排序
if (start.compareTo(end) < 0) {
return Objects.hash(start, end);
} else {
return Objects.hash(end, start);
}
}
}

public static void main(String[] args) {
Graph graph = Graph.build("A-B:16,B-C:10,C-D:3,D-E:4,E-F:8,F-A:14,B-G:7,C-G:6,E-G:2,F-G:9,A-G:12,C-E:5");
System.out.println(JSON.toJSONString(graph.getAdjacentList()));
System.out.println(graph.getVertices());
System.out.println(JSON.toJSONString(graph.getEdges()));
System.out.println(JSON.toJSONString(graph.getVertexEdges("A"), true));
}
}

下面是顶点 A 的所有边:

1
2
3
4
5
6
7
8
9
10
11
12
13
[{
"end":"B",
"start":"A",
"weight":16.0
},{
"end":"F",
"start":"A",
"weight":14.0
},{
"end":"G",
"start":"A",
"weight":12.0
}]

安装 MySQL

Mac

使用 Brew 安装、使用 Docker 安装:

  • 创建 mysql 的配置文件 /Users/Biao/Documents/workspace/Docker/mysql/config-file.cnf (参考下面的配置,去掉 [WinMySQLAdmin] 部分、basedirdatadir)
  • docker pull mysql:5.7.29
  • docker run --name mysql -v /Users/Biao/Documents/workspace/Docker/mysql:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD=root -p 3306:3306 -d mysql:5.7.29
  • 进入 MySQL 容器: docker exec -it mysql bash,然后可以在里面执行 mysql -u root -p 访问 MySQL

Linux

使用 Yum 安装、使用 Docker 安装

Windows

使用 Docker 安装,下面介绍安装解压版:

  1. 下载解压 http://dev.mysql.com/downloads/mysql/

  2. 在 mysql 的根目录创建 data 目录和 my.ini 配置文件,参考最后面的配置文件内容

  3. 参考安装 MySQL: 以管理员身份运行 cmd(一定要用管理员身份运行,不然权限不够),通过命令,进入 mysql bin 目录

  4. 输入 mysqld --initialize-insecure --user=mysql 回车

  5. 输入 mysqld install 回车

  6. 启动 MySQL: 输入 net start mysql 回车,启动 mysql 服务,start 启动,stop 停止。启动出错时可参考 net start mysql发生系统错误 2,找不到指定文件

  7. 输入 mysql -u root -p ,回车,出现 Enter passwore: ,输入密码,由于刚安装,没有设置密码,直接回车 Enter 进入

  8. MySQL 5.7 root 用户密码修改

    1
    2
    3
    use mysql;
    update user set authentication_string=password('新密码') where user='root' and Host='localhost';
    flush privileges;

配置文件 my.ini 的内容:

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
[WinMySQLAdmin]
Server=D:/mysql-5.7/bin/mysqld.exe

[mysqld]
# Only allow connections from localhost
bind-address = 0.0.0.0
max_connections = 2000

basedir=D:/mysql-5.7
datadir=D:/mysql-5.7/data

character-set-server=utf8mb4
init_connect='SET NAMES utf8mb4'

[mysql]
default-character-set=utf8mb4

[mysql.server]
default-character-set=utf8mb4

[mysql_safe]
default-character-set=utf8mb4

[client]
default-character-set=utf8mb4

注意: Windows 下必须配置 [WinMySQLAdmin]

打印二叉树

在学习二叉树、二叉排序树、AVL 树、红黑树等时,如果能够直观的看到树的结构,对于学习有非常大的帮助。利用 binary-tree-printer 化打印可视化的二叉树,更多方案可参考 How to print binary tree diagram?

依赖

1
implementation "com.github.afkbrb:binary-tree-printer:1.0.0"

打印

打印完全二叉树:

1
BTPrinter.printTree("1,2,3,4,5,#,#,6,7,8,1,#,#,#,#,#,#,2,3,4,5,6,7,8,9,10,11,12,13,14,15");

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
        1              
/ \
2 3
/ \
/ \
4 5
/ \ / \
6 7 8 1
/ \
/ \
/ \
/ \
/ \
2 3
/ \ / \
/ \ / \
4 5 6 7
/ \ / \ / \ / \
8 9 10 11 12 13 14 15

字符串构建树

练习树的算法时,使用代码手动构造树比较麻烦,容易出错,用 JSON 来表示也不够方便,用直观的字符串来表示一颗树更简单 (用缩进来表示节点的父子关系),例如:

1
2
3
4
5
6
7
8
9
10
11
1
2
3
5
6
4
7
9
10
8
11

Vue 数组

对象的数组属性存在

一般情况下,data() 返回的 JSON 中 user 的数组属性 roles 预先定义:

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
<template>
<div class="about">
<Button @click="changeRole1">修改角色一</Button>
<Button @click="changeRole2">修改角色二</Button>

<div v-for="role in user.roles" :key="role.value">
{{ role.name }} - {{ role.value }}
</div>
</div>
</template>

<script>
export default {
data() {
return {
user: {
roles: [
{ name: 'student', value: 1 },
{ name: 'teacher', value: 2 },
]
}
};
},
methods: {
changeRole1() {
// [1] 直接给数组赋值,界面响应更新
this.user.roles = [{ name: 'president', value: 3 }];
},
changeRole2() {
// [2] 修改数组的内容,界面响应更新
this.user.roles.push({ name: 'admin', value: 4 });
}
}
};
</script>

点击按钮修改数组内容后界面立即响应。

iView 自定义主题

项目使用 Vue Cli 3 创建,SCSS 作为 Css Pre-processors,按照 iView 自定义主题文档 https://www.iviewui.com/docs/guide/theme 的步骤进行操作时报错了,下面是解决自定义主题的方法:

  1. 安装 Less: yarn add less less-loader --dev

  2. 在 vue.config.js 中配置 Less:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    module.exports = {
    css: {
    loaderOptions: { // 向 CSS 相关的 loader 传递选项
    less: {
    javascriptEnabled: true
    }
    }
    },
    };
  3. 创建文件 public/static/iview-theme.less,在里面覆盖需要修改的 iView 主题的变量 (默认字体为 12px,有点小,修改为 14px):

    1
    2
    3
    4
    @import '~iview/src/styles/index.less';

    @font-size-small: 14px;
    @btn-font-size : 14px;
  4. 在入口文件 main.js 内导入这个 less 文件:

    1
    2
    3
    4
    5
    6
    import 'iview/dist/styles/iview.css';
    import Vue from 'vue';
    import iView from 'iview';
    import '@/../public/static/css/iview-theme.less';

    Vue.use(iView);
  5. 启动项目,主题修改成功,但发现 Modal 中的字体还是 12px (modal.less 中写死了),Switch 的字体为 14px 有点大,使用下面的样式强制修改它们的字体大小:

    1
    2
    3
    4
    5
    6
    7
    .ivu-modal-body {
    font-size: 14px !important;
    }

    .ivu-switch-inner {
    font-size: 12px !important;
    }

完整的变量列表可以查看 默认样式变量,覆盖需要修改的变量即可 (提示: 删除 @import "color/colors" 这一行)。

Vue 中实现拖拽

Sortable is a JavaScript library for reorderable drag-and-drop lists,下面介绍和 Vue 的简单集成:

  1. 添加依赖: yarn add sortablejs

  2. 页面中引入 Sortable: import Sortable from 'sortablejs'

  3. HTML 中创建被拖拽的列表

  4. 使用被拖拽元素的容器创建 Sortable 对象: Sortable.create(element, config)

  5. onEnd 事件触发时,修改 Vue 管理的数据

    和 Vue 集成最关键的是拖拽结束后需要手动修 Vue 管理的数据,Sortable 不会帮我们修改,具体请参考 onEnd 函数。

Elasticsearch 入门

ElasticSearch (下面简称 ES) 是一个基于 Lucene 的全文检索服务器,本文简单的介绍 ES 的安装、配置、启动、一些基本概念、中文分词以及使用 Java 编程访问 ES 等。

安装配置启动

  1. 安装: 目前 spring-data-elasticsearch 最高支持 elasticsearch-6.2.2 (可参考最后的版本对应进行选择),所以下载 https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.2.2.zip,解压即可

  2. 配置: 修改配置文件 config/elasticsearch.yml (只介绍单机环境的,中小型应用足够了):

    1
    2
    3
    4
    cluster.name: ebag      # 集群名称
    node.name: node-1 # 节点名称
    network.host: 0.0.0.0 # 访问地址, 局域网需要访问
    http.port: 9200 # 端口
  3. 启动: elasticsearch -d,注意: Linux 下不允许使用 root 用户启动,可以创建一个用户如 elasticsearch,然后使用此用户启动 ES:

    • useradd elasticsearch
    • passwd elasticsearch
    • su elasticsearch
    • elasticsearch -d
  4. 浏览器中访问 http://localhost:9200,输出如下则说明 ES 启动成功:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {
    "name": "node-1",
    "cluster_name": "ebag",
    "cluster_uuid": "Ogsv5NneTHyHmWDWM5hH5A",
    "version": {
    "number": "6.2.2",
    "build_hash": "10b1edd",
    "build_date": "2018-02-16T19:01:30.685723Z",
    "build_snapshot": false,
    "lucene_version": "7.2.1",
    "minimum_wire_compatibility_version": "5.6.0",
    "minimum_index_compatibility_version": "5.0.0"
    },
    "tagline": "You Know, for Search"
    }

启动时如果发生错误,可参考 https://www.jianshu.com/p/312dfaa3a27b

Async Validator

表单验证插件 jQuery Validation 一文中介绍过表单验证的库 jQuery Validation,这里简单的介绍另外一个表单验证的库 async-validator (iView 的表单验证也是使用了这个库),了解基础使用后,请阅读官方文档深入学习。

添加依赖

1
yarn add async-validator

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. 导入 async-validator
import Validator from 'async-validator';

// 要验证的数据对象
const org = {
name: '',
};

// 2. 定义验证规则
const rules = {
name: { type: 'string', required: true, whitespace: true, message: '机构名不能为空白字符' },
};

// 3. 使用验证规则创建验证器
const validator = new Validator(rules);

// 4. 调用 validate 方法验证数据
validator.validate(org).then(() => {
// 验证通过
console.log('success');
}).catch(({ errors, fields }) => {
// 验证失败
console.log(errors);
});

注意: require 为 true 时表示需要验证,为 false 表示不进行验证,required 默认值为 false。

Spring Boot Converter

Spring Boot 启动时如果发现 ApplicationContext 中某个 Bean 的类继承了 org.springframework.core.convert.converter.Converter,则会自动的把它注册为 Converter。

例如前端传一个字符串格式的日期,Controller 中想自动转换为 java.time.LocalDate 对象,像下面这样做就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.xtuer.converter;

import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

@Component
public class DateConverter implements Converter<String, LocalDate> {
@Override
public LocalDate convert(String date) {
return LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
}

Java 调用 FFmpeg 转换视频音频

使用 FFmpeg 的命令把一种格式的视频转换为另一种格式的视频,例如把 test.avi 转为 test.mp4 的命令为 ffmpeg -i test.avi -vcodec h264 test.mp4,Java 中可以用 ProcessBuilder 调用这个命令执行转换:

1
2
3
4
5
public static void main(String[] args) throws IOException {
ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-i", "test.avi", "-vcodec", "h264", "test.mp4");
pb.directory(new File("/Users/Biao/Desktop")); // pb 的工作目录,设置为 test.avi 所在目录
pb.start();
}

直接调用命令转换虽然很方便,但是如果视频比较大,转换需要的时间比较长时,希望能够及时的得到转换进度并反馈给客户端,就要解析命令的输出获取转换进度,这时就比较麻烦了。接下来介绍 ffmpeg-cli-wrapper 的使用,它对 FFmpeg 的命令进行了封装,简化视频转换的开发难度。

ffmpeg-cli-wrapper: A fluent interface to running FFmpeg from Java.

AspectJ with Annotation - 自定义注解

AspectJ with XmlAspectJ with Annotation 中介绍了 2 中实现 AoP 的方式:

  • AspectJ with Xml 中介绍使用纯 XML 的方式配置切面 (Java 类) 和切入点 (类的方法)
  • AspectJ with Annotation 中介绍使用注解配置切面,方法限定表达式配置切入点

这里我们介绍实现 AoP 的第三种方法: 使用注解配置切面和切入点,主要有以下几个部分:

  • Gradle 依赖
  • 自定义注解
  • 自定义注解配置切入点
  • 使用自定义注解
  • Xml 文件中配置自动扫描包路径
  • 测试
  • 三种实现切面方式的比较
  • 使用 SpEL 增强注解

下面就以实现一个分布式锁的注解来进行介绍。

动态链接库和全局变量

同一个动态链接库里定义的全局变量在不同的应用程序 (进程) 里有各自独立的内存空间,互不影响。

如上图所示项目之间的依赖关系:

  • Lib-1 中定义了全局变量 count
  • App-1 连接了 Lib-1,访问 count
  • Lib-2 连接了 Lib-1,访问 count
  • App-2 连接了 Lib-1 和 Lib-2,访问 count

则全局变量 count:

  • App-1 中的 count 和 App-2 中的 count 不是同一个变量 (打印出变量的地址看一下,不一样)
  • App-2 和 Lib-2 中的 count 是同一个变量,因为他们属于同一个进程

类的静态成员变量也是全局变量,单例可使用类的静态成员变量实现,单例的类可编译成 Lib 提供给其他程序放心的使用,不同程序之间同一个类的单例对象不会互相影响。

LibAndGlobalVariable.7z 是按照上图的依赖关系创建的项目,可以下载来运行观察以便更好的加深印象。

Qt 编译 MySQL 插件

很开心 Qt 5.0 发布后好几个版本 Windows 和 Mac 都自带了 MySQL 的插件,以为以后就省事了。Qt 5.12 时 Windows 下也还带有 MySQL 插件,不过忽然发现 Mac 下不带了,哎,又只好自己去编译了。

下面就介绍一下我们在 Mac 中编译 MySQL 插件成功的步骤:

  1. 下载 MySQL 解压版 (macOS 10.14 (x86, 64-bit), Compressed TAR Archive),解压到 /usr/local (不要用 brew 安装)
  2. 命令行进入 MySQL 插件源码目录: cd /Users/Biao/Qt5.12.4/5.12.4/Src/qtbase/src/plugins/sqldrivers/mysql
  3. 删除 mysql.pro 中的 QMAKE_USE += mysql 这一行
  4. 生成 Makefile 文件: qmake "INCLUDEPATH+=/usr/local/mysql/include" "LIBS+=-L/usr/local/mysql/lib -lmysqlclient" mysql.pro
  5. 编译安装: make && make install,然后在 Qt 的 plugins/sqldrivers 目录下就能看到 MySQL 的插件 libqsqlmysql.dylib

编译的过程中出现下面的警告,忽略即可:

Cannot read /Users/Biao/Qt5.12.4/5.12.4/Src/qtbase/src/plugins/sqldrivers/qtsqldrivers-config.pri: No such file or directory


随便提一下,按照 Qt 自带帮助文档中的步骤进行编译:

cd $QTDIR/qtbase/src/plugins/sqldrivers
qmake – MYSQL_PREFIX=/usr/local
make sub-mysql

报错:

Cannot read /Users/Biao/Qt5.12.4/5.12.4/Src/qtbase/src/plugins/sqldrivers/qtsqldrivers-config.pri: No such file or directory
Project ERROR: Library ‘mysql’ is not defined.

可能是因为 MySQL 的安装问题吧,但是文档里也没提示这种情况下 Mac 里 MySQL 应该怎么安装,具体就不深入研究了,反正上面的方式能编译成功。

Spring MVC 中使用 JetCache

JetCache 是一个基于 Java 的缓存系统封装,提供统一的API和注解来简化缓存的使用。 JetCache 提供了比 SpringCache 更加强大的注解,可以原生的支持 TTL、两级缓存、分布式自动刷新,还提供了 Cache 接口用于手工缓存操作。 当前有四个实现,RedisCacheTairCache (此部分未在 github 开源)、CaffeineCache (in memory) 和一个简易的LinkedHashMapCache (in memory),要添加新的实现也是非常简单的。

网上很多文章介绍 JetCache 的文章包括官方文档主要是基于 Spring Boot 的,也介绍了未使用 SpringBoot 的配置方式,但是估计很多同学还是不明白怎么在传统的 Spring MVC 的 Web 项目里使用 JetCache 吧,毕竟不是所有 Web 项目都使用 Spring Boot,接下来就一步一步的介绍使用的方法。

有动画效果的 CheckBox

在 Android 或者 iOS 的界面中经常看到下图这样的 check box,并且选中状态 checked 变化时还有动画效果把白色的小圆球 indicator 从一边移动到另一边。Qt 提供了 QCheckBox,不过不能直接修改 QSS 实现这样的样式,而且也没有 indicator 移动的动画效果。


在这一篇文章中将介绍自定义一个 check box 的类 AnimatedCheckBox,实现上面样式的 check box,并且使用动画移动 indicator,实现时需要注意一下几点:

  • AnimatedCheckBox 继承了 QCheckBox,这样在使用 QCheckBox 的地方就能直接替换为 AnimatedCheckBox 了,并且不需要我们自己维护 check box 的各种状态,相关的信号槽直接使用 QCheckBox 的就可以了
  • 使用 QPropertyAnimation 实现动画效果
  • 虽然 QCheckBox 有一个 indicator 的 subcontrol,但是满足不了需求,所以我们使用了一个 QLabel 来代替默认的 indicator
  • 为了使用动画移动 indicator,不能使用布局管理器来布局 indicator,而是使用绝对坐标定位的方式来控制 indicator 的位置
  • 默认点击 QCheckBox 的 indicator 或者文字时才能切换选中状态,点击 QCheckBox 上其他空白的地方没有反应,我们希望点击 AnimatedCheckBox 上的任何地方都能够切换选中状态,需要重写 mousePressEvent
  • 为了不使用 QCheckBox 的默认样式,实现一个空的 paintEvent
  • 由于 AnimatedCheckBox 的大小是不固定的,所以 indicator 的大小和位置应该在 resizeEvent 中根据 AnimatedCheckBox 的大小和 checked 的值进行计算
  • 使用 QPainter 绘制的方式也能够实现这样的效果 (实现阴影就不那么容易了),这里我们使用 QSS 的方式调整显示的效果,还可以把样式保存到文件中,修改样式不需要重新编译程序

有了这些基础知识后, 逐步的来实现这样的 check box 就容易多了。

Vue 的递归组件

层级结构,也即树结构,是很常见的,例如部门的组织结构,书的章节目录,论坛里帖子的回复等等,可以使用递归的方式遍历树结构的数据,Vue 的组件也能够使用递归的方式展示层级结构的数据,例如下图中所示的回复:

使用 Vue 的递归组件实现组件 Reply 来完成上图的效果。

线段切割法生成红包

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
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;

public class Test {
/**
* amount 元随机生成 n 个红包
*
* @param amount 红包总额
* @param count 红包数量
* @return 返回红包的 list
*/
public static List<Double> createRedEnvelopes(double amount, int count) {
// 使用线段切割法生成红包:
// 1. 把 [0, amount] 随机分成 count 段 (共需要 count+1 个数, 上下边界为 0 和 amount),每段长度为红包的金额
// 在区间 (0, amount) 中生成 count-1 个不重复的随机数 (即段的位置),保存到 segments
// 2. 把 segments 小到大排序,使用了 TreeSet
// 3. 遍历 segments,每个段的长度 segments[i]-segments[i-1] 则为红包的金额 (保留 2 位小数)
// 4. 确保红包的总额为 amount

Set<Double> segments = new TreeSet<>();

// [1] 把 [0, amount] 随机分成 count 段,每段长度为红包的金额,加上边际 0 和 amount,共需要 count 个数
// 在区间 (0, amount) 中生成 count-1 个不重复的随机数 (即段的位置),保存到 segments
// [2] 把 segments 小到大排序,使用了 TreeSet
Random random = new Random();
segments.add(0.0);
segments.add(amount);
while (segments.size() < count-1) {
double t = random.nextDouble() * amount;
segments.add(t);
}

// [3] 遍历 segments,每个段的长度 segments[i]-segments[i-1] 则为红包的金额 (保留 2 位小数)
List<Double> redEnvelopes = new LinkedList<>();

Double[] temp = segments.toArray(new Double[0]);
for (int i = 1; i < temp.length; ++i) {
redEnvelopes.add(toFixed(temp[i]-temp[i-1], 2));
}

// [4] 确保红包的总额为 amount
double sum = redEnvelopes.stream().mapToDouble(a->a).sum();
redEnvelopes.set(0, toFixed(redEnvelopes.get(0) + amount - sum, 2));

return redEnvelopes;
}

/**
* 浮点数保留 scale 位小数位
*
* @param value 浮点数
* @param scale 小数位
* @return 返回小数位为 scale 位的浮点数
*/
public static double toFixed(double value, int scale) {
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
}

public static void main(String[] args) throws Exception {
List<Double> redEnvelopes = createRedEnvelopes(100, 19);
System.out.println(redEnvelopes);
System.out.println(redEnvelopes.stream().mapToDouble(a->a).sum()); // 红包总额
}
}

输出:

1
2
[2.44, 0.07, 0.4, 4.26, 3.7, 1.36, 22.94, 9.69, 5.37, 2.68, 1.78, 1.01, 3.58, 8.65, 11.68, 2.41, 17.98]
100.0

Vue 同步初始化

有这样的一个需求,在组件的 beforeCreated 中需要先从服务器获取登录用户的信息,然后才能继续往下初始化。使用 jQuery 的同步 Ajax 可以实现这个功能,但是 jQuery 的同步 API 已经不再推荐使用 (浏览器里会有警告),而且现在 Ajax 更多推荐使用 Axios,但是它只支持异步 Ajax 请求,此外,Vue 的生命周期函数 beforeCreated, created, mounted 等函数不支持 async + await,看上去实现这个需求挺不容易的。

幸运的是,在 Vue 的路由守卫函数 beforeRouterEnter 中可以等待条件满足后才继续跳转路由进行新路由组件的渲染,使用这个特点能实现阻塞请求后再渲染组件的需求了:

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
<script>
import { LoadingBar } from 'iview';

export default {
beforeRouteEnter(to, from, next) {
LoadingBar.start(); // 加载动画

// // [1] Ajax 异步请求数据
requestUser().then((user) => {
// [2] 数据保存到 localStorage,结束加载动画
LoadingBar.finish();
localStorage.setItem('user', JSON.stringify(user));
next();
}).catch(() => {
LoadingBar.finish();
});
},
data() {
return {
user: null,
};
},
created() {
// [3] 从 localStorage 中获取数据
this.user = JSON.parse(localStorage.getItem('user'));
console.log(this.user);
}
};
</script>

提示:

  • 函数 beforeRouteEnter 中访问不到当前组件的上下文 (这时 this 为 undefined),所以获取到的数据需要先存储起来 (例如使用 localStorage, 不要使用 vuex,还是 this 的问题),然后在组件的生命周期函数如 beforeCreated 中获取
  • next(vm => {}),next 的回调函数在 created, mounted 等执行完后才执行,所以在它里面设置登录用户的数据满足不了我们的要求
  • 为了更好的用户体验,请求数据的时候显示加载进度条

枚举与 QFlags

传统的 C++ 编程中,通常使用整数来保存 enum 的逻辑运算结果 (与、或、非、异或等),在进行逻辑运算的时候没有进行类型检查,一个枚举类型可以和其他的枚举类型进行逻辑运算,运算的结果可以直接传递给接收参数为整数的函数。

Qt 中,模板类 QFlags<Enum> 提供了类型安全的方式保存 enum 的逻辑运算结果解决上面的这几个问题,这种方式在 Qt 里很常见,例如设置 QLabel 对齐方式的函数是 QLabel::setAlignment(Qt::Alignment) (typedef QFlags<Qt::AlignmentFlag> Qt::Alignment),这就意味着传给 setAlignment 的参数只能是枚举 Qt::AlignmentFlag 的变量、它们的逻辑运算结果或者 0,如果传入其他的枚举类型或者非 0 值,编译时就会报错:

1
2
3
4
label->setAlignment(0); // OK
label->setAlignment(Qt::AlignLeft | Qt::AlignTop); // OK

label->setAlignment(Qt::WA_Hover); // Error: 编译时报错

想要把我们定义的枚举类型和 QFlags 一起使用,需要用到两个宏:

  • Q_DECLARE_FLAGS(Flags, Enum):
    • Enum 是已经定义好的枚举类型
    • 展开的结果为 typedef QFlags<Enum> Flags
  • Q_DECLARE_OPERATORS_FOR_FLAGS(Flags):
    • Flags 就是类型 QFlags<Enum>
    • 给 Flags 定义了运算符 |,使得 Enum 和 Enum,Enum 和 Flags 能够使用或运算符 |,结果为 Flags

使用 QFlags 时需要留意以下几点:

  • QFlags 其实就是用于位操作,设置它保存的数值的某一位为 1 或者为 0,所以和 QFlags 一起使用的枚举类型,其变量的值需要是 2 的 n 次方,即 0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, …,它们的特点是其二进制位中只有一位是 1,其他位全部为 0,这样每一个枚举值只会和 QFlags 中的某一个位对应,不会出现交叉的情况
  • 调用函数 QFlags::setFlag(Enum flag, bool on = true),on 为 true 时设置 flag 对应的位为 1,on 为 false 时设置 flag 对应的位为 0,设置 flag 对应的位为 1 还可以使用运算符 |
  • 调用函数 QFlags::testFlag(Enum flag) 测试 flag 对应的位是否为 1
  • 整数转为 QFlags: 把整数作为 QFlags 构造函数的参数创建一个 QFlags 变量
  • QFlags 转为整数: 调用 int(flags) 把 QFlags 变量转换为整数值

C++ 临时变量的析构

Qt 中经常会使用 QString::toUtf8() 获取字符串的 UTF-8 数组,例如下面这样使用:

1
2
3
4
void foo(const char *data) { ... }

const char *data = QString::toUtf8().constData();
foo(data);

看上去没啥问题,其实这个代码有很严重的问题,一般不是经验很丰富的程序员很难发现这个 Bug,很可能会导致程序崩溃退出,但就是找不到为什么。这是因为 QString::toUtf8() 返回的是一个 QByteArray 的栈变量,第 3 行语句中的 QByteArray 是一个临时变量,这行语句结束时这个变量就被析构了,指针 data 指向的内存也被回收,所以下面使用的 data 指向的内存已经被释放了,难怪程序会崩溃了。把代码修改为下面的样子则就能够正确运行了:

1
2
3
4
5
6
7
// [1]
foo(QString::toUtf8().constData()); // 很奇怪是不是?

// [2] 先保存 ba
QByteArray ba = QString::toUtf8();
const char *data = ba.constData();
foo(data);

这个问题涉及到临时变量的析构,也许还不太明白是怎么回事,下面的程序就来模拟上面的情况,一看就明白了:

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
#include <QDebug>

class B {
public:
B() {
qDebug() << "B()";
}

~B() {
qDebug() << "~B()";
}

int value = 10;
};

// A 中有一个 B* 的成员变量
class A {
public:
A() {
qDebug() << "A()";
b = new B();
}

~A() {
delete b;
qDebug() << "~A()";
}

B *b = nullptr;
};

// 返回 A 的栈变量对象
A a() {
return A();
}

void foo(B *b) {
qDebug() << b->value;
}

int main(int argc, char** argv) {
// 提示: 下面的语句中 a() 返回的变量是一个临时变量,在语句结束时才析构提示

foo(a().b); // OK: 函数 foo() 执行完时 a() 创建的 A 和 B 才析构,在函数 foo() 中放心的使用 b

B *b = a().b; // Error: 语句结束时 A 和 B 才析构,此时 b 已经被回收,所以不要在后面的语句中使用 b
qDebug() << b->value; // Error: b 已经被析构,b->value 的值不是 10,而是随机的

qDebug() << "main() 结束";
return 0;
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
A()
B()
10
~B()
~A()

A()
B()
~B()
~A()
1859919056 // 注意: 这里不是 10 呢

main() 结束

Vue 尽量不用 scoped

在 Vue 中,为了减少样式的冲突,可以使用 scoped 使得生成的 DOM element 和 class 都带上随机属性,但有利有弊,总的来说,不推荐使用 scoped,因为使用 scoped 后就不能跨级设置组件的样式了。

跨级: 组件 A 中直接使用组件 B,组件 B 中直接使用组件 C,因为 C 没有在 A 中直接使用,所以我们说 A 和 C 跨级了。

线程的上下文

Qt 中每个 QObject 及其子类的对象都有自己的线程上下文环境,即对象所属的线程,对象属于创建它的函数执行时所在的线程,例如在 Ui 线程中的函数里创建了对象 ops,则 ops 属于 Ui 线程。使用 QObject::thread() 获取对象所属线程,可以使用 QObject::moveToThread(otherThread) 移动一个对象到另一个线程。由于跨线程调用函数有可能会造成程序崩溃,所以有比较了解代码在执行时它所处的线程。

下面以示例演示不同情况下代码执行时所处的线程:

  • Widget 所属线程
  • 线程对象所属线程
  • Lambda 的方式处理信号槽
  • Qt 5 函数指针的方式处理信号槽
  • 传统 signal slot 的方式处理信号槽

注意: 下面的注释都是基于示例代码的,为了不让描述的太过繁琐,便于帮助理解,有些描述理论上可能是不精确的,需要大家自行更进一步的分析。

上传 Blob URL 指向的文件

Blob 是 Binary large object 的简称,格式为 blob:http://localhost:8080/da126298-1b6b-4dfb-8a92-2e3ccbee611d, 是一种伪协议,只能在浏览器本地使用,例如在使用 TinyMCE 粘贴从 Word 复制的图片时,得到一个 Blob URL,可以使用下面的方式把 Blob 对应的图片上传到服务器:

  1. 使用 XMLHttpRequest 获取 Blob URL 源数据的 Blob 文件对象
  2. 上传 Blob 到服务器

下面以上传图片 Blob 为例:

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
const xhr = new XMLHttpRequest();
xhr.open('GET', blobUrl); // blob:http://localhost:8080/da126298-1b6b-4dfb-8a92-2e3ccbee611d
xhr.responseType = 'blob';

xhr.onreadystatechange = function() {
if (this.readyState === 4 && this.status === 200) {
const imageBlob = this.response;
const imageType = imageBlob.type;
let imageName = null;

// 根据文件的类型确定文件名,否则服务端得到的文件名为 blob,没有后缀
if (imageType.includes('png')) {
imageName = 'uploaded-image.png';
} else if (imageType.includes('gif')) {
imageName = 'uploaded-image.gif';
} else if (imageType.includes('bmp')) {
imageName = 'uploaded-image.bmp';
} else {
imageName = 'uploaded-image.jpg';
}

// 上传 Blob 图片
const form = new FormData();
form.append('file', imageBlob, imageName); // 第三个参数为文件名

$.ajax({
type: 'POST',
url : Urls.FORM_UPLOAD_TEMPORARY_FILE,
data: form,
processData: false,
contentType: false,
}).done(function(result) {
// 把服务器返回的图片 URL 插入到需要的地方
console.log(result);
});
}
};

xhr.send();

获取 Blob 对象的核心代码为:

1
2
3
4
5
6
7
8
9
var xhr = new XMLHttpRequest();
xhr.open('GET', 'blob:http://your.blob.url.here', true);
xhr.responseType = 'blob';
xhr.onload = function(e) {
if (this.status == 200) {
var blob = this.response; // blob is now the blob that the object URL pointed to.
}
};
xhr.send();

使用 Axios 的为:

1
2
3
4
5
6
7
axios({
method: 'get',
url: blobUrl,
responseType: 'blob',
}).then(function(response) {
let blob = response.data;
})

Qt 生成报表

开发中经常会需要输出报表,方法很多,这里就不一一去列举对比各种方法的优缺点,只介绍使用字符串模板输出 PDF 报表的方式,能够满足绝大多数时候的需求:

  1. 字符串模板 + 数据输出 HTML
  2. HTML 输出 PDF

输出 HTML

为了生成 HTML,可以在程序中使用字符串拼接的方式,但这种方式对于复杂点的 HTML 就会非常麻烦,不推荐使用,我们这里将使用字符串模板引擎 Inja 来生成 HTML,使用步骤如下:

  1. 下载 Inja

  2. 解压,得到的目录结构如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    inja-2.1.0
    ...
    ├── single_include
    │   └── inja
    │   └── inja.hpp
    └── third_party
    └── include
    └── nlohmann
    ├── LICENSE.MIT
    └── json.hpp
  3. 复制 single_include 下的 inja 目录和 third_party/include 下的 nlohmann 目录到项目的 lib 目录下:

    1
    2
    3
    4
    5
    6
    lib
    ├── inja
    │   └── inja.hpp
    └── nlohmann
    ├── LICENSE.MIT
    └── json.hpp
  4. Qt 工程的 pro 文件中添加包含路径: INCLUDEPATH += lib

  5. 最简单的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <inja/inja.hpp>

    using namespace inja;
    using json = nlohmann::json;

    int main(int argc, char *argv[]) {
    json data;
    data["name"] = "world";

    render("Hello {{ name }}", data); // Returns std::string "Hello world"

    return 0;
    }

    Inja 的模板使用了 Mustache 风格的语法,上面例子中 { { name } }data["name"] 的值 world 替换掉,生成字符串 Hello world,其中 Inja 使用 JSON 库 nlohmann json 传递数据给模板。

动态加载 JS 和 CSS

动态加载 JS 文件的正确姿势中列举了多种加载 JS 的方式,下面摘出个人最喜欢的方式,用于动态加载 JS 和 CSS。

动态加载 JS

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>Load JS</title>
</head>

<body>
<script>
/**
* 使用 Promise 异步加载 JS
*
* @param {String} url JS 的路径
* @param {String} id JS 的 <style> 的 ID,如果已经存在则不再重复加载,默认为时间戳+随机数
* @return 返回 Promise 对象, then 的参数为加载成功的信息,无多大意义
*/
function loadJs(url, id = Date.now() + '-' + Math.random()) {
return new Promise(function(resolve, reject) {
// 避免重复加载
if (document.getElementById(id)) {
resolve('success: ' + url);
return;
}

var script = document.createElement('script');

if (script.readyState) { // IE
script.onreadystatechange = function() {
if (script.readyState == 'loaded' || script.readyState == 'complete') {
script.onreadystatechange = null;
resolve('success: ' + url);
}
};
} else { // Other Browsers
script.onload = function() {
resolve('success: ' + url);
};
}

script.onerror = function() {
reject(Error(url + ' load error!'));
};

script.type = 'text/javascript';
script.id = id;
script.src = `${url}?hash=${id}`;
document.getElementsByTagName('head').item(0).appendChild(script);
});
}

// 加载 JS 后执行 then 的回调函数
loadJs('http://cdn.bootcss.com/jquery/1.9.1/jquery.min.js', 'jq').then(msg => {
console.log(msg);
});

// 加载完所有 JS 后执行 then 的回调函数
const one = 'http://cdn.bootcss.com/jquery/1.9.1/jquery.min.js';
const two = 'https://github.githubassets.com/assets/frameworks-5c304257.js';
Promise.all([loadJs(one, 'jq'), loadJs(two)]).then(results => {
console.log(results);
})
</script>
</body>

</html>

汉若塔

传说越南河内某间寺院有三根银棒,上串 64 个金盘,金盘尺寸由下到上依次变小。寺院里的僧侣依照一个古老的预言,以下述规则把这些盘子从第一根银棒移至第三根银棒上 (可以借助第二根银棒):

  • 每次只能移动一个圆盘
  • 大盘不能迭在小盘上面

预言说当这些盘子移动完毕,世界就会灭亡。这个传说叫做梵天寺之塔问题,即汉若塔问题。

用递归实现的代码很简洁:

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 class Test {
public static void main(String[] args) {
hanoi(3, 'A', 'B', 'C');
}

/**
* 把 n 个盘子从 a 柱移动到 c 柱,可以借助 b 柱
*
* @param n: 盘子的数量
*/
public static void hanoi(int n, char a, char b, char c) {
if (n == 1) {
move(1, a, c);
return;
}

hanoi(n-1, a, c, b); // 小和尚借助 c 柱把 a 柱最上面的 n-1 个盘子移动到 b 柱
move(n, a, c); // 老和尚把最下面的一个盘子从 a 柱移动到 c 柱
hanoi(n-1, b, a, c); // 小和尚再借助 a 柱把 b 柱上的 n-1 个盘子移动到 c 柱
}

// sn 是盘子的序号: 开始时 a 柱上的盘子序号从上到下依次为 1, 2, 3, ..., n
public static void move(int sn, char from, char to) {
System.out.printf("%d: %c -> %c\n", sn, from, to);
}
}

代码不多,但是一直理解不了这个递归为什么要这么写,直到大学时人工智能的老师给了个记忆的办法,从此就理解了:

老和尚比较懒 (有点大不敬,忽略之),于是就叫小和尚把第一根柱子最上面的 n-1 个盘子借助第三根柱子先搬到第二根柱子上,然后老和尚自己把第一根柱子最下面的那个大盘子搬到第三根柱子上,再叫小和尚把第二根柱子上的 n-1 个盘子借助第一根柱子搬到第三根柱子上。

斐波那契数列

斐波那契数列 (意大利语: Successione di Fibonacci),又称黄金分割数列、费波那西数列、费波拿契数、费氏数列,指的是这样一个数列:1、1、2、3、5、8、13、21、……在数学上,斐波那契数列以如下被以递归的方法定义:F1=1,F2=1,Fn=F(n-1) + F(n-2) (n>2,n∈N*),使用递归实现如下:

1
2
3
4
5
public static int fib(int n) {
if (n == 1 || n == 2) { return 1; }

return fib(n-1) + fib(n-2);
}

求 fib(6) 斐波那契数列的递归过程如图:

重叠子问题 (Overlap subproblem): 把大问题被分解为多个小问题进行求解,小问题重复的出现多次。

答疑表设计

答疑主要涉及 2 个表,问题表 qa_queston 和回复表 qa_reply,核心字段如下:


数据存储逻辑:

  • 问题存储到表 qa_question
  • 每个回复都存储问题 ID: question_id
  • 每个回复都保存第一级回复 ID: top_reply_id,为了方便找到回复树下的所有回复
  • 对问题进行的回复存储到表 qa_reply
    • parent_id 为 0
    • top_reply_id 为此回复的 ID
  • 对回复进行的回复存储到表 qa_reply
    • 回复是一个有层级的树状结构,所以使用了 parent_id
    • parent_id 为被回复的回复 ID
    • top_reply_id 为此回复所在第一级回复 ID

此设计的优点:

可以使用一条 SQL 语句分页查询出问题的 N 个一级回复、以及这些一级回复下所有相关的回复:

  • 查找到问题的 n 个第一级回复的 top_reply_id x
  • 查找问题的回复中所有第一级回复为 x 的回复
1
2
3
4
5
SELECT q.*, r.*
FROM qa_question q
JOIN (SELECT question_id, top_reply_id FROM qa_reply WHERE question_id = 1 AND parent_id = 0 LIMIT 0, 10) t ON t.question_id = q.id
LEFT JOIN qa_reply r ON r.question_id = q.id AND r.top_reply_id = t.top_reply_id
ORDER BY r.created_at DESC;

parent_id 为 0,说明是第一级回复。

如果回复再增加 level 属性,还可以对回复层数进行过滤,当层数很多时,可以动态请求更深层次的回复,避免一次获取太多数据。

此外,根据业务,可以对问题表增加字段如是否加精、推荐置顶、点赞数量、回复数量等,回复表增加点赞数量、被回复者的名字 (方便显示) 等,对点赞、取消点赞、点击推荐等行为记录到日志表中。


以下部分用于测试

问题和回复

1
2
3
4
5
6
7
8
9
1. 特别定制「乌龙茶」,有人跟我一样只喜欢有味道的,或者冰的饮品嘛?
1. 东方树叶,每天基本都会喝 1,2 瓶
2. 抹茶粉确实很流行,但是和我提的浓缩粉其实是两种产品
3. 炭焙的一般口味略重
4. 好处就是完全不伤胃
2. 找一个靠谱的设计师长期合作
5. 明天早上起来加你
6. 怎么联系
7. 本人做一些兼职

清空数据库

1
2
TRUNCATE TABLE qa_question;
TRUNCATE TABLE qa_reply;

问题一

1
2
3
4
5
6
INSERT INTO qa_question(id, clazz_id, user_id, user_name, content) VALUES (1, 1, 30, 'Bob', '特别定制「乌龙茶」,有人跟我一样只喜欢有味道的,或者冰的饮品嘛?');

INSERT INTO qa_reply(id, question_id, parent_id, top_reply_id, user_id, user_name, content) VALUES (1, 1, 0, 1, 31, 'Tom', '东方树叶,每天基本都会喝 1,2 瓶');
INSERT INTO qa_reply(id, question_id, parent_id, top_reply_id, user_id, user_name, content) VALUES (2, 1, 0, 2, 31, 'Tom', '抹茶粉确实很流行,但是和我提的浓缩粉其实是两种产品');
INSERT INTO qa_reply(id, question_id, parent_id, top_reply_id, user_id, user_name, content) VALUES (3, 1, 2, 2, 31, 'Tom', '炭焙的一般口味略重');
INSERT INTO qa_reply(id, question_id, parent_id, top_reply_id, user_id, user_name, content) VALUES (4, 1, 2, 2, 31, 'Tom', '好处就是完全不伤胃');

问题二

1
2
3
4
5
INSERT INTO qa_question(id, clazz_id, user_id, user_name, content) VALUES (2, 1, 30, 'Bob', '找一个靠谱的设计师长期合作?');

INSERT INTO qa_reply(id, question_id, parent_id, top_reply_id, user_id, user_name, content) VALUES (5, 2, 0, 5, 32, 'Max', '明天早上起来加你');
INSERT INTO qa_reply(id, question_id, parent_id, top_reply_id, user_id, user_name, content) VALUES (6, 2, 5, 5, 32, 'Max', '怎么联系');
INSERT INTO qa_reply(id, question_id, parent_id, top_reply_id, user_id, user_name, content) VALUES (7, 2, 0, 7, 32, 'Max', '本人做一些兼职');

查询问题和回复

只在第一级回复上分页,同时查询出这个回复的所有后代:

  1. 查找到问题的 n 个第一级回复的 top_reply_id x
  2. 查找问题的回复中所有第一级回复为 x 的回复
1
2
3
4
5
SELECT q.*, r.*
FROM qa_question q
JOIN (SELECT question_id, top_reply_id FROM qa_reply WHERE question_id = 1 AND parent_id = 0 LIMIT 0, 10) t ON t.question_id = q.id
LEFT JOIN qa_reply r ON r.question_id = q.id AND r.top_reply_id = t.top_reply_id
ORDER BY r.created_at DESC;

修改 question_id 查询不同问题的回复。

iView 使用 JSX render 树

iView 提供了树的组件 Tree,复杂的节点可以使用 render 自定义显示:

  • <Tree>:render 渲染所有节点
  • 节点的属性 render 覆盖全局的 render,优先级更高

文档中使用的 render 语法比较繁琐,下面使用 JSX 进行改写,实现下面的树,只是额外增加了样式的变化:

  • 高亮当前行
  • 高亮被点击的节点名字 (使用了 jQuery)
  • 鼠标移动到节点所在的行时才显示这一行的按钮


MyBatis 的 ResultMap 注意事项

MyBatis 的 ResultMap 比较灵活,使用时需要注意以下几点:

  • ResultMap 中 <result> 的 property 对应 Bean 的属性名,column 对应 SELECT 的字段名:
    • column 存在,property 对应的属性存在时正确
    • column 存在,property 对应的属性不存在时报错
    • column 存在,没有写相关 <result>不报错,Bean 有同名属性时自动设置属性的值,没有时忽略
    • column 不存在,property 对应的属性存在时不报错
    • column 不存在,property 对应的属性不存在时不报错
  • SELECT 的字段名和 ResultMap 里属性名相同时,可以不用写 <result>,MyBatis 会自动映射对应的 column 和 property,也就是说如果 SELECT 的字段名和 Bean 的属性名都是同名的,即使 ResultMap 为空,Bean 的属性都会自动设置好,例如下面的 id 和 info 都注释掉了,它们的属性值仍然被正确的设置了
  • SELECT 中的字段可以比 ResultMap 里的 <result> 多或者少
  • SELECT 的字段名和 ResultMap 中 <result> 的 property 不同时,需要使用 column 进行映射
  • 但是,如果使用了 <collection> 或者 <association>,只有使用 <result> 或者 <id> 设置过的属性会被设置,诡异的规则

下面的代码能够正常运行,对这几条进行了展示:

Java Bean:

1
2
3
4
5
6
7
public class Demo {
private int id;
private String info;
private boolean marked;

// Getters and setters
}

SQL:

1
2
3
<select id="findDemoById" parameterType="int" resultMap="DemoResultMap">
SELECT id, info, is_marked FROM demo WHERE id=#{id}
</select>

ResultMap:

1
2
3
4
5
6
7
8
9
<resultMap id="DemoResultMap" type="Demo">
<!--<id property="id" column="id"/>-->
<!--<result property="info" column="info"/>-->

<result property="marked" column="is_marked"/>

<!-- extra 在 SELECT 的字段名和 Bean 的属性里都不存在 -->
<result property="extra" column="extra"/>
</resultMap>

常用排序

下面列举常用的排序算法的实现:

  • 冒泡排序
  • 插入排序
  • 选择排序
  • 快速排序
  • 归并排序
  • 堆排序

排序的时候, 需要大量的交换数组中的 2 个元素, 使用下面的函数 swap() 进行交换:

1
2
3
4
5
6
7
8
/**
* 交换数组中指定下标的 2 个元素的值
*/
public static void swap(int[] a, int indexA, int indexB) {
int temp = a[indexA];
a[indexA] = a[indexB];
a[indexB] = temp;
}

冒泡排序

原理:

  1. 比较相邻的元素,如果第一个比第二个大,就交换他们两个,把大的交换到后面。
  2. 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对,最后的元素应该会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
  5. 有 n 个元素的数组需要进行 n-1 轮

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 冒泡排序
*/
public static void bubbleSort(int[] a) {
for (int r = 1; r < a.length; ++r) { // r: round
for (int i = 0; i < a.length-r; ++i) {
// 如果 a[i] 大于 a[i+1], 则把大的交换到后面
if (a[i] > a[i+1]) {
swap(a, i, i+1);
}
}
}
}

FastJson 反序列化 Boolean

JSON 字符串反序列化为 Java 对象时, 不只是 true 和 false 能够转换为 boolean 变量的值:

  • boolean:
    • true: true
    • false: false
  • 数字:
    • 1: true
    • 0: false
    • 其他数字: false
  • 字符串:
    • “true”: true (大小写不敏感)
    • “false”: false (大小写不敏感)
    • “1”: true
    • “0”: false
    • 其他字符串抛异常
1
2
3
4
5
6
7
8
9
10
11
{ "visible": true }    // true
{ "visible": false } // false
{ "visible": 1 } // true
{ "visible": 0 } // false
{ "visible": 3 } // false
{ "visible": "true" } // true
{ "visible": "True" } // true
{ "visible": "false" } // false
{ "visible": "falsE" } // false
{ "visible": " true" } // Exception
{ "visible": "Bla" } // Exception

Vue 中使用 TinyMCE

TinyMCE 是一个功能强大的富文本编辑器:

  • 支持从 Word 中复制的文本格式
  • 拖拽修改图片的大小
  • 表格拖拽修改单元格大小
  • 提供了三种编辑模式
    • Full featured: 默认显示工具栏
    • Inline: 编辑器的到焦点时才显示工具栏
    • Distraction Free: 选中文本后才显示工具栏
  • 同一页面中可以创建多个编辑器
  • 界面美观简洁, 使用 CSS 修改样式很方便, 工具栏按钮使用 SVG 图片
  • 插件开发简单, 甚至不需要开发插件就能向工具栏插入按钮

TinyMCE 提供了 cloud 版本, 也可以下载到本地使用.

使用 TinyMCE 只需要 3 步:

  1. 引入 TinyMCE
  2. 为 TinyMCE 准备一个 DOM
  3. 基于准备好的 DOM,初始化 TinyMCE 实例

下图是使用默认参数创建的 TinyMCE 编辑器:

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<title>TinyMCE</title>
<!-- [1] 引入 TinyMCE -->
<script src="tinymce/tinymce.min.js"></script>
</head>

<body>
<!-- [2] 为 TinyMCE 准备一个 DOM -->
<div id="editor">道格拉斯•狗</div>

<script>
// [3] 基于准备好的 DOM,初始化 TinyMCE 实例
tinymce.init({ selector: '#editor' });
</script>
</body>

</html>

官方提供了非常丰富的文档, 请访问 https://www.tiny.cloud/docs 进行阅读, 了解 TinyMCE 更多的使用方法.

Vue 中使用 Echarts

Echarts, 一个使用 JavaScript 实现的开源可视化库,可以流畅的运行在 PC 和移动设备上,兼容当前绝大部分浏览器(IE8/9/10/11,Chrome,Firefox,Safari等),底层依赖轻量级的矢量图形库 ZRender,提供直观,交互丰富,可高度个性化定制的数据可视化图表。

Vue 项目中使用 yarn 安装 Echarts: yarn add echarts, 然后在页面中使用 Echarts 步骤如下:

  1. 为 ECharts 准备一个具备大小 (宽高) 的 DOM

  2. 引入 echarts:

    • 全部引入:

      1
      import echarts from 'echarts'; // 方便, 但是也同时引入了很多不需要的组件
    • 按需引入:

      1
      2
      import echarts from 'echarts/lib/echarts'; // 引入基本模板
      import bar from 'echarts/lib/chart/bar'; // 引入柱状图组件
  3. 基于准备好的 DOM,初始化 Echarts 实例

  4. 设置 Echarts 图表数据

入门示例

使用 Echarts 实现的柱状图:

Tio WebSocket 经验

使用 Tio 实现 WebSocket, 可以在 tio-websocket-showcase 的基础上进行修改 (据说能够达到企业级性能, 单机支持 30 万连接). 利用 Tio 提供的绑定功能实现消息群发和给指定的用户发送消息, 并且把用户对象直接存储到 ChannelContext 上, 还能省去自己管理用户的麻烦, Tio 也提供了心跳检测功能, IP 黑名单, 流量监控等, 我们只需要关注与业务层代码即可, 下面介绍一些相关的经验:

  • 应用中只有一个 GroupContext, 发送消息, 获取小组信息等

  • 一个连接对应一个 ChannelContext (ip:port), 可以使用 setAttribute() 存储业务数据, getAttribute() 获取数据

  • 绑定 (使用 Tio 进行绑定, 可参考让网络编程更轻松和有趣 t-io):

    • userid: 一个 userid 可以绑定多个 ChannelContext (实现同一个账号多个设备登录)

      1
      2
      bindUser(ChannelContext channelContext, String userid)
      SetWithLock<ChannelContext> getChannelContextsByUserid(GroupContext groupContext, String userid)
    • token: 一个 token 可以绑定多个 ChannelContext (实现同一个账号多个设备登录)

      1
      2
      bindToken(ChannelContext channelContext, String token)
      SetWithLock<ChannelContext> getChannelContextsByToken(GroupContext groupContext, String token)
    • bsId: 一个 bsId 只能绑定一个 ChannelContext (实现同一个账号只允许登录一个设备)

      1
      2
      bindBsId(ChannelContext channelContext, String bsId)
      ChannelContext getChannelContextByBsId(GroupContext groupContext, String bsId)

      下面介绍的绑定以 bsId 为例, 其他的方式参考实现即可

简单的 Mindmap

树形结构如果作为侧边栏, 使用 zTree 比较合适, 下图所示的平铺展开整棵树, 使用 zTree 估计就不适合了, 在此我们使用 jQuery 的另一个插件 Simple jQuery Mind Map Diagram Plugin - mindmap 来实现.

文档的例子使用静态的 HTML 标签准备 mindmap 需要的树形结构的数据, 实际中大多情况下树形结构的数据是存储在文件或者通过接口返回, 我们这里展示递归的方式遍历树形结构的数据创建 mindmap 需要的 HTML 标签. 因为每个节点都是 HTML 的标签, 能很方便的使用 CSS 定制样式.

Ckeditor 5 简介

新版富文本编辑器 Ckeditor 5 比 Ckeditor 4 使用更简单, 下载 zip 包后只需要里面的 ckeditor.js (连样式文件都不需要), 像下面这样 3 步就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="en" dir="ltr">

<head>
<meta charset="utf-8">
<title>Ckeditor Demo</title>
</head>

<body>
<div id="editor"></div> <!-- [1] -->

<script src="ckeditor.js"></script> <!-- [2] -->
<script>
ClassicEditor.create(document.querySelector('#editor')); // [3]
</script>
</body>

</html>

只是简单的使用, 默认的功能就可以了. 如果还需要更多的功能, 例如集成多种编辑模式, 使用非默认自带插件, 自定义插件等就不能使用直接下载的代码 (You can’t add a plugin to an existing build), 需要我们自己编译源码才行, 下面就一一进行介绍.

圆角 button and widget 组合

下图的控件估计很多数人会使用绘图的方式实现, 代码实现起来虽然不会很复杂, 但是要控制得到满意的效果就不太容易, 对于我来说, 能不使用继承就不使用继承 (例如给某些控件处理事件时, 能用 eventFilter 实现就尽量避免使用继承, 因为继承又要多出几个源码文件 =_=!!!). 巧妙的利用 QSS, 一个 QPushButton 和 QWidget 也能实现.

下面就来看实现的方法吧:

  1. 在 QtCreator 中如下图在一个 QWidget 中放一个 QPushButton, 右边放一个 Horizontal Spacer, 然后水平布局

  2. 使用下面的 QSS 就可以得到我们想要的效果了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #widget {
    min-height: 40px;
    max-height: 40px;
    border: 4px solid white;
    border-top-left-radius: 24px;
    border-bottom-left-radius: 24px;
    padding: 0;
    background: #AAA;
    }

    QPushButton {
    min-height: 40px;
    max-height: 40px;
    min-width: 40px;
    max-width: 40px;
    border: 4px solid white;
    border-radius: 24px;
    background: lightblue;
    }

    QPushButton:hover {
    background: orange;
    }

补充说明:

  • 高: widget 和 button 的最大最小高都设置为 40px, 这样就不会随布局的变化改变了, 圆角就不会受影响
  • 宽: button 的最大最小宽都设置为 40px, 这样就可以把按钮设置为圆形, 而不会变成圆角矩形
  • 圆角半径: 为 24px 而不是 20px, 因为圆角的计算需要把边框的宽度也计算在内, 边框的宽度是 4px, 所以圆角半径设置为 24px
  • Padding: 为了 widget 和 button 之间不留空隙, 设置 widget 的 padding 为 0
  • 边框和背景色就无需多说了, 根据自己的爱好设置即可

Gradle 管理 Scala 项目

可以使用 Gradle 来管理 Scala 项目, 和管理 Java 的项目是一样的.

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
scala
├── build.gradle
└── src
├── main
│   ├── java
│   ├── resources
│   └── scala
│   └── AppDemo.scala
└── test
├── java
├── resources
└── scala

初始文件

build.gradle 的内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
plugins {
id 'java'
id 'scala'
id 'application'
id 'com.github.johnrengelman.shadow' version '4.0.3'
}

// [1] Scala 需要的依赖
dependencies {
compile "org.scala-lang:scala-library:2.12.7"
compile "org.scala-lang:scala-compiler:2.12.7"
compile "org.scala-lang:scala-reflect:2.12.7"
}

// [2.1] 从命令行运行默认类: gradle run
// [2.2] 从命令行运行某个类: gradle run -DmainClass=Foo
ext {
project.mainClassName = System.getProperty("mainClass", "AppDemo")
}

// [3] 打包: gradle clean shadowJar [-DmainClass=Foo]
shadowJar {
mergeServiceFiles('META-INF/spring.*')
}

插件 shadowJar 用于项目打包, 更多信息请参考 https://qtdebug.com/misc-gradle-app/

AppDemo.scala 的内容为:

1
2
3
4
5
object AppDemo {
def main(args: Array[String]): Unit = {
println("Hello World")
}
}

运行打包

  • 运行: gradle run

    运行对象 AppDemo 的 main 函数, 因为 project.mainClassName 中定义了默认运行的类名为 AppDemo

  • 打包: gradle clean shadowJar

Scala 语法摘要

Scala: Scalable language, 面向对象的函数式编程语言.

类型

类型的首字母大写, 没有基本类型:

  • Boolean
  • Byte
  • Char
  • Short
  • Int
  • Long
  • Float
  • Double
  • String
  • Unit (即是 void)
  • Any
  • AnyRef
  • AnyValue

变量

常量: val greeting: String = "Hello world" (value), val π = 3.1415926

变量: var greeting: String = "Hello world" (variable)

名字: 字母, 数字, 特殊操作符如 +, -, *, /, π, θ 等, 不能以数字开头

MongoDB 账号密码登录

配置MongoDB 账号密码登录:

  1. 创建全局管理员
  2. 创建数据库用户
  3. 数据库用户登录

一、创建全局管理员

使用非安全模式启动 MongoDB,不需要登录,可以操作任何数据库:

  1. 启动 MongoDB: mongod --config /usr/local/etc/mongod.conf

  2. 进入 MongoDB 客户端: mongo

  3. 进入数据库 admin: use admin

  4. 创建管理员 admin:

    1
    2
    3
    4
    5
    6
    7
    db.createUser(
    {
    user: "admin",
    pwd: "admin",
    roles: [{ role: "userAdminAnyDatabase", db: "admin" }, "readWriteAnyDatabase"]
    }
    )
  5. 退出 MongoDB 客户端: exit

  6. 退出非安全模式启动的 MongoDB

二、创建集合的用户

安全模式启动 MongoDB,登录全局的管理员的 admin,然后创建指定数据库的用户:

  1. 安全模式启动 MongoDB: mongod --auth --config /usr/local/etc/mongod.conf

  2. 进入 MongoDB 客户端: mongo

  3. 管理员登录:

    1. use admin
    2. db.auth("admin", "admin")
  4. 进入数据库 foo: use foo (不存在则会自动创建)

  5. 创建用户 bar:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    db.createUser(
    {
    user: "bar",
    pwd: "bar",
    roles: [
    { role: "dbAdmin", db: "foo" },
    { role: "readWrite", db: "foo" }
    ]
    }
    )
  6. 退出 MongoDB 客户端: exit

三、数据库用户登录

  1. 安全模式启动 MongoDB:

    • 方式一: mongod --auth --config /usr/local/etc/mongod.conf

    • 方式二: 配置文件中添加下面的配置,执行 mongod --config /usr/local/etc/mongod.conf 进行启动

      1
      2
      security:
      authorization: enabled
  2. 用户登录:

    • 方式一:
      1. 进入 MongoDB 客户端: mongo
      2. 进入数据库: use foo
      3. 用户登录: db.auth('bar', 'bar')
      4. 操作数据库
    • 方式二:
      1. 进入 MongoDB 客户端同时进行登录: mongo --port 27017 -u "bar" -p "bar" --authenticationDatabase "foo"
      2. 进入数据库: use foo
      3. 操作数据库