zsh必备插件

首先是 oh-my-zsh 自带的 alias 插件,这些东西能让你在终端少打很多字:

1. git

定义了有关 git 的 alias。常用的有

  • gaa = git add –all
  • gcmsg = git commit -m
  • ga = git add
  • gst = git status
  • gp = git push

2. tmux

定义了有关 tmux 的 alias。常用的有

  • tl = tmux list-sessions
  • tkss = tmux kill-session -t
  • ta = tmux attach -t
  • ts = tmux new-session -s

然后是 oh-my-zsh 自带的,一些提供实用命令的插件。

1. extract

提供一个 extract 命令,以及它的别名 x。功能是:一!键!解!压!你知道tar的四种写法吗?我也不知道,所以我装了这个。从今以后 tar, gz, zip, rar 全部使用 extract 命令解压,再也不用天天查 cheatsheet 啦!

2. rand-quote

提供一条 quote 命令,显示随机名言。和fortune的作用差不多,但是我感觉fortune上面大多是冷笑话,还是quote的内容比较有意思。

当然这种东西很少有人会主动去按的。所以你可以在你的zshrc里面的最后一行加上quote,实现每次打开shell显示一条名言的效果~

再进一步,安装一个cowsay,把quote | cowsay放到zshrc的最后一行。于是每次打开终端你就可以看到一头牛对你说:

imgimg

3. themes

提供一条 theme 命令,用来随时手动切换主题。在想要尝试各种主题的时候很实用,不需要一直改 zshrc 然后重载。

4. gitignore

提供一条 gi 命令,用来查询 gitignore 模板。比如你新建了一个 python 项目,就可以用

1
gi python > .gitignore 

来生成一份 gitignore 文件。

5. cp

提供一个 cpv 命令,这个命令使用 rsync 实现带进度条的复制功能。

6. zsh_reload

提供一个 src 命令,重载 zsh。对于经常折腾 zshrc 的我,这条命令非常实用。

7. git-open

提供一个 git-open 命令,在浏览器中打开当前所在 git 项目的远程仓库地址。

8. z

提供一个 z 命令,在常用目录之间跳转。类似 autojump,但是不需要额外安装软件。


接着是 oh-my-zsh 自带的,其他一些功能强大的实用工具。

1. vi-mode

vim输入模式,非常强大,不用多说。

2. per-directory-history

开启之后在一个目录下只能查询到这个目录下的历史命令。按 Ctrl+g 开启/关闭。对我来说很实用,但是不一定所有人都喜欢,可以考虑一下自己是否真的需要。

3. command-not-found

当你输入一条不存在的命令时,会自动查询这条命令可以如何获得。

4. safe-paste

像我这样的懒人,经常会从网上复制各种脚本。但是复制的命令有可能并不就是我要的,可能还需要改一改。但是往往我复制了几行脚本,粘贴到 zsh 里,就发现它直接运行了。这真是非常危险。

这个插件的功能就是:当你往 zsh 粘贴脚本时,它不会被立刻运行。给了我这种懒人修改别人脚本的机会。

5. colored-man-pages

给你带颜色的 man 命令。

6. sudo

apt 忘了加 sudo?开启这个插件,双击 Esc,zsh 会把上一条命令加上 sudo 给你。

一般人会在 zsh 中绑定 history-search-backward 与 histor-search-forward 两个功能。

1
2
bindkey '^P' history-search-backward
bindkey '^N' history-search-forward

这样子,就可以在输入一个命令,比如 git 之后,按 Ctrl-P 与 Ctrl-N 在以 git为前缀的历史记录中浏览,非常方便。

但是这个做法有一个问题,就是这个功能只考虑输入的第一个单词。也就是说,如果之前输入了 git status, git commit, git push 等等命令,那么我输入 “git s” 再 Ctrl-P,并不会锁定到 “git status”, 而是会在所有以 git 开头的历史命令中循环。

这个插件的功能就是实现了一对更好用的 history-search-backward 与 histor-search-forward ,解决了上面所说的问题。开启之后,需要绑定按键:

1
2
bindkey '^P' history-substring-search-up
bindkey '^N' history-substring-search-down

这样子就可以以自己输入的所有内容为前缀,进行历史查找了。


然后下面是需要单独安装的:

1. zplug

zsh 的插件管理器,类似 vim 的 vundle,把你需要的所有插件写到 zshrc 里,然后运行 zplug install 就可以安装这些插件。就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if [[ -f ~/.zplug/init.zsh ]] {
source ~/.zplug/init.zsh

zplug "zsh-users/zsh-syntax-highlighting"
zplug "zsh-users/zsh-autosuggestions"
zplug "supercrabtree/k"
zplug "denisidoro/navi"
zplug "MichaelAquilina/zsh-you-should-use"
zplug "changyuheng/zsh-interactive-cd"
zplug "SleepyBag/zsh-confer"

zplug "Powerlevel9k/powerlevel9k", from:github, as:theme, if:"[[ $ZSH_THEME_STYLE == 9k ]]"
zplug "denysdovhan/spaceship-prompt", use:spaceship.zsh-theme, from:github, as:theme, if:"[[ $ZSH_THEME_STYLE == spaceship ]]"
zplug "caiogondim/bullet-train.zsh", use:bullet-train.zsh-theme, from:github, as:theme, if:"[[ $ZSH_THEME_STYLE == bullet ]]"
zplug "skylerlee/zeta-zsh-theme", from:github, as:theme, if:"[[ $ZSH_THEME_STYLE == zeta ]]"

if ! zplug check --verbose; then
echo 'Run "zplug install" to install'
fi
# Then, source plugins and add commands to $PATH
zplug load
}

这个工具不仅可以用来装 zsh 插件,事实上它可以用来自动安装任何你认为有必要的插件、主题、脚本甚至二进制程序。但是对于非 zsh 插件的程序,在安装之前要先看看 zplug 的文档,搞清楚如何安装。

2. zsh-syntax-highlighting

shell 命令的代码高亮。你没有理由拒绝高亮。

3. zsh-autosuggestions

在输入命令的过程中根据你的历史记录显示你可能想要输入的命令,按 tab 补全。

不过 tab 键似乎与 zsh 的补全有冲突,所以我改成了 ctrl-y 直接运行命令,关于如何修改快捷键,项目主页上也有写。

l

在校外时利用Easy Connect连接西工大校园内网(FTP、内网资源)简易教程

1.使用场景

在校外或者电脑未连接校园网的情况下,想访问内网信息。

2.所需工具

能联网的电脑

3.使用步骤

  1. 浏览器中输入https://vpn.nwpu.edu.cn,跳转至以下页面,点击“下载客户端”。

  1. 客户端安装完成后,桌面上会创建如下快捷方式,双击即可。

得到以下页面

  1. 输入图片中的网址,西工大的为https://vpn.nwpu.edu.cnµ,其他高校应该在自己学校官网可以查到对应的vpn地址。点击。跳转至以下页面:

  2. 输入用户名及密码,点击登录即可。(西工大的用户名密码即为登录翱翔门户的用户名密码,其他高校可到自家官网查询要求)登陆成功,右下角会显示登陆成功的提示信息。

  3. 获取资源
    3.5.1双击桌面Easy Connect 快捷方式即可得到校内资源的访问。

  4. FTP资源
    和在校内一样,在我的电脑的地址栏里输入对应得ftp地址,就可以成功跳转。

l

glance内存分析工具使用

glances 是一款用于 Linux、BSD 的开源命令行系统监视工具,它使用 Python 语言开发,能够监视 CPU、负载、内存、磁盘 I/O、网络流量、文件系统、系统温度等信息。本文介绍 glances 的使用方法和技巧,帮助 Linux 系统管理员了解掌握服务器性能。

前言

glances 可以为 Unix 和 Linux 性能专家提供监视和分析性能数据的功能,其中包括:

  • CPU 使用率
  • 内存使用情况
  • 内核统计信息和运行队列信息
  • 磁盘 I/O 速度、传输和读/写比率
  • 文件系统中的可用空间
  • 磁盘适配器
  • 网络 I/O 速度、传输和读/写比率
  • 页面空间和页面速度
  • 消耗资源最多的进程
  • 计算机信息和系统资源

glances 工具可以在用户的终端上实时显示重要的系统信息,并动态地对其进行更新。这个高效的工具可以工作于任何终端屏幕。另外它并不会消耗大量的 CPU 资源,通常低于百分之二。glances 在屏幕上对数据进行显示,并且每隔两秒钟对其进行更新。您也可以自己将这个时间间隔更改为更长或更短的数值。glances 工具还可以将相同的数据捕获到一个文件,便于以后对报告进行分析和绘制图形。输出文件可以是电子表格的格式 (.csv) 或者 html 格式。

两种方法安装 glances

通 常可以有两种方法安装 glances。第一种是通过编译源代码的方式,这种方法比较复杂另外可能会遇到软件包依赖性问题。还有一种是使用特定的软件包管理工具来安装 glances,这种方法比较简单。本文使用后者,需要说明的是在 CentOS 特定的软件包管理工具来安装。glances 要首先配置 EPEL repo,然后使用 pip 工具安装 glances。

pip 软件包简介

通 常 Linux 系统管理员有两种方式来安装一个 Python 的软件包:一种是通过系统的包管理工具(如 apt-get)从系统的软件仓库里安装,一种是通过 Python 自己的包管理工具(如 easy_install 或者 pip)从 Python Cheese Shop 中下载安装。笔者推荐使用 pip。pip 是一个可以代替 easy_install 的安装和管理 Python 软件包的工具,是一个安装 Python 库很方便的工具,功能类似 YUM。注意 CentOS 和 Fedora 下安装 Python-pip 后,关键字不是 pip 而是 pip-Python。

首先配置 EPEL repo

如 果既想获得 RHEL 的高质量、高性能、高可靠性,又需要方便易用(关键是免费)的软件包更新功能,那么 Fedora Project 推出的 EPEL(Extra Packages for Enterprise Linux ,http://fedoraproject.org/wiki/EPEL)正好适合你。它是由 Fedora 社区打造,为 RHEL 及衍生发行版如 CentOS、Scientific Linux 等提供高质量软件包的项目。装上了 EPEL,就像在 Fedora 上一样,可以通过 yum install package-name,随意安装软件。安装使用 EPEL 非常简单:

1
2
3
4
5
6
7
8
9
10
#wget http://ftp.riken.jp/Linux/fedora/epel/RPM-GPG-KEY-EPEL-6
#rpm --import RPM-GPG-KEY-EPEL-6
#rm -f RPM-GPG-KEY-EPEL-6
#vi /etc/yum.repos.d/epel.repo
# create new
[epel]
name=EPEL RPM Repository for Red Hat Enterprise Linux
baseurl=http://ftp.riken.jp/Linux/fedora/epel/6/$basearch/
gpgcheck=1
enabled=0

使用 pip 安装 glances

这里介绍一下安装过程:首先使用 YUM 安装 pip 工具,然后使用 pip 工具安装 glances 和用来显示系统温度的相关软件。

1
2
#yum --enablerepo=epel install Python Python-pip Python-devel gcc
# pip-Python install glances

安装 lm_sensors 软件

lm_sensors 的软件可以帮助我们来监控主板、CPU 的工作电压、风扇转速、温度等数据。这些数据我们通常在主板的 BIOS 也可以看到。当我们可以在机器运行的时候通过 lm_sensors 随时来监测着 CPU 的温度变化,可以预防呵保护因为 CPU 过热而会烧掉。lm_sensors 软件监测到的数据可以被 glances 调用并且显示 。

1
2
#yum install lm_sensor
# pip-Python install PySensors

glances 使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
glances 是一个命令行工具包括如下命令选项:
-b:显示网络连接速度 Byte/ 秒
-B @IP|host :绑定服务器端 IP 地址或者主机名称
-c @IP|host:连接 glances 服务器端
-C file:设置配置文件默认是 /etc/glances/glances.conf
-d:关闭磁盘 I/O 模块
-e:显示传感器温度
-f file:设置输出文件(格式是 HTML 或者 CSV)
-m:关闭挂载的磁盘模块
-n:关闭网络模块
-p PORT:设置运行端口默认是 61209
-P password:设置客户端 / 服务器密码
-s:设置 glances 运行模式为服务器
-t sec:设置屏幕刷新的时间间隔,单位为秒,默认值为 2 秒,数值许可范围:1~32767
-h : 显示帮助信息
-v : 显示版本信息

glances 工作界面如图 1
图 1.glances 工作界面

glances

glances 工作界面的说明 :

在图 1 的上部是 CPU 、Load(负载)、Mem(内存使用)、 Swap(交换分区)的使用情况。在图 1 的中上部是网络接口、Processes(进程)的使用情况。通常包括如下字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
VIRT: 虚拟内存大小
RES: 进程占用的物理内存值
%CPU:该进程占用的 CPU 使用率

%MEM:该进程占用的物理内存和总内存的百分比

PID: 进程 ID 号
USER: 进程所有者的用户名
TIME+: 该进程启动后占用的总的 CPU 时间
IO_R 和 IO_W: 进程的读写 I/O 速率
NAME: 进程名称
NI: 进程优先级
S: 进程状态,其中 S 表示休眠,R 表示正在运行,Z 表示僵死状态。

在图 1 的中下部是传感器检测到的 CPU 温度。 在图 1 的下部是磁盘 I/O 的使用情况。 另外 glances 可以使用交互式的方式运行该工具,用户可以使用如下快捷键:

1
2
3
4
5
6
7
8
9
10
11
12
h : 显示帮助信息
q : 离开程序退出
c :按照 CPU 实时负载对系统进程进行排序
m :按照内存使用状况对系统进程排序
i:按照 I/O 使用状况对系统进程排序
p: 按照进程名称排序
d : 显示磁盘读写状况
w : 删除日志文件
l :显示日志
s: 显示传感器信息
f : 显示系统信息
1 :轮流显示每个 CPU 内核的使用情况(次选项仅仅使用在多核 CPU 系统)

glances 的高级应用

glances 的结果输出方法

让 glances 输出 HTML 格式文件,首先安装相关软件包

1
2
# pip-Python install Jinja2
# glances -o HTML -f /var/www/html

下面可以使用 Firefox 浏览器输入网址: http://localhost/glances.html,结果如图 2。
图 2.输出 HTML 格式文件

[glances

输出 csv 格式

该文件采用逗号分隔值(CSV)的格式,并且可以将其直接导入到电子表格中。

1
# glances -o CSV -f /home/cjh/glances.csv

下面使用 libreoffice 的 calc 工具打开 csv 格式文件(如图 3)

1
#libreoffice --calc %U /tmp/glances.csv

图 3.使用 libreoffice 的 calc 工具打开 csv 格式文件

glances

glances 服务器 / 客户端工作方式

glances 支持服务器/客户端工作方式,可以实现远程监控。首先假设

服务器 IP 地址:10.0.2.14

客户端 IP 地址:10.0.2.15

确保二者都已经安装好 glances 软件包。

首先在服务器端启动;

1
2
# glances -s -B 10.0.2.15
glances server is running on 10.0.2.15:61209

可以看到 glances 使用的端口号是 61209,所以用户需要确保防火墙打开这个端口。

下面在客户端使用如下命令连接服务器如图 4:

1
# glances -c 10.0.2.15

图 4.客户端连接服务器

[glances

注意图 4 的左下角显示“Connected to 10.0.2.15”>表示客户端已经连接服务器成功。

通过 glances 输出颜色了解系统性能
图 5.是 glances 的一个输出界面

glances

绿色表示性能良好,无需做任何额外工作;(此时 CPU 使用率、磁盘空间使用率和内存使用率低于 50%,系统负载低于 0.7)。

蓝色表示系统性能有一些小问题,用户应当开始关注系统性能;(此时 CPU 使用率、磁盘空间使用率和内存使用率在 50%-70% 之间,系统负载在 0.7-1 之间)。

品红表示性能报警,应当采取措施比如备份数据;(此时 CPU 使用率、磁盘空间使用率和内存使用率在 70%-90% 之间,,系统负载在 1-5 之间)。

红色表示性能问题严重,可能宕机;(此时 CPU 使用率、磁盘空间使用率和内存使用率在大于 90%,系统负载大于 5)。

l

命令行的艺术

Join the chat at https://gitter.im/jlevy/the-art-of-command-line

熟练使用命令行是一种常常被忽视,或被认为难以掌握的技能,但实际上,它会提高你作为工程师的灵活性以及生产力。本文是一份我在 Linux 上工作时,发现的一些命令行使用技巧的摘要。有些技巧非常基础,而另一些则相当复杂,甚至晦涩难懂。这篇文章并不长,但当你能够熟练掌握这里列出的所有技巧时,你就学会了很多关于命令行的东西了。

这篇文章是许多作者和译者共同的成果。这里的部分内容首次出现Quora,但已经迁移到了 Github,并由众多高手做出了许多改进。如果你在本文中发现了错误或者存在可以改善的地方,请贡献你的一份力量

前言

涵盖范围:

  • 这篇文章不仅能帮助刚接触命令行的新手,而且对具有经验的人也大有裨益。本文致力于做到覆盖面广(涉及所有重要的内容),具体(给出具体的最常用的例子),以及简洁(避免冗余的内容,或是可以在其他地方轻松查到的细枝末节)。在特定应用场景下,本文的内容属于基本功或者能帮助您节约大量的时间。
  • 本文主要为 Linux 所写,但在仅限 OS X 系统章节和仅限 Windows 系统章节中也包含有对应操作系统的内容。除去这两个章节外,其它的内容大部分均可在其他类 Unix 系统或 OS X,甚至 Cygwin 中得到应用。
  • 本文主要关注于交互式 Bash,但也有很多技巧可以应用于其他 shell 和 Bash 脚本当中。
  • 除去“标准的”Unix 命令,本文还包括了一些依赖于特定软件包的命令(前提是它们具有足够的价值)。

注意事项:

  • 为了能在一页内展示尽量多的东西,一些具体的信息可以在引用的页面中找到。我们相信机智的你知道如何使用 Google 或者其他搜索引擎来查阅到更多的详细信息。文中部分命令需要您使用 apt-getyumdnfpacmanpipbrew(以及其它合适的包管理器)来安装依赖的程序。
  • 遇到问题的话,请尝试使用 Explainshell 去获取相关命令、参数、管道等内容的解释。

基础

  • 学习 Bash 的基础知识。具体地,在命令行中输入 man bash 并至少全文浏览一遍; 它理解起来很简单并且不冗长。其他的 shell 可能很好用,但 Bash 的功能已经足够强大并且到几乎总是可用的( 如果你学习 zsh,fish 或其他的 shell 的话,在你自己的设备上会显得很方便,但过度依赖这些功能会给您带来不便,例如当你需要在服务器上工作时)。

  • 熟悉至少一个基于文本的编辑器。通常而言 Vim (vi) 会是你最好的选择,毕竟在终端中编辑文本时 Vim 是最好用的工具(甚至大部分情况下 Vim 要比 Emacs、大型 IDE 或是炫酷的编辑器更好用)。

  • 学会如何使用 man 命令去阅读文档。学会使用 apropos 去查找文档。知道有些命令并不对应可执行文件,而是在 Bash 内置好的,此时可以使用 helphelp -d 命令获取帮助信息。你可以用 type 命令 来判断这个命令到底是可执行文件、shell 内置命令还是别名。

  • 学会使用 >< 来重定向输出和输入,学会使用 | 来重定向管道。明白 > 会覆盖了输出文件而 >> 是在文件末添加。了解标准输出 stdout 和标准错误 stderr。

  • 学会使用通配符 * (或许再算上 ?[]) 和引用以及引用中 '" 的区别(后文中有一些具体的例子)。

  • 熟悉 Bash 中的任务管理工具:&ctrl-zctrl-cjobsfgbgkill 等。

  • 学会使用 ssh 进行远程命令行登录,最好知道如何使用 ssh-agentssh-add 等命令来实现基础的无密码认证登录。

  • 学会基本的文件管理工具:lsls -l (了解 ls -l 中每一列代表的意义),lessheadtailtail -f (甚至 less +F),lnln -s (了解硬链接与软链接的区别),chownchmoddu (硬盘使用情况概述:du -hs *)。 关于文件系统的管理,学习 dfmountfdiskmkfslsblk。知道 inode 是什么(与 ls -idf -i 等命令相关)。

  • 学习基本的网络管理工具:ipifconfigdig

  • 学习并使用一种版本控制管理系统,例如 git

  • 熟悉正则表达式,学会使用 grepegrep,它们的参数中 -i-o-v-A-B-C 这些是很常用并值得认真学习的。

  • 学会使用 apt-getyumdnfpacman (具体使用哪个取决于你使用的 Linux 发行版)来查找和安装软件包。并确保你的环境中有 pip 来安装基于 Python 的命令行工具 (接下来提到的部分程序使用 pip 来安装会很方便)。

日常使用

  • 在 Bash 中,可以通过按 Tab 键实现自动补全参数,使用 ctrl-r 搜索命令行历史记录(按下按键之后,输入关键字便可以搜索,重复按下 ctrl-r 会向后查找匹配项,按下 Enter 键会执行当前匹配的命令,而按下右方向键会将匹配项放入当前行中,不会直接执行,以便做出修改)。

  • 在 Bash 中,可以按下 ctrl-w 删除你键入的最后一个单词,ctrl-u 可以删除行内光标所在位置之前的内容,alt-balt-f 可以以单词为单位移动光标,ctrl-a 可以将光标移至行首,ctrl-e 可以将光标移至行尾,ctrl-k 可以删除光标至行尾的所有内容,ctrl-l 可以清屏。键入 man readline 可以查看 Bash 中的默认快捷键。内容有很多,例如 alt-. 循环地移向前一个参数,而 alt-* 可以展开通配符。

  • 你喜欢的话,可以执行 set -o vi 来使用 vi 风格的快捷键,而执行 set -o emacs 可以把它改回来。

  • 为了便于编辑长命令,在设置你的默认编辑器后(例如 export EDITOR=vim),ctrl-x ctrl-e 会打开一个编辑器来编辑当前输入的命令。在 vi 风格下快捷键则是 escape-v

  • 键入 history 查看命令行历史记录,再用 !nn 是命令编号)就可以再次执行。其中有许多缩写,最有用的大概就是 !$, 它用于指代上次键入的参数,而 !! 可以指代上次键入的命令了(参考 man 页面中的“HISTORY EXPANSION”)。不过这些功能,你也可以通过快捷键 ctrl-ralt-. 来实现。

  • cd 命令可以切换工作路径,输入 cd ~ 可以进入 home 目录。要访问你的 home 目录中的文件,可以使用前缀 ~(例如 ~/.bashrc)。在 sh 脚本里则用环境变量 $HOME 指代 home 目录的路径。

  • 回到前一个工作路径:cd -

  • 如果你输入命令的时候中途改了主意,按下 alt-# 在行首添加 # 把它当做注释再按下回车执行(或者依次按下 ctrl-a, **#**, enter)。这样做的话,之后借助命令行历史记录,你可以很方便恢复你刚才输入到一半的命令。

  • 使用 xargs ( 或 parallel)。他们非常给力。注意到你可以控制每行参数个数(-L)和最大并行数(-P)。如果你不确定它们是否会按你想的那样工作,先使用 xargs echo 查看一下。此外,使用 -I{} 会很方便。例如:

    1
    2
    find . -name '*.py' | xargs grep some_function
    cat hosts | xargs -I{} ssh root@{} hostname
  • pstree -p 以一种优雅的方式展示进程树。

  • 使用 pgreppkill 根据名字查找进程或发送信号(-f 参数通常有用)。

  • 了解你可以发往进程的信号的种类。比如,使用 kill -STOP [pid] 停止一个进程。使用 man 7 signal 查看详细列表。

  • 使用 nohupdisown 使一个后台进程持续运行。

  • 使用 netstat -lntpss -plat 检查哪些进程在监听端口(默认是检查 TCP 端口; 添加参数 -u 则检查 UDP 端口)或者 lsof -iTCP -sTCP:LISTEN -P -n (这也可以在 OS X 上运行)。

  • lsof 来查看开启的套接字和文件。

  • 使用 uptimew 来查看系统已经运行多长时间。

  • 使用 alias 来创建常用命令的快捷形式。例如:alias ll='ls -latr' 创建了一个新的命令别名 ll

  • 可以把别名、shell 选项和常用函数保存在 ~/.bashrc,具体看下这篇文章。这样做的话你就可以在所有 shell 会话中使用你的设定。

  • 把环境变量的设定以及登陆时要执行的命令保存在 ~/.bash_profile。而对于从图形界面启动的 shell 和 cron 启动的 shell,则需要单独配置文件。

  • 要想在几台电脑中同步你的配置文件(例如 .bashrc.bash_profile),可以借助 Git。

  • 当变量和文件名中包含空格的时候要格外小心。Bash 变量要用引号括起来,比如 "$FOO"。尽量使用 -0-print0 选项以便用 NULL 来分隔文件名,例如 locate -0 pattern | xargs -0 ls -alfind / -print0 -type d | xargs -0 ls -al。如果 for 循环中循环访问的文件名含有空字符(空格、tab 等字符),只需用 IFS=$'\n' 把内部字段分隔符设为换行符。

  • 在 Bash 脚本中,使用 set -x 去调试输出(或者使用它的变体 set -v,它会记录原始输入,包括多余的参数和注释)。尽可能地使用严格模式:使用 set -e 令脚本在发生错误时退出而不是继续运行;使用 set -u 来检查是否使用了未赋值的变量;试试 set -o pipefail,它可以监测管道中的错误。当牵扯到很多脚本时,使用 trap 来检测 ERR 和 EXIT。一个好的习惯是在脚本文件开头这样写,这会使它能够检测一些错误,并在错误发生时中断程序并输出信息:

    1
    2
    set -euo pipefail
    trap "echo 'error: Script failed: see failed command above'" ERR
  • 在 Bash 脚本中,子 shell(使用括号 (...))是一种组织参数的便捷方式。一个常见的例子是临时地移动工作路径,代码如下:

    1
    2
    3
    # do something in current dir
    (cd /some/other/dir && other-command)
    # continue in original dir
  • 在 Bash 中,变量有许多的扩展方式。${name:?error message} 用于检查变量是否存在。此外,当 Bash 脚本只需要一个参数时,可以使用这样的代码 input_file=${1:?usage: $0 input_file}。在变量为空时使用默认值:${name:-default}。如果你要在之前的例子中再加一个(可选的)参数,可以使用类似这样的代码 output_file=${2:-logfile},如果省略了 $2,它的值就为空,于是 output_file 就会被设为 logfile。数学表达式:i=$(( (i + 1) % 5 ))。序列:{1..10}。截断字符串:${var%suffix}${var#prefix}。例如,假设 var=foo.pdf,那么 echo ${var%.pdf}.txt 将输出 foo.txt

  • 使用括号扩展({})来减少输入相似文本,并自动化文本组合。这在某些情况下会很有用,例如 mv foo.{txt,pdf} some-dir(同时移动两个文件),cp somefile{,.bak}(会被扩展成 cp somefile somefile.bak)或者 mkdir -p test-{a,b,c}/subtest-{1,2,3}(会被扩展成所有可能的组合,并创建一个目录树)。

  • 通过使用 <(some command) 可以将输出视为文件。例如,对比本地文件 /etc/hosts 和一个远程文件:

    1
    diff /etc/hosts <(ssh somehost cat /etc/hosts)
  • 编写脚本时,你可能会想要把代码都放在大括号里。缺少右括号的话,代码就会因为语法错误而无法执行。如果你的脚本是要放在网上分享供他人使用的,这样的写法就体现出它的好处了,因为这样可以防止下载不完全代码被执行。

    1
    2
    3
    {
    # 在这里写代码
    }
  • 了解 Bash 中的“here documents”,例如 cat <<EOF ...

  • 在 Bash 中,同时重定向标准输出和标准错误:some-command >logfile 2>&1 或者 some-command &>logfile。通常,为了保证命令不会在标准输入里残留一个未关闭的文件句柄捆绑在你当前所在的终端上,在命令后添加 </dev/null 是一个好习惯。

  • 使用 man ascii 查看具有十六进制和十进制值的ASCII表。man unicodeman utf-8,以及 man latin1 有助于你去了解通用的编码信息。

  • 使用 screentmux 来使用多份屏幕,当你在使用 ssh 时(保存 session 信息)将尤为有用。而 byobu 可以为它们提供更多的信息和易用的管理工具。另一个轻量级的 session 持久化解决方案是 dtach

  • ssh 中,了解如何使用 -L-D(偶尔需要用 -R)开启隧道是非常有用的,比如当你需要从一台远程服务器上访问 web 页面。

  • 对 ssh 设置做一些小优化可能是很有用的,例如这个 ~/.ssh/config 文件包含了防止特定网络环境下连接断开、压缩数据、多通道等选项:

    1
    2
    3
    4
    5
    6
    7
    TCPKeepAlive=yes
    ServerAliveInterval=15
    ServerAliveCountMax=6
    Compression=yes
    ControlMaster auto
    ControlPath /tmp/%r@%h:%p
    ControlPersist yes
  • 一些其他的关于 ssh 的选项是与安全相关的,应当小心翼翼的使用。例如你应当只能在可信任的网络中启用 StrictHostKeyChecking=noForwardAgent=yes

  • 考虑使用 mosh 作为 ssh 的替代品,它使用 UDP 协议。它可以避免连接被中断并且对带宽需求更小,但它需要在服务端做相应的配置。

  • 获取八进制形式的文件访问权限(修改系统设置时通常需要,但 ls 的功能不那么好用并且通常会搞砸),可以使用类似如下的代码:

    1
    stat -c '%A %a %n' /etc/timezone
  • 使用 percol 或者 fzf 可以交互式地从另一个命令输出中选取值。

  • 使用 fppPathPicker)可以与基于另一个命令(例如 git)输出的文件交互。

  • 将 web 服务器上当前目录下所有的文件(以及子目录)暴露给你所处网络的所有用户,使用:
    python -m SimpleHTTPServer 7777 (使用端口 7777 和 Python 2)或python -m http.server 7777 (使用端口 7777 和 Python 3)。

  • 以其他用户的身份执行命令,使用 sudo。默认以 root 用户的身份执行;使用 -u 来指定其他用户。使用 -i 来以该用户登录(需要输入_你自己的_密码)。

  • 将 shell 切换为其他用户,使用 su username 或者 sudo - username。加入 - 会使得切换后的环境与使用该用户登录后的环境相同。省略用户名则默认为 root。切换到哪个用户,就需要输入_哪个用户的_密码。

  • 了解命令行的 128K 限制。使用通配符匹配大量文件名时,常会遇到“Argument list too long”的错误信息。(这种情况下换用 findxargs 通常可以解决。)

  • 当你需要一个基本的计算器时,可以使用 python 解释器(当然你要用 python 的时候也是这样)。例如:

    1
    2
    >>> 2+3
    5

文件及数据处理

  • 在当前目录下通过文件名查找一个文件,使用类似于这样的命令:find . -iname '*something*'。在所有路径下通过文件名查找文件,使用 locate something (但注意到 updatedb 可能没有对最近新建的文件建立索引,所以你可能无法定位到这些未被索引的文件)。

  • 使用 ag 在源代码或数据文件里检索(grep -r 同样可以做到,但相比之下 ag 更加先进)。

  • 将 HTML 转为文本:lynx -dump -stdin

  • Markdown,HTML,以及所有文档格式之间的转换,试试 pandoc

  • 当你要处理棘手的 XML 时候,xmlstarlet 算是上古时代流传下来的神器。

  • 使用 jq 处理 JSON。

  • 使用 shyaml 处理 YAML。

  • 要处理 Excel 或 CSV 文件的话,csvkit 提供了 in2csvcsvcutcsvjoincsvgrep 等方便易用的工具。

  • 当你要处理 Amazon S3 相关的工作的时候,s3cmd 是一个很方便的工具而 s4cmd 的效率更高。Amazon 官方提供的 aws 以及 saws 是其他 AWS 相关工作的基础,值得学习。

  • 了解如何使用 sortuniq,包括 uniq 的 -u 参数和 -d 参数,具体内容在后文单行脚本节中。另外可以了解一下 comm

  • 了解如何使用 cutpastejoin 来更改文件。很多人都会使用 cut,但遗忘了 join

  • 了解如何运用 wc 去计算新行数(-l),字符数(-m),单词数(-w)以及字节数(-c)。

  • 了解如何使用 tee 将标准输入复制到文件甚至标准输出,例如 ls -al | tee file.txt

  • 要进行一些复杂的计算,比如分组、逆序和一些其他的统计分析,可以考虑使用 datamash

  • 注意到语言设置(中文或英文等)对许多命令行工具有一些微妙的影响,比如排序的顺序和性能。大多数 Linux 的安装过程会将 LANG 或其他有关的变量设置为符合本地的设置。要意识到当你改变语言设置时,排序的结果可能会改变。明白国际化可能会使 sort 或其他命令运行效率下降许多倍。某些情况下(例如集合运算)你可以放心的使用 export LC_ALL=C 来忽略掉国际化并按照字节来判断顺序。

  • 你可以单独指定某一条命令的环境,只需在调用时把环境变量设定放在命令的前面,例如 TZ=Pacific/Fiji date 可以获取斐济的时间。

  • 了解如何使用 awksed 来进行简单的数据处理。 参阅 One-liners 获取示例。

  • 替换一个或多个文件中出现的字符串:

    1
    perl -pi.bak -e 's/old-string/new-string/g' my-files-*.txt
  • 使用 repren 来批量重命名文件,或是在多个文件中搜索替换内容。(有些时候 rename 命令也可以批量重命名,但要注意,它在不同 Linux 发行版中的功能并不完全一样。)

    1
    2
    3
    4
    5
    6
    # 将文件、目录和内容全部重命名 foo -> bar:
    repren --full --preserve-case --from foo --to bar .
    # 还原所有备份文件 whatever.bak -> whatever:
    repren --renames --from '(.*)\.bak' --to '\1' *.bak
    # 用 rename 实现上述功能(若可用):
    rename 's/\.bak$//' *.bak
  • 根据 man 页面的描述,rsync 是一个快速且非常灵活的文件复制工具。它闻名于设备之间的文件同步,但其实它在本地情况下也同样有用。在安全设置允许下,用 rsync 代替 scp 可以实现文件续传,而不用重新从头开始。它同时也是删除大量文件的最快方法之一:

    1
    mkdir empty && rsync -r --delete empty/ some-dir && rmdir some-dir
  • 若要在复制文件时获取当前进度,可使用 pvpycpprogressrsync --progress。若所执行的复制为block块拷贝,可以使用 dd status=progress

  • 使用 shuf 可以以行为单位来打乱文件的内容或从一个文件中随机选取多行。

  • 了解 sort 的参数。显示数字时,使用 -n 或者 -h 来显示更易读的数(例如 du -h 的输出)。明白排序时关键字的工作原理(-t-k)。例如,注意到你需要 -k1,1 来仅按第一个域来排序,而 -k1 意味着按整行排序。稳定排序(sort -s)在某些情况下很有用。例如,以第二个域为主关键字,第一个域为次关键字进行排序,你可以使用 sort -k1,1 | sort -s -k2,2

  • 如果你想在 Bash 命令行中写 tab 制表符,按下 ctrl-v [Tab] 或键入 $'\t' (后者可能更好,因为你可以复制粘贴它)。

  • 标准的源代码对比及合并工具是 diffpatch。使用 diffstat 查看变更总览数据。注意到 diff -r 对整个文件夹有效。使用 diff -r tree1 tree2 | diffstat 查看变更的统计数据。vimdiff 用于比对并编辑文件。

  • 对于二进制文件,使用 hdhexdump 或者 xxd 使其以十六进制显示,使用 bvihexedit 或者 biew 来进行二进制编辑。

  • 同样对于二进制文件,strings(包括 grep 等工具)可以帮助在二进制文件中查找特定比特。

  • 制作二进制差分文件(Delta 压缩),使用 xdelta3

  • 使用 iconv 更改文本编码。需要更高级的功能,可以使用 uconv,它支持一些高级的 Unicode 功能。例如,这条命令移除了所有重音符号:

    1
    uconv -f utf-8 -t utf-8 -x '::Any-Lower; ::Any-NFD; [:Nonspacing Mark:] >; ::Any-NFC; ' < input.txt > output.txt
  • 拆分文件可以使用 split(按大小拆分)和 csplit(按模式拆分)。

  • 操作日期和时间表达式,可以用 dateutils 中的 dateadddatediffstrptime 等工具。

  • 使用 zlesszmorezcatzgrep 对压缩过的文件进行操作。

  • 文件属性可以通过 chattr 进行设置,它比文件权限更加底层。例如,为了保护文件不被意外删除,可以使用不可修改标记:sudo chattr +i /critical/directory/or/file

  • 使用 getfaclsetfacl 以保存和恢复文件权限。例如:

    1
    2
    getfacl -R /some/path > permissions.txt
    setfacl --restore=permissions.txt
  • 为了高效地创建空文件,请使用 truncate(创建稀疏文件),fallocate(用于 ext4,xfs,btrf 和 ocfs2 文件系统),xfs_mkfile(适用于几乎所有的文件系统,包含在 xfsprogs 包中),mkfile(用于类 Unix 操作系统,比如 Solaris 和 Mac OS)。

系统调试

  • curlcurl -I 可以被轻松地应用于 web 调试中,它们的好兄弟 wget 也是如此,或者也可以试试更潮的 httpie

  • 获取 CPU 和硬盘的使用状态,通常使用使用 tophtop 更佳),iostatiotop。而 iostat -mxz 15 可以让你获悉 CPU 和每个硬盘分区的基本信息和性能表现。

  • 使用 netstatss 查看网络连接的细节。

  • dstat 在你想要对系统的现状有一个粗略的认识时是非常有用的。然而若要对系统有一个深度的总体认识,使用 glances,它会在一个终端窗口中向你提供一些系统级的数据。

  • 若要了解内存状态,运行并理解 freevmstat 的输出。值得留意的是“cached”的值,它指的是 Linux 内核用来作为文件缓存的内存大小,而与空闲内存无关。

  • Java 系统调试则是一件截然不同的事,一个可以用于 Oracle 的 JVM 或其他 JVM 上的调试的技巧是你可以运行 kill -3 <pid> 同时一个完整的栈轨迹和堆概述(包括 GC 的细节)会被保存到标准错误或是日志文件。JDK 中的 jpsjstatjstackjmap 很有用。SJK tools 更高级。

  • 使用 mtr 去跟踪路由,用于确定网络问题。

  • ncdu 来查看磁盘使用情况,它比寻常的命令,如 du -sh *,更节省时间。

  • 查找正在使用带宽的套接字连接或进程,使用 iftopnethogs

  • ab 工具(Apache 中自带)可以简单粗暴地检查 web 服务器的性能。对于更复杂的负载测试,使用 siege

  • wiresharktsharkngrep 可用于复杂的网络调试。

  • 了解 straceltrace。这俩工具在你的程序运行失败、挂起甚至崩溃,而你却不知道为什么或你想对性能有个总体的认识的时候是非常有用的。注意 profile 参数(-c)和附加到一个运行的进程参数 (-p)。

  • 了解使用 ldd 来检查共享库。但是永远不要在不信任的文件上运行

  • 了解如何运用 gdb 连接到一个运行着的进程并获取它的堆栈轨迹。

  • 学会使用 /proc。它在调试正在出现的问题的时候有时会效果惊人。比如:/proc/cpuinfo/proc/meminfo/proc/cmdline/proc/xxx/cwd/proc/xxx/exe/proc/xxx/fd//proc/xxx/smaps(这里的 xxx 表示进程的 id 或 pid)。

  • 当调试一些之前出现的问题的时候,sar 非常有用。它展示了 cpu、内存以及网络等的历史数据。

  • 关于更深层次的系统分析以及性能分析,看看 stapSystemTap),perf,以及sysdig

  • 查看你当前使用的系统,使用 unameuname -a(Unix/kernel 信息)或者 lsb_release -a(Linux 发行版信息)。

  • 无论什么东西工作得很欢乐(可能是硬件或驱动问题)时可以试试 dmesg

  • 如果你删除了一个文件,但通过 du 发现没有释放预期的磁盘空间,请检查文件是否被进程占用:
    lsof | grep deleted | grep "filename-of-my-big-file"

单行脚本

一些命令组合的例子:

  • 当你需要对文本文件做集合交、并、差运算时,sortuniq 会是你的好帮手。具体例子请参照代码后面的,此处假设 ab 是两内容不同的文件。这种方式效率很高,并且在小文件和上 G 的文件上都能运用(注意尽管在 /tmp 在一个小的根分区上时你可能需要 -T 参数,但是实际上 sort 并不被内存大小约束),参阅前文中关于 LC_ALLsort-u 参数的部分。

    1
    2
    3
    sort a b | uniq > c   # c 是 a 并 b
    sort a b | uniq -d > c # c 是 a 交 b
    sort a b b | uniq -u > c # c 是 a - b
  • 使用 grep . *(每行都会附上文件名)或者 head -100 *(每个文件有一个标题)来阅读检查目录下所有文件的内容。这在检查一个充满配置文件的目录(如 /sys/proc/etc)时特别好用。

  • 计算文本文件第三列中所有数的和(可能比同等作用的 Python 代码快三倍且代码量少三倍):

    1
    awk '{ x += $3 } END { print x }' myfile
  • 如果你想在文件树上查看大小/日期,这可能看起来像递归版的 ls -l 但比 ls -lR 更易于理解:

    1
    find . -type f -ls
  • 假设你有一个类似于 web 服务器日志文件的文本文件,并且一个确定的值只会出现在某些行上,假设一个 acct_id 参数在 URI 中。如果你想计算出每个 acct_id 值有多少次请求,使用如下代码:

    1
    egrep -o 'acct_id=[0-9]+' access.log | cut -d= -f2 | sort | uniq -c | sort -rn
  • 要持续监测文件改动,可以使用 watch,例如检查某个文件夹中文件的改变,可以用 watch -d -n 2 'ls -rtlh | tail';或者在排查 WiFi 设置故障时要监测网络设置的更改,可以用 watch -d -n 2 ifconfig

  • 运行这个函数从这篇文档中随机获取一条技巧(解析 Markdown 文件并抽取项目):

    1
    2
    3
    4
    5
    6
    7
    8
    function taocl() {
    curl -s https://raw.githubusercontent.com/jlevy/the-art-of-command-line/master/README-zh.md|
    pandoc -f markdown -t html |
    iconv -f 'utf-8' -t 'unicode' |
    xmlstarlet fo --html --dropdtd |
    xmlstarlet sel -t -v "(html/body/ul/li[count(p)>0])[$RANDOM mod last()+1]" |
    xmlstarlet unesc | fmt -80
    }

冷门但有用

  • expr:计算表达式或正则匹配

  • m4:简单的宏处理器

  • yes:多次打印字符串

  • cal:漂亮的日历

  • env:执行一个命令(脚本文件中很有用)

  • printenv:打印环境变量(调试时或在写脚本文件时很有用)

  • look:查找以特定字符串开头的单词或行

  • cutpastejoin:数据修改

  • fmt:格式化文本段落

  • pr:将文本格式化成页/列形式

  • fold:包裹文本中的几行

  • column:将文本格式化成多个对齐、定宽的列或表格

  • expandunexpand:制表符与空格之间转换

  • nl:添加行号

  • seq:打印数字

  • bc:计算器

  • factor:分解因数

  • gpg:加密并签名文件

  • toe:terminfo 入口列表

  • nc:网络调试及数据传输

  • socat:套接字代理,与 netcat 类似

  • slurm:网络流量可视化

  • dd:文件或设备间传输数据

  • file:确定文件类型

  • tree:以树的形式显示路径和文件,类似于递归的 ls

  • stat:文件信息

  • time:执行命令,并计算执行时间

  • timeout:在指定时长范围内执行命令,并在规定时间结束后停止进程

  • lockfile:使文件只能通过 rm -f 移除

  • logrotate: 切换、压缩以及发送日志文件

  • watch:重复运行同一个命令,展示结果并/或高亮有更改的部分

  • when-changed:当检测到文件更改时执行指定命令。参阅 inotifywaitentr

  • tac:反向输出文件

  • shuf:文件中随机选取几行

  • comm:一行一行的比较排序过的文件

  • strings:从二进制文件中抽取文本

  • tr:转换字母

  • iconvuconv:文本编码转换

  • splitcsplit:分割文件

  • sponge:在写入前读取所有输入,在读取文件后再向同一文件写入时比较有用,例如 grep -v something some-file | sponge some-file

  • units:将一种计量单位转换为另一种等效的计量单位(参阅 /usr/share/units/definitions.units

  • apg:随机生成密码

  • xz:高比例的文件压缩

  • ldd:动态库信息

  • nm:提取 obj 文件中的符号

  • abwrk:web 服务器性能分析

  • strace:调试系统调用

  • mtr:更好的网络调试跟踪工具

  • cssh:可视化的并发 shell

  • rsync:通过 ssh 或本地文件系统同步文件和文件夹

  • wiresharktshark:抓包和网络调试工具

  • ngrep:网络层的 grep

  • hostdig:DNS 查找

  • lsof:列出当前系统打开文件的工具以及查看端口信息

  • dstat:系统状态查看

  • glances:高层次的多子系统总览

  • iostat:硬盘使用状态

  • mpstat: CPU 使用状态

  • vmstat: 内存使用状态

  • htop:top 的加强版

  • last:登入记录

  • w:查看处于登录状态的用户

  • id:用户/组 ID 信息

  • sar:系统历史数据

  • iftopnethogs:套接字及进程的网络利用情况

  • ss:套接字数据

  • dmesg:引导及系统错误信息

  • sysctl: 在内核运行时动态地查看和修改内核的运行参数

  • hdparm:SATA/ATA 磁盘更改及性能分析

  • lsblk:列出块设备信息:以树形展示你的磁盘以及磁盘分区信息

  • lshwlscpulspcilsusbdmidecode:查看硬件信息,包括 CPU、BIOS、RAID、显卡、USB设备等

  • lsmodmodinfo:列出内核模块,并显示其细节

  • fortuneddatesl:额,这主要取决于你是否认为蒸汽火车和莫名其妙的名人名言是否“有用”

仅限 OS X 系统

以下是仅限于 OS X 系统的技巧。

  • brew (Homebrew)或者 port (MacPorts)进行包管理。这些可以用来在 OS X 系统上安装以上的大多数命令。

  • pbcopy 复制任何命令的输出到桌面应用,用 pbpaste 粘贴输入。

  • 若要在 OS X 终端中将 Option 键视为 alt 键(例如在上面介绍的 alt-balt-f 等命令中用到),打开 偏好设置 -> 描述文件 -> 键盘 并勾选“使用 Option 键作为 Meta 键”。

  • open 或者 open -a /Applications/Whatever.app 使用桌面应用打开文件。

  • Spotlight:用 mdfind 搜索文件,用 mdls 列出元数据(例如照片的 EXIF 信息)。

  • 注意 OS X 系统是基于 BSD UNIX 的,许多命令(例如 pslstailawksed)都和 Linux 中有微妙的不同( Linux 很大程度上受到了 System V-style Unix 和 GNU 工具影响)。你可以通过标题为 “BSD General Commands Manual” 的 man 页面发现这些不同。在有些情况下 GNU 版本的命令也可能被安装(例如 gawkgsed 对应 GNU 中的 awk 和 sed )。如果要写跨平台的 Bash 脚本,避免使用这些命令(例如,考虑 Python 或者 perl )或者经过仔细的测试。

  • sw_vers 获取 OS X 的版本信息。

仅限 Windows 系统

以下是仅限于 Windows 系统的技巧。

在 Winodws 下获取 Unix 工具

  • 可以安装 Cygwin 允许你在 Microsoft Windows 中体验 Unix shell 的威力。这样的话,本文中介绍的大多数内容都将适用。

  • 在 Windows 10 上,你可以使用 Bash on Ubuntu on Windows,它提供了一个熟悉的 Bash 环境,包含了不少 Unix 命令行工具。好处是它允许 Linux 上编写的程序在 Windows 上运行,而另一方面,Windows 上编写的程序却无法在 Bash 命令行中运行。

  • 如果你在 Windows 上主要想用 GNU 开发者工具(例如 GCC),可以考虑 MinGW 以及它的 MSYS 包,这个包提供了例如 bash,gawk,make 和 grep 的工具。MSYS 并不包含所有可以与 Cygwin 媲美的特性。当制作 Unix 工具的原生 Windows 端口时 MinGW 将特别地有用。

  • 另一个在 Windows 下实现接近 Unix 环境外观效果的选项是 Cash。注意在此环境下只有很少的 Unix 命令和命令行可用。

实用 Windows 命令行工具

  • 可以使用 wmic 在命令行环境下给大部分 Windows 系统管理任务编写脚本以及执行这些任务。

  • Windows 实用的原生命令行网络工具包括 pingipconfigtracert,和 netstat

  • 可以使用 Rundll32 命令来实现许多有用的 Windows 任务

Cygwin 技巧

  • 通过 Cygwin 的包管理器来安装额外的 Unix 程序。

  • 使用 mintty 作为你的命令行窗口。

  • 要访问 Windows 剪贴板,可以通过 /dev/clipboard

  • 运行 cygstart 以通过默认程序打开一个文件。

  • 要访问 Windows 注册表,可以使用 regtool

  • 注意 Windows 驱动器路径 C:\ 在 Cygwin 中用 /cygdrive/c 代表,而 Cygwin 的 / 代表 Windows 中的 C:\cygwin。要转换 Cygwin 和 Windows 风格的路径可以用 cygpath。这在需要调用 Windows 程序的脚本里很有用。

  • 学会使用 wmic,你就可以从命令行执行大多数 Windows 系统管理任务,并编成脚本。

  • 要在 Windows 下获得 Unix 的界面和体验,另一个办法是使用 Cash。需要注意的是,这个环境支持的 Unix 命令和命令行参数非常少。

  • 要在 Windows 上获取 GNU 开发者工具(比如 GCC)的另一个办法是使用 MinGW 以及它的 MSYS 软件包,该软件包提供了 bash、gawk、make、grep 等工具。然而 MSYS 提供的功能没有 Cygwin 完善。MinGW 在创建 Unix 工具的 Windows 原生移植方面非常有用。

更多资源

免责声明

除去特别小的工作,你编写的代码应当方便他人阅读。能力往往伴随着责任,你 有能力 在 Bash 中玩一些奇技淫巧并不意味着你应该去做!;)

授权条款

Creative Commons License

本文使用授权协议 Creative Commons Attribution-ShareAlike 4.0 International License

l

手把手教你黑白群晖NAS安装破解版ROON音乐播放器1.6

什么是Roon?

roon不能说是一个播放软件,它是一个系统,由Roon Server为核心构建的,Roon Server本身不存储任何音乐文件,你也不需要把任何的音乐文件存储到它里面,他可以读取你本地的任何共享文件夹里的音乐文件,然后从他庞大的数据库中,帮你归类音乐文件,只要是信息齐全的,它没什么不认识的。

然后,重点来了。他可以通过IPAD、手机、PC这些设备,来控制你的其他设备播放你本地的别的设备里的音乐,并且可以接入你的家庭智能系统。

然后你可以通过手机APP,平板电脑和普通电脑去管理你的Roon,并且输出到任何接入这个Roon平台的音频设备,也就是说他可以实现局域网内多平台操控和多房间系统播放。

硬件要求:
1:有一台群晖
2:CPU最好是I5或者I7级别,官方这么说的,因为有的计算需要CPU强劲一些,实测呢,蜗牛也能跑,如果你不搞升频什么的。
3:一块SSD,Roon安装在这块SSD里,比较好。速度真的快很多,要不恶心死你。我用的一块240还是256的INTEL的SSD,而且计划这块SSD只跑Roon。
当然,你也可以装在你的机械硬盘里,如果你不太在乎速度,或者你没那么多DSD音乐的话,实测这也不是必须的。

安装准备:
1:群晖开启Root权限(怎么开启我就不介绍了,网上教程太多了)
2:Winscp
3:最好有梯子,否则安装的你想哭

安装1.7版本的Roon官方套件

我们在群晖安装Roon Server时,由于涉及到程序自启动以及环境变量的配置问题,一般都选择运行官方的安装脚本方式进行安装。
roon官方的有关于群晖安装的链接:https://roononnas.org/de/roon-auf-nas/
按照链接下载安装文件:RoonServer_Synology_x86_64_2018-03-07.spk
然后到群晖后台去手动安装插件!

这里会有第一个坑。因为即使本地安装,他也需要连接服务器,不知道为什么,如果你没梯子,速度因地方而异了。我是开了梯子,顺利安装,非常快。这里打开你的梯子,并且让群晖可以走梯子。你有耐心慢慢等也是可以的,有人慢慢等也装上了。

到这里。第一部分结束,安装好了最新的1.7的Roon Server,但是现在老毛子只破解了1.6的,我们现在得替换文件。

版本文件替换

1.6文件下载:

链接: https://pan.baidu.com/s/1NiRZCHdrHXisZyE2wTLKBA 密码: j05p

1:进入套件中心,停用,上面绿色的已启动会变成停用。

2:打开你的群晖的SSH那些,控制面板–终端机和SNMP1,两个打钩。

3:解压缩你刚才下载的RoonServer那个压缩包。

4:运行WINSCP。第一个是SCP模式,然后HOSTNAME填写你的群晖的IP。端口如果你没改的话是22,usernama填root,后面password,就是你的root账号的密码了。

5:连接进去以后,找到你的RoonServer套件所在的位置。
我的是/volume7/@appstore/RoonServer/RoonServer。找到这个目录,然后左边本地找到你刚才解压缩的那个roonserver1.6下面的roonserver文件夹。你会发现左右两边的文件是一样的。这个时候我们覆盖他就好了。
怎么覆盖?左边的一起框柱,然后往右边啦过去。

这里会问你是不是要overwrite,就是问你是不是覆盖,选yes to all 就是是的全部。

自此覆盖全部结束,到这还没全结束,我们只是降级了,现在我们要破解了。

破解服务器端

相关文件:

链接: https://pan.baidu.com/s/1f5Kg2XSi4sAF9ygmu07pSA 密码: uw01

1:修改群晖的HOST文件
SSH修改方式很好,但是怕有的同学不会,咱们弄点简单的。打开刚才那个WINSCP软件,进入根目录下,找ETC目录,然后往下翻,找到hosts文件,拖到左边去,下载下来,软件不要关。

2:找到下载下来的hosts文件,双击,用写字板打开,当然用notepod更好。
最后加上两行

1
2
127.0.0.1 accounts5.roonlabs.com
127.0.0.1 updates.roonlabs.com

保存,然后从WINSCP软件里面,找到修改好的文件,再拖会群晖里面就好了。会提示你是不是覆盖,覆盖掉就好了。这样RoonServer就没办法升级和验证了。

3:修改电脑的HOSTS文件

进入 C:/Windows/System32/drivers/etc目录

把hosts文件COPY到桌面,然后记事本打开,加两行:

1
2
127.0.0.1 accounts5.roonlabs.com
127.0.0.1 updates.roonlabs.com

保存,然后覆盖回 C:/Windows/System32/drivers/etc目录。会提示你拒绝访问,要管理员权限,继续。

覆盖完成,准备工作就好了。

4:开始正式破解
这里面我们需要找一个注册文件
所以我们需要安装RoonServer服务器端的PC版,找到roon.rar文件,解压缩,得到一个文件夹和两个文件,我为了方便,新建了一个ROON目录,在C盘,把文件都放进去了。

RoonKeyMaker是破解文件,RoonInstaller64是播放软件,我们现在要用的是RoonServerInstaller64.exe,双击运行RoonServerInstaller64.exe。这里没什么讲究的,反正你一会会卸载他,如图按顺序即可。

然后你要注意你电脑的右下角,多了一个图标,是ROONSERVER的。显示是ROON。这个就是服务器端开始运行了。不用管他了。
运行CMD,调出命令行。

进入里的ROON破解文件的目录,如果你跟我的一样,按顺序输入

1
2
cd\
cd roon\RoonKeyMaker\rkm_win

进入目录(注意不要输入错,你可以直接复制我上面的命令运行)

然后输入

1
rkm_win -i rs

会跳出Enter your name,包括后面的。你可以随便输,不过这个后面你运行软件的时候会这么显示。

看到successfully我们知道成功了。(如果是PC端安装server的同学,这里你就破解成功了。右下角那个下图标,右键,quit,关闭一下,然后桌边的roonserver,重新启动一下就好了)
然后我们去找KEY去吧。看下面的图,找到如图文件,复制出来。
C:/Users/用户名/AppData/Local/RoonServer/Database/Core

还是得用WINSCP,把文件复制过来即可。

这里有一个提示,开始我们找的那个目录是APPSTONE下满这个目录不一样了,是你最早新建的那个目录,我的目录是 /volume7/RoonServer/RoonServer/Database/Core,你不是一定是volume7,根据你自己的情况,把文件拖过去,自此,RoonServer,安装破解成功。

安装播放控制端

找到开始那个roon压缩包的解压缩文件夹。
1:运行RoonInstaller64.exe安装播放控制端。安装没什么好说的了。

2:ROON就运行了。这时候我们什么都不要动。不管他,也别关软件,一定不要关软件,一定不要关软件,一定不要关软件,一定不要关软件,一定不要关软件,一定不要关软件。

3:破解PC的播放端
首先运行CMD,调出命令行
然后依次输入

1
2
3
cd \
cd roon\RoonKeyMaker\rkm_win
rkm_win -i r

看清楚了。这次是r不是rs了,这两个命令一个是破解服务器端,一个是破解播放端的。

4:关闭电脑上的Roon软件,然后重新运行。因为破解了啊。要重新运行一下。这个时候再重开软件哦
然后左下角选择语言。

选择你喜欢的语言,比如马来语什么的,然后会提示你语言改了。现在重启。

5:这边,因为破解了。说一堆什么的,不管他,但是我们还是要谢谢一下。选择不,谢谢!

然后,这边我同意!

6:连接你的服务器吧

自此安装全部完成。

l

emby-server媒体库硬链接.md

现在emby和plex等媒体ggi库对于刮削的命名要求很高,而PT下载后我们却很难将下载的媒体资源添加到影音库中,因为要保持源文件名进行保种,而复制一份文件的话,又很占硬盘空间,造成浪费。
在这种情况下,使用Linux连接就能便捷得一石二鸟。

软链接和硬链接

软连接可以便捷地创立整个文件夹的连接,但软连接类似快捷方式,删除源文件后,连接就会失效。
这种情况下,如果你删除PT保种的文件,在媒体库中整理好的资源也会失效。
相比起来,硬链接更加方便,在创立连接后,即便删除了源文件,只要没有删除所有的硬链接文件,硬链接仍然有效。
但Linux的硬链接却有一个小小的缺点:为了避免递归问题,硬链接只能创建单个文件的连接,而无法连接整个文件夹。

linux硬链接脚本

Windows下有一些好用的硬链接工具,但在Linux系统下,却没有找到类似的工具,于是我就就从零搞起,网上东抄抄西抄抄,自己写了个简单的批量硬链接脚本,进行对文件夹的硬链接,在我的openmediavault系统(基于debian)下测试了几周,还没出过问题。
还没有在别的系统上测试过,但理论上适用于所有Linux系统,包括群晖、铁骑马、威联通、openmediavault、unas等等。

使用教程

  1. 编辑两个bash脚本文件hardlink.bash和2.bash,复制到Linux下的/usr/local/bin/文件夹中。
  2. 使用cd命令切换到想要让硬链接文件存在的文件夹,如test。
  3. 以root账户或具有root权限的账户执行:sudo bash hardlink.bash [你的PT下载影音资源所在的目录]

** 注:root权限下不再需要输入sudo,链接文件和源文件必须处于同一个硬盘之下,不能跨硬盘执行硬链接操作。**
如此一来,在test这个文件夹下,就出现了你想要硬链接的文件夹下的所有子文件夹和文件。
对链接文件进行修改文件名,删除操作,均不会影响源文件,仍然能pt保种,但修改链接文件内容,会造成源文件内容改变,同时,对于大部分的程序来说,硬链接文件和源文件是相同的。
如果感觉每次都需要输入很烦,可以创建一个计划任务,可以让系统自动创建硬链接。

hardlink.bash

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
#!/bin/bash
PRE_IFS=$IFS
IFS=$'\n'
distdir=$1
echo $distdir
newdir=`basename $distdir`
mkdir $newdir
cd $newdir
filename=$(ls $distdir)
currentdir=`pwd`
mod=$currentdir
echo $currentdir
for file in $filename
do
echo $file
if [ -f $distdir/$file ]
then
ln $distdir/$file $currentdir/$file
fi
if [ -d $distdir/$file ]
then
cd $currentdir
mkdir $currentdir/$file
cd $currentdir/$file
bash /usr/local/bin/2.bash $distdir/$file
fi
done
# 任务执行完毕,把IFS还原回默认值
chmod -R 777 $mod
IFS=$PRE_IFS

2.bash

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
#!/bin/bash
PRE_IFS=$IFS
IFS=$'\n'
currentdir=`pwd`
echo $currentdir
distdir=$1
echo $distdir
filename=$(ls $distdir)
for file in $filename
do
echo $file
if [ -f $distdir/$file ]
then
ln $distdir/$file $currentdir/$file
fi
if [ -d $distdir/$file ]
then
cd $currentdir
mkdir $currentdir/$file
cd $currentdir/$file
bash /usr/local/bin/${0##*/} $distdir/$file
fi
done
# 任务执行完毕,把IFS还原回默认值
IFS=$PRE_IFS
l

Spring Boot 2.x 开始 Lettuce 已取代 Jedis 成为首选 Redis 的客户端。当然 Spring Boot 2.x 仍然支持 Jedis,并且你可以任意切换客户端。

Lettuce

Lettuce 是一个可伸缩的线程安全的 Redis 客户端,支持同步、异步和响应式模式。多个线程可以共享一个连接实例,而不必担心多线程并发问题。它基于优秀 Netty NIO 框架构建,支持 Redis 的高级功能,如 Sentinel、集群、流水线、自动重新连接和 Redis 数据模型

Jedis 实现通过直接连接的 redis server,如果在多线程环境下是非线程安全的,这个时候只有使用连接池,为每个 Jedis 实例增加物理连接。

Lettuce 的连接是基于 Netty 的,连接实例 (StatefulRedisConnection) 可以在多个线程间并发访问,因为 StatefulRedisConnection 是线程安全的,所以一个连接实例 (StatefulRedisConnection) 就可以满足多线程环境下的并发访问,当然这个也是可伸缩的设计,一个连接实例不够的情况也可以按需增加连接实例。        

Spring Boot 2.0 集成 redis

一般需要4步

  1. 引入依赖
  2. 配置 redis
  3. 自定义 RedisTemplate (推荐)
  4. 自定义 redis 操作类 (推荐)

引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- lettuce pool 缓存连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

<!--jackson-->
<!--<dependency>-->
<!-- <groupId>com.fasterxml.jackson.core</groupId>-->
<!-- <artifactId>jackson-databind</artifactId>-->
<!--</dependency>-->

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.67</version>
</dependency>
  • 如果用的是 lettuce 客户端,需要引入 commons-pool2 连接池。
  • 如果想用 json 序列化 redis 的 value 值,需要引入 jackson

配置 redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# redis 服务端相关配置
# 服务器地址
spring.redis.host=localhost
# 端口号
spring.redis.port=6379
# 密码,默认为 null
spring.redis.password=
# 使用的数据库,默认选择下标为0的数据库
spring.redis.database=0
# 客户端超时时间,默认是2000ms
spring.redis.timeout=2000ms


## jedis 客户端配置(从 Spring Boot 2.x 开始,不再推荐使用 jedis 客户端)
## 建立连接最大等待时间,默认1ms,超出该时间会抛异常。设为-1表示无限等待,直到分配成功。
#spring.redis.jedis.pool.max-wait=1ms
## 最大连连接数,默认为8,负值表示没有限制
#spring.redis.jedis.pool.max-active=8
## 最大空闲连接数,默认8。负值表示没有限制
#spring.redis.jedis.pool.max-idle=8
## 最小空闲连接数,默认0。
#spring.redis.jedis.pool.min-idle=0


# lettuce 客户端配置(从 Spring Boot 2.x 开始,推荐使用 lettuce 客户端)
# 建立连接最大等待时间,默认1ms,超出该时间会抛异常。设为-1表示无限等待,直到分配成功。
spring.redis.lettuce.pool.max-wait=1ms
# 最大连连接数,默认为8,负值表示没有限制
spring.redis.lettuce.pool.max-active=8
# 最大空闲连接数,默认8。负值表示没有限制
spring.redis.lettuce.pool.max-idle=8
# 最小空闲连接数,默认0。
spring.redis.lettuce.pool.min-idle=0
# 设置关闭连接的超时时间
spring.redis.lettuce.shutdown-timeout=100ms

自定义 RedisTemplate

RedisTemplate 是 spring 为我们提供的 redis 操作类,通过它我们可以完成大部分 redis 操作。

只要我们引入了 redis 依赖,并将 redis 的连接信息配置正确,springboot 就会根据我们的配置会给我们生成默认 RedisTemplate。

但是默认生成的 RedisTemplate 有两个地方不是很符合日常开发中的使用习惯

  1. 默认生成的 RedisTemplate<K, V> 接收的keyvalue为泛型,经常需要类型转换,直接使用不是很方便
  2. 默认生成的 RedisTemplate 序列化时,使用的是 JdkSerializationRedisSerializer ,存储到 redis 中后,内容为二进制字节,不利于查看原始内容

对于第一个问题,一般习惯将 RedisTemplate<K, V> 改为 RedisTemplate<String, Object>,即接收的 keyString 类型,接收的 valueObject 类型 对于第二个问题,一般会把数据序列化为 json 格式,然后存储到 redis 中,序列化成 json 格式还有一个好处就是跨语言,其他语言也可以读取你存储在 redis 中的内容

为了实现上面两个目的,我们需要自定义自己的 RedisTemplate

如下,创建一个 config 类,在里面配置 自定义的 RedisTemplate

image.png

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
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.*;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 配置 json 序列化器 - Jackson2JsonRedisSerializer
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper()
// 扩展序列化器,增加对 java.time.* 包中时间类的序列化、反序列化支持
.registerModule(new ParameterNamesModule())
.registerModule(new Jdk8Module())
.registerModule(new JavaTimeModule());
jacksonSerializer.setObjectMapper(objectMapper);

// 创建并配置自定义 RedisTemplateRedisOperator
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 将 key 序列化成字符串
template.setKeySerializer(new StringRedisSerializer());
// 将 hash 的 key 序列化成字符串
template.setHashKeySerializer(new StringRedisSerializer());
// 将 value 序列化成 json
template.setValueSerializer(jacksonSerializer);
// 将 hash 的 value 序列化成 json
template.setHashValueSerializer(jacksonSerializer);
// 设置连接器
template.setConnectionFactory(redisConnectionFactory);
return template;
}

@Bean
public HashOperations<String, String, Object> hashOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForHash();
}

@Bean
public ValueOperations<String, Object> valueOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForValue();
}

@Bean
public ListOperations<String, Object> listOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForList();
}

@Bean
public SetOperations<String, Object> setOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForSet();
}

@Bean
public ZSetOperations<String, Object> zSetOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForZSet();
}

}

自定义 Redis 操作类

虽然 RedisTemplate 已经对 redis 的操作进行了一定程度的封装,但是直接使用还是有些不方便,实际开发中,一般会对 RedisTemplate 做近一步封装,形成一个简单、方便使用的Redis 操作类。

当然你也可以选择不封装,看个人喜好。

具体的封装类参考基于 RedisTemplate 自定义 Redis 操作类

Spring Cache

Spring Cache 是 Spring 为缓存场景提供的一套解决方案。通过使用 @CachePut@CacheEvict@Cacheable等注解实现对缓存的,存储、查询、删除等操作

当我们引入了 spring-boot-starter-data-redis 后,只要在带有@Configuration类上使用 @EnableCaching 注解 Spring Cache 就会被“激活”。

Spring Cache 会为我们配置默认的缓存管理器key生成器,但是缓存管理器对缓存的序列化和key生成器生成的key,不易阅读。建议自定义缓存管理器key生成器

如果用不上 Spring Cache ,可以不用管。

1
注意:Spring Cache 并不是只能使用 Redis 作为缓存容器,其他例如 MemCache 等缓存中间件,都支持。

配置 Spring Cache

img

1
2
3
4
5
## spring cache 配置
# 使用的缓存的类型
spring.cache.type=redis
# 通过 spring cache 注解添加的缓存 的到期时间,单位秒(这是一个自定义属性)
cache.expireTime=60

最重要的就是指定使用的缓存的类型
另外是一个自定义的变量,后面配置缓存管理器会用到

配置缓存管理器和 key 生成器

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.lang.reflect.Method;
import java.time.Duration;

@Configuration
// 开启 Spring Cache
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

@Value("${cache.expireTime}")
// 缓存超时时间
private int cacheExpireTime;

/**
* 配置@Cacheable@CacheEvict等注解在没有指定Key的情况下,key生成策略
* 该配置作用于缓存管理器管理的所有缓存
* 最终生成的key 为 cache类注解指定的cacheNames::类名:方法名#参数值1,参数值2...
*
* @return
*/
@Bean
public KeyGenerator keyGenerator() {
return new KeyGenerator() {
@Override
public Object generate(Object target, Method method, Object... params) {
StringBuffer sb = new StringBuffer();
sb.append(target.getClass().getName());
sb.append(":");
sb.append(method.getName());
sb.append("#");
for (Object obj : params) {
sb.append(obj.toString());
sb.append(",");
}
return sb.substring(0, sb.length() - 1);
}
};
}


/**
* 配置缓存管理器
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// 配置 json 序列化器 - Jackson2JsonRedisSerializer
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jacksonSerializer.setObjectMapper(objectMapper);

//关键点,spring cache 的注解使用的序列化都从这来,没有这个配置的话使用的jdk自己的序列化,实际上不影响使用,只是打印出来不适合人眼识别
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
// 将 key 序列化成字符串
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// 将 value 序列化成 json
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jacksonSerializer))//value序列化方式
// 设置缓存过期时间,单位秒
.entryTtl(Duration.ofSeconds(cacheExpireTime))
// 不缓存空值
.disableCachingNullValues();

return RedisCacheManager.builder(factory)
.cacheDefaults(cacheConfig)
.build();
}
}

总结

网上 Spring Boot 集成 redis 的教程大多都是,将 redis 和 spring cache 一块配置,很容易让人产生误解。

其实 redis 和 spring cache 是两个不同的东西,所以,上面的教程我特意分为了两个配置文件。

你可以只使用 redis 而不使用 spring cache,也可以反过来。

那为什么两者经常放在一起去讨论呢?
原因在于两者也有一定的联系

站在 reids 的角度看,spring cache 提供了一种便捷的操作 reids 的途径,为缓存场景提供了优秀的解决方案。

站在 spring cache 的角度看, reids 提供了一种缓存容器,可以把缓存放在 reids 中。

缓存管理器对 reids 的操作也是通过 redisTemplate 实现的。

l

renren-fast开发文档3.0最新版

版权说明

本文档为付费文档,版权归人人开源(renren.io)所有,并保留一切权利,本文档及其描述的内容受有关法律的版权保护,对本文档以任何形式的非法复制、泄露或散布到网络提供下载,都将导致相应的法律责任。

免责声明

本文档仅提供阶段性信息,所含内容可根据项目的实际情况随时更新,以人人开源社区公告为准。如因文档使用不当造成的直接或间接损失,人人开源不承担任何责任。

文档更新

本文档由人人开源于 2019 年 03 月 01 日最后修订。

第 1 章 项目介绍

人人权限系统是一套轻量级的权限系统,主要包括用户管理、角色管理、部门管理、菜单管理、定时任务、 参数管理、字典管理、文件上传、登录日志、操作日志、异常日志、文章管理、APP模块等功能。其中,还拥有多数据源、数据权限、国际化支持、Redis缓存动态开启与关闭、统一异常处理等技术特点。

1.1 项目描述

1.2 项目特点

1.3 数据交互

1.4 开发环境搭建

1.5 获取帮助

1.1 项目描述

renren-fast是一套轻量级的权限系统,主要包括用户管理、角色管理、菜单管理、定时任务、文件上传、系 统日志、APP模块等功能。其中,还拥有多数据源、Redis缓存动态开启与关闭、统一异常处理等技术特 点。

1.2 项目特点

  • renren-fast采用SpringBoot 2.1、MyBatis、Shiro框架,开发的一套权限系统,极低门槛,拿来即用。设
    计之初,就非常注重安全性,为企业系统保驾护航,让一切都变得如此简单。
  • 灵活的权限控制,可控制到页面或按钮,满足绝大部分的权限需求
  • 完善的 XSS 防范及脚本过滤,彻底杜绝 XSS 攻击
  • 支持MySQL、Oracle、SQL Server、PostgreSQL等主流数据库

推荐使用阿里云服务器部署项目,免费领取阿里云优惠券,请点击【免费领取】

1.3 数据交互

  • 一般情况下,web项目都是通过session进行认证,每次请求数据时,都会把jsessionid放在cookie中,以便与服务端保持会话
  • 本项目是前后端分离的,通过token进行认证(登录时,生成唯一的token凭证),每次请求数据时,都会把token放在header中,服务端解析token,并确定用户身份及用户权限,数据通过json交互
  • 数据交互流程,如下所示:

1.4 开发环境搭建

1.4.1 软件需求

  • JDK 1.8+
  • Maven 3.0+
  • MySQL 5.5+
  • Oracle 11g+
  • SQL Server 2012+
  • PostgreSQL 9.4+

1.4.2 下载源码

  • 通过 git ,下载renren-fast源码,如下:
1
git clone https://gitee.com/renrenio/renren-fast.git

1.4.3 IDEA 开发工具

  • IDEA打开项目, File -> Open 如下图:

1.4.4 Eclipse 开发工具

  • Eclipse导入项目,如下图:

1.4.5 创建数据库

  • 创建数据库 renren_fast ,数据库编码为UTF-8
1
2
CREATE
DATABASE renren_fast CHARACTER SET utf8 COLLATE utf8_general_ci;
  • 执行 db/mysql.sql 文件,初始化数据(默认支持MySQL)

1.4.6 修改配置文件

1.4.7 前端部署

renren-fast-vue基于vue、element-ui构建开发,实现renren-fast后台管理前端功能,提供一套更优的前端解决方案。 欢迎star或fork前端Git库,方便日后寻找,及二次开发

  • 开发环境,需要安装node8.x最新版
1
2
3
4
5
6
# 克隆项目
git clone https://gitee.com/renrenio/renren-fast-vue.git
# 安装依赖
npm install --registry=https://registry.npm.taobao.org
# 启动服务
npm run dev
  • 生产环境,打包并把dist目录文件,部署到Nginx里
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#构建生产环境(默认)
npm run build
# 构建测试环境
npm run build --qa
# 构建验收环境
npm run build --uat
# 构建生产环境
npm run build --prod
# 安装Nginx,并配置Nginx
server {
listen 80;
server_name localhost;
location / {
root E:\\renren-fast-vue;
index index.html index.htm;
}
}
# 启动Nginx后,访问如下路径即可
http://localhost
  • 登录的账号密码:admin/admin

1.5 获取帮助

第 2 章 数据库支持

2.1 MySQL 数据库支持

2.2 Oracle 数据库支持

2.3 SQL Server 数据库支持

2.4 PostgreSQL 数据库支持

2.1 MySQL 数据库支持

  1. 修改数据库配置信息,开发环境的配置文件在application-dev.yml,如下所示:
1
2
3
4
5
6
7
spring:
datasource:
druid:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/renren_fast?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false
username: root
password: 123456
  1. 执行db/mysql.sql,创建表及初始化数据,再启动项目即可

2.2 Oracle 数据库支持

  1. 修改数据库配置信息,开发环境的配置文件在application-dev.yml,如下所示:
1
2
3
4
5
6
7
spring:
datasource:
druid:
driver-class-name: oracle.jdbc.OracleDriver
url: jdbc:oracle:thin:@192.168.10.10:1521:renren
username: renren_fast
password: 123456
  1. 执行db/oracle.sql,创建表及初始化数据,再启动项目即可

2.3 SQL Server 数据库支持

  1. 修改数据库配置信息,开发环境的配置文件在application-dev.yml,如下所示:
1
2
3
4
5
6
7
spring:
datasource:
druid:
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://192.168.10.10:1433;DatabaseName=renren_fast
username: sa
password: 123456
  1. 执行db/sqlserver.sql,创建表及初始化数据,再启动项目即可

2.4 PostgreSQL 数据库支持

  1. 修改数据库配置信息,开发环境的配置文件在application-dev.yml,如下所示:
1
2
3
4
5
6
7
spring:
datasource:
druid:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.10.10:5432/renren_fast
username: renren
password: 123456
  1. 修改quartz配置信息,quartz配置文件 ScheduleConfig.java ,打开注释,如下所示:
1
2
3
//PostgreSQL数据库,需要打开此注释
prop.put("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.PostgreSQLD
elegate");
  1. 执行db/postgresql.sql,创建表及初始化数据,再启动项目即可

第 3 章 多数据源支持

3.1 多数据源配置

3.2 多数据源使用

3.3 源码讲解

3.1 多数据源配置

多数据源的应用场景,主要针对跨多个数据库实例的情况,如果是同实例中的多个数据库,则没必要使用多数据源。

1
2
3
#下面演示单实例,多数据库的使用情况
select * from db.table;
#其中,db为数据库名,table为数据库表名
  • 配置多数据源,如果是开发环境,则修改 application-dev.xml ,如下所示

多数据源的配置

1
2
3
4
5
6
7
8
9
10
11
12
dynamic:
datasource:
slave1:
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://192.168.10.10:1433;DatabaseName=renren_fast
username: sa
password: 123456
slave2:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.10.10:5432/renren_fast
username: postgres
password: 123456

3.2 多数据源使用

多数据源的使用,只需在Service类、方法上添加@DataSource(“”)注解即可,比如在类上添加了 @DataSource(“userDB”)注解,则表示该Service方法里的所有CURD,都会在 userDB 数据源里执行。

  1. 多数据源注解使用规则
    • 支持在Service类或方法上,添加多数据源的注解@DataSource
    • 在Service类上添加了@DataSource注解,则该类下的所有方法,都会使用@DataSource标注的数据源
    • 在Service类、方法上都添加了@DataSource注解,则方法上的注解会覆盖Service类上的注解
  2. 编写DynamicDataSourceTestService.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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package io.renren.service;

import io.renren.commons.dynamic.datasource.annotation.DataSource;
import io.renren.dao.SysUserDao;
import io.renren.entity.SysUserEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 测试多数据源
*
* @author Mark sunlightcs@gmail.com
*/
@Service
//@DataSource("slave1") 多数据源全局配置
public class DynamicDataSourceTestService {
@Autowired
private SysUserDao sysUserDao;

@Transactional
public void updateUser(Long id) {
SysUserEntity user = new SysUserEntity();
user.setUserId(id);
user.setMobile("13500000000");
sysUserDao.updateById(user);
}

@Transactional
@DataSource("slave1")
public void updateUserBySlave1(Long id) {
SysUserEntity user = new SysUserEntity();
user.setUserId(id);
user.setMobile("13500000001");
sysUserDao.updateById(user);
}

@DataSource("slave2")
@Transactional
public void updateUserBySlave2(Long id) {
SysUserEntity user = new SysUserEntity();
user.setUserId(id);
user.setMobile("13500000002");
sysUserDao.updateById(user);
//测试事物
int i = 1 / 0;
}
}
  1. 运行测试类DynamicDataSourceTest.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
25
26
27
package io.renren.service;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

/**
* 多数据源测试
*
* @author Mark sunlightcs@gmail.com
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class DynamicDataSourceTest {
@Autowired
private DynamicDataSourceTestService dynamicDataSourceTestService;

@Test
public void test() {
Long id = 1L;
dynamicDataSourceTestService.updateUser(id);
dynamicDataSourceTestService.updateUserBySlave1(id);
dynamicDataSourceTestService.updateUserBySlave2(id);
}
}
  1. 其中, @DataSource(“slave1”) 、 @DataSource(“slave2”) 里的 slave1 、 slave2 值,是在application- dev.xml里配置的,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
dynamic:
datasource:
slave1:
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://localhost:1433;DatabaseName=renren_security
username: sa
password: 123456
slave2:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/renren_security
username: renren
password: 123456

3.3 源码讲解

  1. 定义多数据源注解类@DataSource,使用多数据源时,只需在Service方法上添加@DataSource注解即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.lang.annotation.*;

/**
* 多数据源注解
*
* @author Mark sunlightcs@gmail.com
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DataSource {
String value() default "";
}
  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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
/**
* 多数据源属性
*
* @author Mark sunlightcs@gmail.com
*/
public class DataSourceProperties {
private String driverClassName;
private String url;
private String username;
private String password;
/**
* Druid默认参数
*/
private int initialSize = 2;
private int maxActive = 10;
private int minIdle = -1;
private long maxWait = 60 * 1000L;
private long timeBetweenEvictionRunsMillis = 60 * 1000L;
private long minEvictableIdleTimeMillis = 1000L * 60L * 30L;
private long maxEvictableIdleTimeMillis = 1000L * 60L * 60L * 7;
private String validationQuery = "select 1";
private int validationQueryTimeout = -1;
private boolean testOnBorrow = false;
private boolean testOnReturn = false;
private boolean testWhileIdle = true;
private boolean poolPreparedStatements = false;
private int maxOpenPreparedStatements = -1;
private boolean sharePreparedStatements = false;
private String filters = "stat,wall";

public String getDriverClassName() {
return driverClassName;
}

public void setDriverClassName(String driverClassName) {
this.driverClassName = driverClassName;
}

public String getUrl() {
return url;
}

public void setUrl(String url) {
this.url = url;
}

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}

public int getInitialSize() {
return initialSize;
}

public void setInitialSize(int initialSize) {
this.initialSize = initialSize;
}

public int getMaxActive() {
return maxActive;
}

public void setMaxActive(int maxActive) {
this.maxActive = maxActive;
}

public int getMinIdle() {
return minIdle;
}

public void setMinIdle(int minIdle) {
this.minIdle = minIdle;
}

public long getMaxWait() {
return maxWait;
}

public void setMaxWait(long maxWait) {
this.maxWait = maxWait;
}

public long getTimeBetweenEvictionRunsMillis() {
return timeBetweenEvictionRunsMillis;
}

public void setTimeBetweenEvictionRunsMillis(long timeBetweenEvictionRunsMillis) {
this.timeBetweenEvictionRunsMillis =
timeBetweenEvictionRunsMillis;
}

public long getMinEvictableIdleTimeMillis() {
return minEvictableIdleTimeMillis;
}

public void setMinEvictableIdleTimeMillis(long minEvictableIdleTimeMillis) {
this.minEvictableIdleTimeMillis =
minEvictableIdleTimeMillis;
}

public long getMaxEvictableIdleTimeMillis() {
return maxEvictableIdleTimeMillis;
}

public void setMaxEvictableIdleTimeMillis(long maxEvictableIdleTimeMillis) {
this.maxEvictableIdleTimeMillis =
maxEvictableIdleTimeMillis;
}

public String getValidationQuery() {
return validationQuery;
}

public void setValidationQuery(String validationQuery) {
this.validationQuery = validationQuery;
}

public int getValidationQueryTimeout() {
return validationQueryTimeout;
}

public void setValidationQueryTimeout(int validationQueryTimeout) {
this.validationQueryTimeout =
validationQueryTimeout;
}

public boolean isTestOnBorrow() {
return testOnBorrow;
}

public void setTestOnBorrow(boolean testOnBorrow) {
this.testOnBorrow = testOnBorrow;
}

public boolean isTestOnReturn() {
return testOnReturn;
}

public void setTestOnReturn(boolean testOnReturn) {
this.testOnReturn = testOnReturn;
}

public boolean isTestWhileIdle() {
return testWhileIdle;
}

public void setTestWhileIdle(boolean testWhileIdle) {
this.testWhileIdle = testWhileIdle;
}

public boolean isPoolPreparedStatements() {
return poolPreparedStatements;
}

public void setPoolPreparedStatements(boolean poolPreparedStatements) {
this.poolPreparedStatements =
poolPreparedStatements;
}

public int getMaxOpenPreparedStatements() {
return maxOpenPreparedStatements;
}

public void setMaxOpenPreparedStatements(int maxOpenPreparedStatements) {
this.maxOpenPreparedStatements =
maxOpenPreparedStatements;
}

public boolean isSharePreparedStatements() {
return sharePreparedStatements;
}

public void setSharePreparedStatements(boolean sharePreparedStatements) {
this.sharePreparedStatements =
sharePreparedStatements;
}

public String getFilters() {
return filters;
}

public void setFilters(String filters) {
this.filters = filters;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* 多数据源属性
*
* @author Mark sunlightcs@gmail.com
*/
@ConfigurationProperties(prefix = "dynamic")
public class DynamicDataSourceProperties {
private Map<String, DataSourceProperties> datasource = new LinkedHashMap<>();

public Map<String, DataSourceProperties> getDatasource() {
return datasource;
}

public void setDatasource(Map<String, DataSourceProperties> datasource) {
this.datasource = datasource;
}
}
  1. 扩展Spring的AbstractRoutingDataSource抽象类,
    AbstractRoutingDataSource中的抽象方法determineCurrentLookupKey是实现多数据源的核心,并对该方法进行Override,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

/**
* 多数据源
*
* @author Mark sunlightcs@gmail.com
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicContextHolder.peek();
}
}
  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
/**
* 多数据源上下文
*
* @author Mark sunlightcs@gmail.com
*/
public class DynamicContextHolder {
@SuppressWarnings("unchecked")
private static final ThreadLocal<Deque<String>> CONTEXT_HOLDER = new ThreadLocal() {
@Override
protected Object initialValue() {
return new ArrayDeque();
}
};

/**
* 获得当前线程数据源
*
* @return 数据源名称
*/
public static String peek() {
return CONTEXT_HOLDER.get().peek();
}

/**
* 设置当前线程数据源
*
* @param dataSource 数据源名称
*/
public static void push(String dataSource) {
CONTEXT_HOLDER.get().push(dataSource);
}

/**
* 清空当前线程数据源
*/
public static void poll() {
Deque<String> deque = CONTEXT_HOLDER.get();
deque.poll();
if (deque.isEmpty()) {
CONTEXT_HOLDER.remove();
}
}
}
  1. 配置多数据源,如下所示:
1
2
3
return druidDataSource;
}
}
  1. @DataSource注解的切面处理类,动态切换的核心代码
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
import com.alibaba.druid.pool.DruidDataSource;
import io.renren.commons.dynamic.datasource.properties.DataSourceProperties;
import io.renren.commons.dynamic.datasource.properties.DynamicDataSourceProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

/**
* 配置多数据源
*
* @author Mark sunlightcs@gmail.com
*/
@Configuration
@EnableConfigurationProperties(DynamicDataSourceProperties.class)
public class DynamicDataSourceConfig {
@Autowired
private DynamicDataSourceProperties properties;

@Bean
@ConfigurationProperties(prefix = "spring.datasource.druid")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
//因为DynamicDataSource是继承与AbstractRoutingDataSource,而AbstractRoutingDataSource又是继
承于AbstractDataSource,AbstractDataSource实现了统一的DataSource接口,
所以DynamicDataSource也可
以当做DataSource使用

@Bean
public DynamicDataSource dynamicDataSource(DataSourceProperties dataSourceProperties) {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
dynamicDataSource.setTargetDataSources(getDynamicDataSource());
//默认数据源
DruidDataSource defaultDataSource = DynamicDataSourceFactory.buildDruidDataSource(dat
aSourceProperties);
dynamicDataSource.setDefaultTargetDataSource(defaultDataSource);
return dynamicDataSource;
}

private Map<Object, Object> getDynamicDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
properties.getDatasource().forEach((k, v) -> {
DruidDataSource druidDataSource = DynamicDataSourceFactory.buildDruidDataSource(v
);
targetDataSources.put(k, druidDataSource);
});
return targetDataSources;
}
}
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
import com.alibaba.druid.pool.DruidDataSource;
import io.renren.commons.dynamic.datasource.properties.DataSourceProperties;

import java.sql.SQLException;

/**
* DruidDataSource
*
* @author Mark sunlightcs@gmail.com
*/
public class DynamicDataSourceFactory {
public static DruidDataSource buildDruidDataSource(DataSourceProperties properties) {
DruidDataSource druidDataSource = new DruidDataSource();
druidDataSource.setDriverClassName(properties.getDriverClassName());
druidDataSource.setUrl(properties.getUrl());
druidDataSource.setUsername(properties.getUsername());
druidDataSource.setPassword(properties.getPassword());
druidDataSource.setInitialSize(properties.getInitialSize());
druidDataSource.setMaxActive(properties.getMaxActive());
druidDataSource.setMinIdle(properties.getMinIdle());
druidDataSource.setMaxWait(properties.getMaxWait());
druidDataSource.setTimeBetweenEvictionRunsMillis(properties.getTimeBetweenEvictionRun
sMillis());
druidDataSource.setMinEvictableIdleTimeMillis(properties.getMinEvictableIdleTimeMilli
s());
druidDataSource.setMaxEvictableIdleTimeMillis(properties.getMaxEvictableIdleTimeMilli
s());
druidDataSource.setValidationQuery(properties.getValidationQuery());
druidDataSource.setValidationQueryTimeout(properties.getValidationQueryTimeout());
druidDataSource.setTestOnBorrow(properties.isTestOnBorrow());
druidDataSource.setTestOnReturn(properties.isTestOnReturn());
druidDataSource.setPoolPreparedStatements(properties.isPoolPreparedStatements());
druidDataSource.setMaxOpenPreparedStatements(properties.getMaxOpenPreparedStatements(
));
druidDataSource.setSharePreparedStatements(properties.isSharePreparedStatements());
try {
druidDataSource.setFilters(properties.getFilters());
druidDataSource.init();
} catch (SQLException e) {
e.printStackTrace();
}
return druidDataSource;
}
}
  1. @DataSource注解的切面处理类,动态切换的核心代码
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
import io.renren.commons.dynamic.datasource.annotation.DataSource;
import io.renren.commons.dynamic.datasource.config.DynamicContextHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

/**
* 多数据源,切面处理类
*
* @author Mark sunlightcs@gmail.com
*/
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DataSourceAspect {
protected Logger logger = LoggerFactory.getLogger(getClass());

@Pointcut("@annotation(io.renren.commons.dynamic.datasource.annotation.DataSource) " +
"|| @within(io.renren.commons.dynamic.datasource.annotation.DataSource)")
public void dataSourcePointCut() {
}

@Around("dataSourcePointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Class targetClass = point.getTarget().getClass();
Method method = signature.getMethod();
DataSource targetDataSource = (DataSource) targetClass.getAnnotation(DataSource.class);
DataSource methodDataSource = method.getAnnotation(DataSource.class);
if (targetDataSource != null || methodDataSource != null) {
String value;
if (methodDataSource != null) {
value = methodDataSource.value();
} else {
value = targetDataSource.value();
}
DynamicContextHolder.push(value);
logger.debug("set datasource is {}", value);
}
try {
return point.proceed();
} finally {
DynamicContextHolder.poll();
logger.debug("clean datasource");
}
}
}

第 4 章 基础知识讲解

4.1 Spring MVC 使用

4.2 Swagger 使用

4.3 Mybatis-plus 使用

4.1 Spring MVC 使用

对Spring MVC不太熟悉的,需要理解Spring MVC常用的注解,也方便日后排查问题,常用的注解如下所示:

4.1.1 @Controller 注解

@Controller注解表明了一个类是作为控制器的角色而存在的。Spring不要求你去继承任何控制器基类,也不要求你去实现Servlet的那套API。当然,如果你需要的话也可以去使用任何与Servlet相关的特性。

1
2
3
4
5

@Controller
public class UserController {
// ...
}

4.1.2 @RequestMapping 注解

你可以使用@RequestMapping注解来将请求URL,如/user等,映射到整个类上或某个特定的处理器方法上。
一般来说,类级别的注解负责将一个特定(或符合某种模式)的请求路径映射到一个控制器上,同时通过方法级别的注解来细化映射,即根据特定的HTTP请求方法(GET、POST方法等)、HTTP请求中是否携带特 定参数等条件,将请求映射到匹配的方法上。

1
2
3
4
5
6
7
8
9

@Controller
public class UserController {

@RequestMapping("/user")
public String user() {
return "user";
}
}

以上代码没有指定请求必须是GET方法还是PUT/POST或其他方法,@RequestMapping注解默认会映射所有 的HTTP请求方法。如果仅想接收某种请求方法,请在注解中指定之@RequestMapping(path = “/user”, method = RequestMethod.GET)以缩小范围。

4.1.3 @PathVariable 注解

在Spring MVC中你可以在方法参数上使用@PathVariable注解,将其与URI模板中的参数绑定起来,如下所 示:

1
2
3
4
5
6
@RequestMapping(path = "/user/{userId}", method = RequestMethod.GET)
public String userCenter(@PathVariable("userId") String userId,Model model){
UserDTO user=userService.get(userId);
model.addAttribute("user",user);
return"userCenter";
}

URI模板”/user/{userId}”指定了一个变量名为userId。当控制器处理这个请求的时候,userId的值就会被URI模 板中对应部分的值所填充。比如说,如果请求的URI是/userId/1,此时变量userId的值就是 1 。

4.1.4 @GetMapping 注解

@GetMapping是一个组合注解,是@RequestMapping(method = RequestMethod.GET)的缩写。该注解将HTTP GET映射到特定的处理方法上。可以使用@GetMapping(“/user”)来代替@RequestMapping(path=”/user”,method=RequestMethod.GET)。还有@PostMapping、@PutMapping、 @DeleteMapping等同理。

4.1.5 @RequestBody 注解

该注解用于读取Request请求的body部分数据,使用系统默认配置的HttpMessageConverter进行解析,然后把相应的数据绑定到要返回的对象上,再把HttpMessageConverter返回的对象数据绑定到Controller中方法的参数上。

1
2
3
4
5
6
7
8
9

@Controller
public class UserController {
@GetMapping("/user")
public String user(@RequestBody User user) {
//...
return "user";
}
}

4.1.6 @ResponseBody 注解

该注解用于将Controller的方法返回的对象,通过适当的HttpMessageConverter转换为指定格式后,写入到Response对象的body数据区。比如获取JSON数据,加上@ResponseBody后,会直接返回JSON数据,而不会被解析为视图。

1
2
3
4
5
6
7
8
9
10

@Controller
public class UserController {
@ResponseBody
@GetMapping("/user/{userId}")
public User info(@PathVariable("userId") String userId) {
UserDTO user = userService.get(userId);
return user;
}
}

4.1.7 @RestController 注解

@RestController是一个组合注解,即@Controller + @ResponseBody的组合注解,请求完后,会返回JSON数据。

4.2 Swagger 使用

Swagger是一个根据Swagger注解,即可生成接口文档的服务。

4.2.1 搭建 Swagger 环境

  • 在pom.xml文件中添加swagger相关依赖,如下所示:
1
2
3
4
5
6
7
8
9
10
11

<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>${springfox-version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>${springfox-version}</version>
</dependency>
  • 编写Swagger的Configuration配置文件,如下所示:
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
import io.swagger.annotations.ApiOperation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.ApiKey;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.util.List;

import static com.google.common.collect.Lists.newArrayList;

@Configuration
@EnableSwagger2
public class SwaggerConfig implements WebMvcConfigurer {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
//加了ApiOperation注解的类,才生成接口文档
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
//io.renren.controller包下的类,才生成接口文档
//.apis(RequestHandlerSelectors.basePackage("io.renren.controller"))
.paths(PathSelectors.any())
.build()
.directModelSubstitute(java.util.Date.class, String.class);
}

private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("人人开源")
.description("人人开源接口文档")
.termsOfServiceUrl("https://www.renren.io/community")
.version("1.0.0")
.build();
}
}

4.2.2 Swagger 常用注解

  • @Api注解用在类上,说明该类的作用。可以标记一个Controller类做为swagger文档资源,如下所示:
1
2
3
4
5
6

@Api(tags = "用户管理")
@RestController
public class UserController {

}
  • @ApiOperation注解用在方法上,说明该方法的作用,如下所示:
1
2
3
4
5
6
7
8
9
10
11

@Api(tags = "用户管理")
@RestController
public class UserController {
@GetMapping("/user/list")
@ApiOperation("列表")
public List<UserDTO> list() {
List<UserDTO> list = userService.list();
return list;
}
}
  • @ApiParam注解用在方法参数上,如下所示:
1
2
3
4
5
6
7
8
9
10
11

@Api(tags = "用户管理")
@RestController
public class UserController {
@GetMapping("/user/list")
@ApiOperation("列表")
public List list(@ApiParam(value = "用户名", required = true) String username) {
List list = userService.list();
return list;
}
}
  • @ApiImplicitParams注解用在方法上,主要用于一组参数说明

  • @ApiImplicitParam注解用在@ApiImplicitParams注解中,指定一个请求参数的信息,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping("page")
@ApiOperation("分页")
@ApiImplicitParams({
@ApiImplicitParam(name = "page", value = "当前页码,从 1 开始", paramType = "query", requ ired=true, dataType = "int"),
@ApiImplicitParam(name = "limit", value = "每页显示记录数", paramType = "query", requir ed=true, dataType = "int"),
@ApiImplicitParam(name = "order_field", value = "排序字段", paramType = "query", dataT ype="String"),
@ApiImplicitParam(name = "order", value = "排序方式,可选值(asc、desc)", paramType = "q uery", dataType = "String"),
@ApiImplicitParam(name = "username", value = "用户名", paramType = "query", dataType = "String")
})
public Result<PageData> page(@ApiIgnore @RequestParam Map<String, Object> par ams){
PageData page=sysUserService.page(params);
return new Result<PageData>().ok(page);
}
  • @ApiIgnore注解,可用于类、方法或参数上,表示生成Swagger接口文档时,忽略类、方法或参数。

4.3 Mybatis-plus 使用

在项目的pom.xml里引入依赖,如下所示:

1
2
3
4
5
6

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>

在yml配置文件里,配置mybatis-plus,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
#实体扫描,多个package用逗号或者分号分隔
typeAliasesPackage: io.renren.modules.*.entity
global-config:
#数据库相关配置
db-config:
#主键类型 AUTO:"数据库ID自增", INPUT:"用户输入ID", ID_WORKER:"全局唯一ID (数字类型唯一ID)"
, UUID:"全局唯一ID UUID";
id-type: AUTO
#字段策略 IGNORED:"忽略判断",NOT_NULL:"非 NULL 判断"),NOT_EMPTY:"非空判断"
field-strategy: NOT_NULL
#驼峰下划线转换
column-underline: true
logic-delete-value: -1
logic-not-delete-value: 0
banner: false
#原生配置
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
call-setters-on-nulls: true
jdbc-type-for-null: 'null'

第 5 章 项目实战

5.1 需求说明

5.2 代码生成器

5.1 需求说明

我们来完成一个商品的列表、添加、修改、删除功能,熟悉如何快速开发自己的业务功能模块。

  • 我们先建一个商品表tb_goods,表结构如下所示:
1
2
3
4
5
6
7
8
9
CREATE TABLE `tb_goods`
(
`goods_id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`name` varchar(50) COMMENT '商品名',
`intro` varchar(500) COMMENT '介绍',
`price` decimal(10, 2) COMMENT '价格',
`num` int COMMENT '数量',
PRIMARY KEY (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品管理';

5.1 代码生成器

  • 使用代码生成器前,我们先来看下代码生成器的配置,看看那些是可配置的,打开renren-generator模块 的配置文件generator.properties,如下所示:
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
#代码生成器,配置信息
mainPath=io.renren
#包名
package=io.renren.modules
moduleName=generator
#作者
author=Mark
#Email
email=sunlightcs@gmail.com
#表前缀(类名不会包含表前缀)
tablePrefix=tb_
#类型转换,配置信息
tinyint=Integer
smallint=Integer
mediumint=Integer
int=Integer
integer=Integer
bigint=Long
float=Float
double=Double
decimal=BigDecimal
bit=Boolean
char=String
varchar=String
tinytext=String
text=String
mediumtext=String
longtext=String
date=Date
datetime=Date
timestamp=Date
NUMBER=Integer
INT=Integer
INTEGER=Integer
BINARY_INTEGER=Integer
LONG=String
FLOAT=Float
BINARY_FLOAT=Float
DOUBLE=Double
BINARY_DOUBLE=Double
DECIMAL=BigDecimal
CHAR=String
VARCHAR=String
VARCHAR2=String
NVARCHAR=String
NVARCHAR2=String
CLOB=String
BLOB=String
DATE=Date
DATETIME=Date
TIMESTAMP=Date
TIMESTAMP(6)=Date
int8=Long
int4=Integer
int2=Integer
numeric=BigDecimal

上面的配置文件,可以配置包名、作者信息、表前缀、模块名称、类型转换等信息。其中,类型转换是指, MySQL中的类型与JavaBean中的类型,是怎么一个对应关系。如果有缺少的类型,可自行在generator.properties文件中补充。

  • 再看看renren-generator模块的application.yml配置文件,我们只要修改数据库名、账号、密码,就可以 了。其中,数据库名是指待生成的表,所在的数据库。
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
server:
port: 80
# mysql
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
#MySQL配置
driverClassName: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/renren_fast?useUnicode=true&characterEncoding=UTF-8&useS
SL=false
username: renren
password: 123456
#oracle配置
# driverClassName: oracle.jdbc.OracleDriver
# url: jdbc:oracle:thin:@47.100.206.162:1521:xe
# username: renren
# password: 123456
#SQLServer配置
# driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
# url: jdbc:sqlserver://192.168.10.10:1433;DatabaseName=renren_fast
# username: sa
# password: 123456
#PostgreSQL配置
# driverClassName: org.postgresql.Driver
# url: jdbc:postgresql://192.168.10.10:5432/renren_fast
# username: postgres
# password: 123456
jackson:
time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss resources:
static-locations: classpath:/static/,classpath:/views/

mybatis:
mapperLocations: classpath:mapper/**/*.xml

pagehelper:
reasonable: true
supportMethodsArguments: true
params: count=countSql

#指定数据库,可选值有【mysql、oracle、sqlserver、postgresql】
renren:
database: mysql
  • 在数据库renren_fast中,执行建表语句,创建tb_goods表,再启动renren-generator项目(运行 RenrenApplication.java的main方法即可),如下所示:

  • 在浏览器里输入项目地址(http://localhost),如下所示:

  • 我们只需勾选tb_goods,点击【生成代码】按钮,则可生成相应代码,如下所示:

  • 我们来看下生成的代码结构,如下所示:

  • 生成好代码后,我们只需在数据库renren_fast中,执行goods_menu.sql语句,这个SQL是生成菜单的, SQL语句如下所示:
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
# -- 菜单SQL

INSERT INTO `sys_menu` (`parent_id`, `name`, `url`, `perms`, `type`, `icon`, `order_num`)
VALUES ('1', '商品管理', 'generator/goods', NULL, '1', 'config', '6');

# -- 按钮父菜单ID

set @parentId = @@identity;

-- 菜单对应按钮SQL INSERT INTO `sys_menu` (`parent_id`, `name`, `url`, `perms`, `type`, `icon`, `order_num`)
SELECT @parentId,
'查看',
null,
'generator:goods:list,generator:goods:info',
'2',
null,
'6
';
INSERT INTO `sys_menu` (`parent_id`, `name`, `url`, `perms`, `type`, `icon`, `order_num`)
SELECT @parentId, '新增', null, 'generator:goods:save', '2', null, '6';

INSERT INTO `sys_menu` (`parent_id`, `name`, `url`, `perms`, `type`, `icon`, `order_num`)
SELECT @parentId, '修改', null, 'generator:goods:update', '2', null, '6';
INSERT INTO `sys_menu` ( `parent_id`, `name`
, `url`, `perms`, `type`, `icon`, `order_num`)
SELECT @parentId, '删除', null, 'generator:goods:delete', '2', null, '6';
  • 接下来,再把刚生成的后端代码,添加到项目renren-fast里,前端vue代码,添加到前端项目renren-fast- vue里,在启动renren-fast项目,如下所示:

  • 现在,我们就可以新增、修改、删除等操作

第 6 章 后端源码分析

6.1 前后端分离

6.2 权限设计思路

6.3 XSS 脚本过滤

6.4 SQL 注入

6.5 Redis 缓存

6.6 异常处理机制

6.7 后端效验机制

6.8 系统日志

6.9 添加菜单

6.10 添加角色

6.11 添加管理员

6.12 定时任务模块

6.13 云存储模块

6.14 APP 模块

6.1 前后端分离

要实现前后端分离,需要考虑以下 2 个问题:

1. 项目不再基于session了,如何知道访问者是谁?
1. 如何确认访问者的权限?
3. 前后端分离
  • 一般都是通过token实现,本项目也是一样;用户登录时,生成token及token过期时间,token与用户是一一对应关系,调用接口的时候,把token放到header或请求参数中,服务端就知道是谁在调用接口,登录如下所示:
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
/**
* 验证码
*/
@GetMapping("captcha.jpg")
public void captcha(HttpServletResponse response, String uuid) throws ServletException, IOException {
response.setHeader("Cache-Control", "no-store, no-cache");
response.setContentType("image/jpeg");
//获取图片验证码
BufferedImage image = sysCaptchaService.getCaptcha(uuid);
ServletOutputStream out = response.getOutputStream();
ImageIO.write(image, "jpg", out);
IOUtils.closeQuietly(out);
}

/**
* 登录
*/
@PostMapping("/sys/login")
public Map<String, Object> login(@RequestBody SysLoginForm form) throws IOException {
boolean captcha = sysCaptchaService.validate(form.getUuid(), form.getCaptcha());
if (!captcha) {
return R.error("验证码不正确");
}

//用户信息
SysUserEntity user = sysUserService.queryByUserName(form.getUsername());

//账号不存在、密码错误
if (user == null || !user.getPassword().equals(new Sha256Hash(form.getPassword(), user.get
Salt()).toHex())) {
return R.error("账号或密码不正确");
}
//账号锁定
if (user.getStatus() == 0) {
return R.error("账号已被锁定,请联系管理员");
}
//生成token,并保存到数据库
R r = sysUserTokenService.createToken(user.getUserId());
return r;
}


//生产token
public R createToken(long userId) {
//生成一个token,可以是uuid
String token = TokenGenerator.generateValue();
//当前时间
Date now = new Date();
//过期时间
Date expireTime = new Date(now.getTime() + EXPIRE * 1000);
//判断是否生成过token
SysUserTokenEntity tokenEntity = queryByUserId(userId);
if (tokenEntity == null) {
tokenEntity = new SysUserTokenEntity();
tokenEntity.setUserId(userId);
tokenEntity.setToken(token);
tokenEntity.setUpdateTime(now);
tokenEntity.setExpireTime(expireTime);
//保存token
save(tokenEntity);
} else {
tokenEntity.setToken(token);
tokenEntity.setUpdateTime(now);
tokenEntity.setExpireTime(expireTime);
//更新token
update(tokenEntity);
}
R r = R.ok().put("token", token).put("expire", EXPIRE);
return r;
}

其中,下面的这行代码,是加盐操作;可能有人不理解为何要加盐,其目的是防止被拖库后,黑客轻易的 (通过密码库对比),就能拿到你的密码

1
new Sha256Hash(password, user.getSalt()).toHex())
  • 调用接口时,接受传过来的token后,如何保证token有效及用户权限呢?其实,shiro提供了 AuthenticatingFilter抽象类,继承AuthenticatingFilter抽象类即可。

步骤 1 ,所有请求全部拒绝访问

1
2
3
4
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
return false;
}

步骤 2 ,拒绝访问的请求,会调用onAccessDenied方法,onAccessDenied方法先获取token,再调用 executeLogin方法

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
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
//获取请求token,如果token不存在,直接返回 401
String token = getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
String json = new Gson().toJson(R.error(HttpStatus.SC_UNAUTHORIZED, "invalid token"));
httpResponse.getWriter().print(json);
return false;
}
return executeLogin(request, response);
}

/**
* 获取请求的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
//从header中获取token
String token = httpRequest.getHeader("token");
//如果header中不存在token,则从参数中获取token
if (StringUtils.isBlank(token)) {
token = httpRequest.getParameter("token");
}
return token;
}

步骤 3 ,阅读AuthenticatingFilter抽象类中executeLogin方法,我们发现调用了 subject.login(token) ,这是shiro的登录方法,且需要token参数,我们自定义OAuth2Token类,只要实现AuthenticationToken接口,就可以了

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
//AuthenticatingFilter类中的方法
protected boolean executeLogin(ServletRequest request,ServletResponse response) throws Exception{
AuthenticationToken token=createToken(request,response);
if(token==null){
String msg="createToken method implementation returned null. A valid non-null A
uthenticationToken" +
"must be created in order to execute a login attempt.";
throw new IllegalStateException(msg);
}
try{
Subject subject=getSubject(request,response);
subject.login(token);
return onLoginSuccess(token,subject,request,response);
}catch(AuthenticationException e){
return onLoginFailure(token,e,request,response);
}
}


//subject.login(token)中的token对象,需要实现AuthenticationToken接口
public class OAuth2Token implements AuthenticationToken {
private String token;

public OAuth2Token(String token) {
this.token = token;
}

@Override
public String getPrincipal() {
return token;
}

@Override
public Object getCredentials() {
return token;
}
}

步骤 4 ,定义OAuth2Realm类,并继承AuthorizingRealm抽象类,调用 subject.login(token) 时,则会调用doGetAuthenticationInfo方法,进行登录

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
/**
* authentication身份认证(登录时调用)
*/
// 9. 前面被authc拦截后,需要认证,SecurityBean会调用这里进行认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String accessToken = (String) token.getPrincipal();

//根据accessToken,查询用户信息
SysUserTokenEntity tokenEntity = shiroService.queryByToken(accessToken);
//token失效

if(tokenEntity == null || tokenEntity.getExpireTime().getTime() < System.currentTimeMillis()){
throw new IncorrectCredentialsException("token失效,请重新登录");
}
// 9.1. token生效才能登录
//查询用户信息
SysUserEntity user = shiroService.queryUser(tokenEntity.getUserId());
//账号锁定
if(user.getStatus() == 0){
throw new LockedAccountException("账号已被锁定,请联系管理员");
}

SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, accessToken, getName());
return info;
}

步骤 5 ,登录失败后,则调用onLoginFailure,进行失败处理,整个流程结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setContentType("application/json;charset=utf-8");
try {
//处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
R r = R.error(HttpStatus.SC_UNAUTHORIZED, throwable.getMessage());
String json = new Gson().toJson(r);
httpResponse.getWriter().print(json);
} catch (IOException e1) {
}
return false;
}

步骤 6 ,登录成功后,则调用doGetAuthorizationInfo方法,查询用户的权限,再调用具体的接口,整个流程 结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* authorization授权(验证权限时调用)
*/
// 10. 前面被roles拦截后,需要授权才能登录,SecurityManager需要调用这里进行权限查询
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SysUserEntity user = (SysUserEntity)principals.getPrimaryPrincipal();
Long userId = user.getUserId();

//用户权限列表
Set<String> permsSet = shiroService.getUserPermissions(userId);

SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setStringPermissions(permsSet);
return info;
}

6.2 权限设计思路

权限相关的表结构,如下图所示:

  1. sys_user[用户]表,保存用户相关数据,通过sys_user_role[用户与角色关联]表,与sys_role[角色]表关联;sys_menu[菜单]表通过sys_role_menu[菜单与角色关联]表,与sys_role[角色]表关联
  2. sys_menu表,保存菜单相关数据,并在perms字段里,保存了shiro的权限标识,也就是说,拥有此菜单,就拥有perms字段里的所有权限,比如,某用户拥有的菜单权限标识 sys:user:info ,就可以访问下面的方法
1
2
3
4
@RequestMapping("/info/{userId}")
@RequiresPermissions("sys:user:info")
public R info(@PathVariable("userId") Long userId){
}
  1. 在shiro配置代码里,配置为 anon 的,表示不经过shiro处理,配置为 oauth2 的,表示经过 OAuth2Filter 处理,前后端分离的接口,都会交给 OAuth2Filter处理,这样就保证,没有权限的请求,拒绝访问
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
/**
* Shiro配置
*
* @author Mark sunlightcs@gmail.com
*/
@Configuration
public class ShiroConfig {

/**
* Shiro自带的过滤器,可以在这里配置拦截页面
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {

// 1. 初始化一个ShiroFilter工程类
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
// 2. 我们知道Shiro是通过SecurityManager来管理整个认证和授权流程的,这个SecurityManager可以在下面初始化
shiroFilter.setSecurityManager(securityManager);

//自定义Oauth2Filter过滤器
Map<String, Filter> filters = new HashMap<>();
filters.put("oauth2", new OAuth2Filter());
shiroFilter.setFilters(filters);

// 3. 上面我们讲过,有的页面不需登录任何人可以直接访问,有的需要登录才能访问,有的不仅要登录还需要相关权限才能访问
Map<String, String> filterMap = new LinkedHashMap<>();

// 4. Shiro过滤器常用的有如下几种
// 4.1. anon 任何人都能访问,如登录页面
// 4.2. authc 需要登录才能访问,如系统内的全体通知页面
// 4.3. roles 需要相应的角色才能访问
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/app/**", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/aaa.txt", "anon");
filterMap.put("/**", "oauth2");
// 5. 让ShiroFilter按这个规则拦截
shiroFilter.setFilterChainDefinitionMap(filterMap);

// 6. 用户没登录被拦截后,当然需要调转到登录页了,这里配置登录页
//shiroFilterFactoryBean.setLoginUrl("/api/user/login");
return shiroFilter;
}


@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
// 7. 新建一个SecurityManager供ShiroFilterFactoryBean使用
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 8. SecurityManager既然管理认证等信息,那他就需要一个类来帮他查数据库吧。这就是Realm类
securityManager.setRealm(oAuth2Realm);
securityManager.setRememberMeManager(null);
return securityManager;
}


@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}

@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

6.3 XSS 脚本过滤

XSS跨站脚本攻击的基本原理和SQL注入攻击类似,都是利用系统执行了未经过滤的危险代码,不同点 在于XSS是一种基于网页脚本的注入方式,也就是将脚本攻击载荷写入网页执行以达到对网页客户端访问用户攻击的目的,属于客户端攻击。程序员往往不太关心安全这块,这就给有心之人,提供了机会,本系统针对XSS攻击,提供了过滤功能,可以有效防止XSS攻击,代码如下:

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
public class XssFilter implements Filter {
@Override
public void init(FilterConfig config) throws ServletException {
}

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper(
(HttpServletRequest) request);
chain.doFilter(xssRequest, response);
}

@Override
public void destroy() {
}
}

@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean xssFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setDispatcherTypes(DispatcherType.REQUEST);
registration.setFilter(new XssFilter());
registration.addUrlPatterns("/*");
registration.setName("xssFilter");
registration.setOrder(Integer.MAX_VALUE);
return registration;
}
}
  • 自定义XssFilter过滤器,用来过滤所有请求,具体过滤还是在XssHttpServletRequestWrapper里实现的, 如下所示:
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
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
//没被包装过的HttpServletRequest(特殊场景,需要自己过滤)
HttpServletRequest orgRequest;
//html过滤
private final static HTMLFilter htmlFilter = new HTMLFilter();

public XssHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
orgRequest = request;
}

@Override
public ServletInputStream getInputStream() throws IOException {
//非json类型,直接返回
if (!MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(super.getHeader(HttpHeaders.CON
TENT_TYPE))) {
return super.getInputStream();
}
//为空,直接返回
String json = IOUtils.toString(super.getInputStream(), "utf-8");
if (StringUtils.isBlank(json)) {
return super.getInputStream();
}
//xss过滤
json = xssEncode(json);
final ByteArrayInputStream bis = new ByteArrayInputStream(json.getBytes("utf-8"));
return new ServletInputStream() {
@Override
public boolean isFinished() {
return true;
}

@Override
public boolean isReady() {
return true;
}

@Override
public void setReadListener(ReadListener readListener) {
}

@Override
public int read() throws IOException {
return bis.read();
}
};
}

@Override
public String getParameter(String name) {
String value = super.getParameter(xssEncode(name));
if (StringUtils.isNotBlank(value)) {
value = xssEncode(value);
}
return value;
}

@Override
public String[] getParameterValues(String name) {
String[] parameters = super.getParameterValues(name);
if (parameters == null || parameters.length == 0) {
return null;
}
for (int i = 0; i < parameters.length; i++) {
parameters[i] = xssEncode(parameters[i]);
}
return parameters;
}

@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> map = new LinkedHashMap<>();
Map<String, String[]> parameters = super.getParameterMap();
for (String key : parameters.keySet()) {
String[] values = parameters.get(key);
for (int i = 0; i < values.length; i++) {
values[i] = xssEncode(values[i]);
}
map.put(key, values);
}
return map;
}

@Override
public String getHeader(String name) {
String value = super.getHeader(xssEncode(name));
if (StringUtils.isNotBlank(value)) {
value = xssEncode(value);
}
return value;
}

private String xssEncode(String input) {
return htmlFilter.filter(input);
}

/**
* 获取最原始的request
*/
public HttpServletRequest getOrgRequest() {
return orgRequest;
}

/**
* 获取最原始的request
*/
public static HttpServletRequest getOrgRequest(HttpServletRequest request) {
if (request instanceof XssHttpServletRequestWrapper) {
return ((XssHttpServletRequestWrapper) request).getOrgRequest();
}
return request;
}
}

如果需要处理富文本数据,可以通过 XssHttpServletRequestWrapper.getOrgRequest(request) ,拿到原始 的 request 对象后,再自行处理富文本数据,如:

1
2
3
4
5
6
7
public R data(HttpServletRequest request){
HttpServletRequest orgRequest=XssHttpServletRequestWrapper.getOrgRequest(request);
String content=orgRequest.getParameter("content");
//富文本数据
System.out.println(content);
return R.ok();
}

6.4 SQL 注入

本系统使用的是Mybatis,如果使用${}拼接SQL,则存在SQL注入风险,可以对参数进行过滤,避免 SQL注入,如下:

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
public class SQLFilter {
/**
* SQL注入过滤
* @param str 待验证的字符串
*/
public static String sqlInject(String str) {
if (StringUtils.isBlank(str)) {
return null;
}
//去掉'|"|;|\字符
str = StringUtils.replace(str, "'", "");
str = StringUtils.replace(str, "\"", "");
str = StringUtils.replace(str, ";", "");
str = StringUtils.replace(str, "\\", "");
//转换成小写\
str = str.toLowerCase();
//非法字符
String[] keywords = {"master", "truncate", "insert", "select", "delete", "update", "declare", "alter", "drop"};
//判断是否包含非法字符
for (String keyword : keywords) {
if (str.indexOf(keyword) != -1) {
throw new RRException("包含非法字符");
}
}
return str;
}
}

像查询列表,涉及排序问题,排序字段是从前台传过来的,则存在SQL注入风险,需经如下处理:

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
public class Query<T> {
public IPage<T> getPage(Map<String, Object> params) {
return this.getPage(params, null, false);
}

public IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) {
//分页参数
long curPage = 1;
long limit = 10;
if (params.get(Constant.PAGE) != null) {
curPage = Long.parseLong((String) params.get(Constant.PAGE));
}
if (params.get(Constant.LIMIT) != null) {
limit = Long.parseLong((String) params.get(Constant.LIMIT));
}

//分页对象
Page<T> page = new Page<>(curPage, limit);
//分页参数
params.put(Constant.PAGE, page);

//排序字段
//防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险)
String orderField = SQLFilter.sqlInject((String) params.get(Constant.ORDER_FIELD));
String order = (String) params.get(Constant.ORDER);

//前端字段排序
if (StringUtils.isNotEmpty(orderField) && StringUtils.isNotEmpty(order)) {
if (Constant.ASC.equalsIgnoreCase(order)) {
return page.setAsc(orderField);
} else {
return page.setDesc(orderField);
}
}
//默认排序
if (isAsc) {
page.setAsc(defaultOrderField);
} else {
page.setDesc(defaultOrderField);
}
return page;
}
}

6.5 Redis 缓存

缓存大家都很熟悉,但能否灵活运用,就不一定了。一般设计缓存架构时,我们需要考虑如下几个问 题:

  1. 查询数据的时候,尽量减少DB查询,DB主要负责写数据

  2. 尽量不使用 LEFT JOIN 等关联查询,缓存命中率不高,还浪费内存

  3. 多使用单表查询,缓存命中率最高

  4. 数据库 insert 、 update 、 delete 时,同步更新缓存数据

  5. 合理运用Redis数据结构,也许有质的飞跃

  6. 对于访问量不大的项目,使用缓存只会增加项目的复杂度

本系统采用Redis作为缓存,并可配置是否开启redis缓存,主要还是通过Spring AOP实现的,配置如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
redis:
database: 0
host: localhost
port: 6379
password: # 密码(默认为空)
timeout: 6000ms # 连接超时时长(毫秒)
jedis:
pool:
max-active: 1000 # 连接池最大连接数(使用负值表示没有限制)
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
max-idle: 10 # 连接池中的最大空闲连接
min-idle: 5 # 连接池中的最小空闲连接
renren:
redis:
open: false #是否开启redis缓存 true开启 false关闭

本项目中,使用Redis服务的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class SysConfigServiceImpl implements SysConfigService {
@Autowired
private SysConfigDao sysConfigDao;
@Autowired
private SysConfigRedis sysConfigRedis;

@Override
@Transactional
public void save(SysConfigEntity config) {
sysConfigDao.save(config);
sysConfigRedis.saveOrUpdate(config);
}

@Override
@Transactional
public void update(SysConfigEntity config) {
sysConfigDao.update(config);
sysConfigRedis.saveOrUpdate(config);
}

@Override
@Transactional
public void updateValueByKey(String key, String value) {
sysConfigDao.updateValueByKey(key, value);
sysConfigRedis.delete(key);
}

@Override
@Transactional
public void deleteBatch(Long[] ids) {
sysConfigDao.deleteBatch(ids);
for (Long id : ids) {
SysConfigEntity config = queryObject(id);
sysConfigRedis.delete(config.getKey());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

@Component
public class SysConfigRedis {
@Autowired
private RedisUtils redisUtils;

public void saveOrUpdate(SysConfigEntity config) {
if (config == null) {
return;
}
String key = RedisKeys.getSysConfigKey(config.getKey());
redisUtils.set(key, config);
}

public void delete(String configKey) {
String key = RedisKeys.getSysConfigKey(configKey);
redisUtils.delete(key);
}

public SysConfigEntity get(String configKey) {
String key = RedisKeys.getSysConfigKey(configKey);
return redisUtils.get(key, SysConfigEntity.class);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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

@Component
public class RedisUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ValueOperations<String, String> valueOperations;
@Autowired
private HashOperations<String, String, Object> hashOperations;
@Autowired
private ListOperations<String, Object> listOperations;
@Autowired
private SetOperations<String, Object> setOperations;
@Autowired
private ZSetOperations<String, Object> zSetOperations;
/**
* 默认过期时长,单位:秒
*/
public final static long DEFAULT_EXPIRE = 60 * 60 * 24;
/**
* 不设置过期时长
*/
public final static long NOT_EXPIRE = -1;
private final static Gson gson = new Gson();

public void set(String key, Object value, long expire) {
valueOperations.set(key, toJson(value));
if (expire != NOT_EXPIRE) {
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
}

public void set(String key, Object value) {
set(key, value, DEFAULT_EXPIRE);
}

public <T> T get(String key, Class<T> clazz, long expire) {
String value = valueOperations.get(key);
if (expire != NOT_EXPIRE) {
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
return value == null ? null : fromJson(value, clazz);
}

public <T> T get(String key, Class<T> clazz) {
return get(key, clazz, NOT_EXPIRE);
}

public String get(String key, long expire) {
String value = valueOperations.get(key);
if (expire != NOT_EXPIRE) {
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
return value;
}

public String get(String key) {
return get(key, NOT_EXPIRE);
}

public void delete(String key) {
redisTemplate.delete(key);
}

/**
* Object转成JSON数据
*/
private String toJson(Object object) {
if (object instanceof Integer || object instanceof Long || object instanceof Float ||
object instanceof Double || object instanceof Boolean || object instanceof St
ring) {
return String.valueOf(object);
}
return gson.toJson(object);
}

/**
* JSON数据,转成Object
*/
private <T> T fromJson(String json, Class<T> clazz) {
return gson.fromJson(json, clazz);
}
}

大家可能会有疑问,认为这个项目必须要配置Redis缓存,不然会报错,因为有操作Redis的代码,其实不 然,通过Spring AOP,我们可以控制,是否真的使用Redis,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

@Aspect
@Component
public class RedisAspect {
private Logger logger = LoggerFactory.getLogger(getClass());
/**
* 是否开启redis缓存 true开启 false关闭
*/
@Value("${renren.redis.open: false}")
private boolean open;

@Around("execution(* io.renren.common.utils.RedisUtils.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
Object result = null;
if (open) {
try {
result = point.proceed();
} catch (Exception e) {
logger.error("redis error", e);
throw new RRException("Redis服务异常");
}
}
return result;
}
}

6.6 异常处理机制

本项目通过RRException异常类,抛出自定义异常,RRException继承RuntimeException,不能继承 Exception,如果继承Exception,则Spring事务不会回滚。

RRException代码如下所示:

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
public class RRException extends RuntimeException {
private static final long serialVersionUID = 1L;
private String msg;
private int code = 500;

public RRException(String msg) {
super(msg);
this.msg = msg;
}

public RRException(String msg, Throwable e) {
super(msg, e);
this.msg = msg;
}

public RRException(String msg, int code) {
super(msg);
this.msg = msg;
this.code = code;
}

public RRException(String msg, int code, Throwable e) {
super(msg, e);
this.msg = msg;
this.code = code;
}

public String getMsg() {
return msg;
}

public void setMsg(String msg) {
this.msg = msg;
}

public int getCode() {
return code;
}

public void setCode(int code) {
this.code = code;
}
}

如何处理抛出的异常呢,我们定义了RRExceptionHandler类,并加上注解@RestControllerAdvice,就可以处理所有抛出的异常,并返回JSON数据。@RestControllerAdvice是由@ControllerAdvice、@ResponseBody注解组合而来的,可以查找@ControllerAdvice相关的资料,理解@ControllerAdvice注解 的使用。

RRExceptionHandler代码如下所示:

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

@RestControllerAdvice
public class RRExceptionHandler {
private Logger logger = LoggerFactory.getLogger(getClass());

/**
* 处理自定义异常
*/
@ExceptionHandler(RRException.class)
public R handleRRException(RRException e) {
R r = new R();
r.put("code", e.getCode());
r.put("msg", e.getMessage());
return r;
}

@ExceptionHandler(DuplicateKeyException.class)
public R handleDuplicateKeyException(DuplicateKeyException e) {
logger.error(e.getMessage(), e);
return R.error("数据库中已存在该记录");
}

@ExceptionHandler(AuthorizationException.class)
public R handleAuthorizationException(AuthorizationException e) {
logger.error(e.getMessage(), e);
return R.error("没有权限,请联系管理员授权");
}

@ExceptionHandler(Exception.class)
public R handleException(Exception e) {
logger.error(e.getMessage(), e);
return R.error();
}
}

6.7 后端效验机制

本项目,后端效验使用的是Hibernate Validator校验框架,且自定义ValidatorUtils工具类,用来效验数 据。

Hibernate Validator官方文档: http://docs.jboss.org/hibernate/validator/5.4/reference/en-US/html_single/
ValidatorUtils代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ValidatorUtils {
private static Validator validator;

static {
validator = Validation.buildDefaultValidatorFactory().getValidator();
}

/**
* 校验对象
* @param object 待校验对象
* @param groups 待校验的组
* @throws RRException 校验不通过,则报RRException异常
*/
public static void validateEntity(Object object, Class<?>... groups) throws RRException {
Set<ConstraintViolation<Object>> constraintViolations = validator.validate(object, groups);
if (!constraintViolations.isEmpty()) {
ConstraintViolation<Object> constraint = (ConstraintViolation<Object>) constraintV
iolations.iterator().next();
throw new RRException(constraint.getMessage());
}
}
}

使用案例:

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

@RestController
@RequestMapping("/sys/user")
public class SysUserController extends AbstractController {
/**
* 保存用户
*/
@SysLog("保存用户")
@RequestMapping("/save")
@RequiresPermissions("sys:user:save")
public R save(@RequestBody SysUserEntity user) {
//保存用户时,效验SysUserEntity里,带有AddGroup注解的属性
ValidatorUtils.validateEntity(user, AddGroup.class);
user.setCreateUserId(getUserId());
sysUserService.save(user);
return R.ok();
}

/**
* 修改用户
*/
@SysLog("修改用户")
@RequestMapping("/update")
@RequiresPermissions("sys:user:update")
public R update(@RequestBody SysUserEntity user) {
//修改用户时,效验SysUserEntity里,带有UpdateGroup注解的属性
ValidatorUtils.validateEntity(user, UpdateGroup.class);
user.setCreateUserId(getUserId());
sysUserService.update(user);
return R.ok();
}
}
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
public class SysUserEntity implements Serializable {
/**
* 用户ID
*/
private Long userId;
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空", groups = {AddGroup.class, UpdateGroup.class})
private String username;
/**
* 密码
*/
@NotBlank(message = "密码不能为空", groups = AddGroup.class)
private String password;
/**
* 盐
*/
private String salt;
/**
* 邮箱
*/
@NotBlank(message = "邮箱不能为空", groups = {AddGroup.class, UpdateGroup.class})
@Email(message = "邮箱格式不正确", groups = {AddGroup.class, UpdateGroup.class})
private String email;
/**
* 手机号
*/
private String mobile;
/**
* 状态
0:禁用
1:正常
*/
private Integer status;
/**
* 角色ID列表
*/
private List<Long> roleIdList;
/**
* 创建者ID
*/
private Long createUserId;
/**
* 创建时间
*/
private Date createTime;
}

通过分析上面的代码,我们来理解Hibernate Validator校验框架的使用。 其中,username属性,表示保存或修改用户时,都会效验username属性;而password属性,表示只有保存用户时,才会效验password属性,也就是说,修改用户时,password可以不填写,允许为空。

如果不指定属性的groups,则默认属于javax.validation.groups.Default.class分组,可以通过ValidatorUtils.validateEntity(user)进行效验。

6.8 系统日志

系统日志是通过Spring AOP实现的,我们自定义了注解 @SysLog ,且只能在方法上使用,如下所示:

1
2
3
4
5
6
7

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
String value() default "";
}

下面是自定义注解 @SysLog 的使用方式,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

@RestController
@RequestMapping("/sys/user")
public class SysUserController extends AbstractController {
@SysLog("保存用户")
@RequestMapping("/save")
@RequiresPermissions("sys:user:save")
public R save(@RequestBody SysUserEntity user) {
ValidatorUtils.validateEntity(user, AddGroup.class);
user.setCreateUserId(getUserId());
sysUserService.save(user);
return R.ok();
}
}

我们可以发现,只需要在保存日志的请求方法上,加上 @SysLog 注解,就可以把日志保存到数据库里了。 具体是在哪里把数据保存到数据库里的呢,我们定义了 SysLogAspect 处理类,就是来干这事的,如下所 示:

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
/**
* 系统日志,切面处理类
*/
@Aspect
@Component
public class SysLogAspect {
@Autowired
private SysLogService sysLogService;

@Pointcut("@annotation(io.renren.common.annotation.SysLog)")
public void logPointCut() {
}

@Around("logPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
long beginTime = System.currentTimeMillis();
//执行方法
Object result = point.proceed();
//执行时长(毫秒)
long time = System.currentTimeMillis() - beginTime;
//保存日志
saveSysLog(point, time);
return result;
}

private void saveSysLog(ProceedingJoinPoint joinPoint, long time) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
SysLogEntity sysLog = new SysLogEntity();
SysLog syslog = method.getAnnotation(SysLog.class);
if (syslog != null) {
//注解上的描述
sysLog.setOperation(syslog.value());
}
//请求的方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
sysLog.setMethod(className + "." + methodName + "()");
//请求的参数
Object[] args = joinPoint.getArgs();
try {
String params = new Gson().toJson(args[0]);
sysLog.setParams(params);
} catch (Exception e) {
}
//获取request
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
//设置IP地址
sysLog.setIp(IPUtils.getIpAddr(request));
//用户名
String username = ((SysUserEntity) SecurityUtils.getSubject().getPrincipal()).getUser
name();
sysLog.setUsername(username);
sysLog.setTime(time);
sysLog.setCreateDate(new Date());
//保存系统日志
sysLogService.save(sysLog);
}
}

SysLogAspect 类定义了一个切入点,请求 @SysLog 注解的方法时,会进入 around方法,把系统日志保存到数据库中。

6.9 添加菜单

菜单管理,主要是对【目录、菜单、按钮】进行动态的新增、修改、删除等操作,方便开发者管理菜单。

上图是拿现有的菜单进行讲解。其中,授权标识与shiro中的注解@RequiresPermissions,定义的授权标识是 一一对应的,如下所示:

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

@RestController
@RequestMapping("/sys/config")
public class SysConfigController extends AbstractController {
@RequestMapping("/list")
@RequiresPermissions("sys:config:list")
public R list(@RequestParam Map<String, Object> params) {
}

@RequestMapping("/info/{id}")
@RequiresPermissions("sys:config:info")
public R info(@PathVariable("id") Long id) {
}

@RequestMapping("/save")
@RequiresPermissions("sys:config:save")
public R save(@RequestBody SysConfigEntity config) {
}

@RequestMapping("/update")
@RequiresPermissions("sys:config:update")
public R update(@RequestBody SysConfigEntity config) {

}

@RequestMapping("/delete")
@RequiresPermissions("sys:config:delete")
public R delete(@RequestBody Long[] ids) {

}

}

6.10 添加角色

管理员权限是通过角色进行管理的,给管理员分配权限时,要先创建好角色。

下面创建了一个【开发角色】,如下图所示:

6.11 添加管理员

本系统默认就创建了admin账号,无需分配任何角色,就拥有最高权限。 一个管理员是可以拥有多个角 色的。

下面创建一个【zhangsan】的管理员账号,并属于【开发角色】,如下所示:

6.12 定时任务模块

本系统使用开源框架Quartz,实现的定时任务,已实现分布式定时任务,可部署多台服务器,不重复执行,以及动态增加、修改、删除、暂停、恢复、立即执行定时任务。 Quartz自带了各数据库的SQL脚本,如果想更改成其他数据库,可参考Quartz相应的SQL脚本。

6.12.1 新增定时任务

新增一个定时任务,其实很简单,只要定义一个普通的Spring Bean即可,如下所示:

1
2
3
4
5
6
7
8
9
@Component("testTask")
public class TestTask implements ITask {
private Logger logger = LoggerFactory.getLogger(getClass());

@Override
public void run(String params) {
logger.debug("TestTask定时任务正在执行,参数为:{}", params);
}
}

如何让Quartz,定时执行testTask里的方法呢?只需要在管理后台,新增一个定时任务即可,如下图所示:

刚才配置的定时任务,每隔 30 分钟,就会调用TestTask的test方法了,是不是很简单啊。

6.12.2 源码分析

Quartz提供了相关的API,我们可以调用API,对Quartz进行增加、修改、删除、暂停、恢复、立即执行等。 本系统中, ScheduleUtils 类就是对Quartz API进行的封装,代码如下所示:

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
public class ScheduleUtils {
private final static String JOB_NAME = "TASK_";

/**
* 获取触发器key
*/
private static TriggerKey getTriggerKey(Long jobId) {
return TriggerKey.triggerKey(JOB_NAME + jobId);
}

/**
* 获取jobKey
*/
private static JobKey getJobKey(Long jobId) {
return JobKey.jobKey(JOB_NAME + jobId);
}

/**
* 获取表达式触发器
*/
public static CronTrigger getCronTrigger(Scheduler scheduler, Long jobId) {
try {
return (CronTrigger) scheduler.getTrigger(getTriggerKey(jobId));
} catch (SchedulerException e) {
throw new RRException("getCronTrigger异常,请检查qrtz开头的表,是否有脏数据", e);
}
}

/**
* 创建定时任务
*/
public static void createScheduleJob(Scheduler scheduler, ScheduleJobEntity scheduleJob) {
try {
//构建job信息
JobDetail jobDetail = JobBuilder.newJob(ScheduleJob.class).withIdentity(getJobKey
(scheduleJob.getJobId())).build();
//表达式调度构建器
CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(scheduleJo
b.getCronExpression())
.withMisfireHandlingInstructionDoNothing();
//按新的cronExpression表达式构建一个新的trigger
CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(getTriggerKey(sche
duleJob.getJobId())).
withSchedule(scheduleBuilder).build();
//放入参数,运行时的方法可以获取
jobDetail.getJobDataMap().put(ScheduleJobEntity.JOB_PARAM_KEY, new Gson().toJson(
scheduleJob));
scheduler.scheduleJob(jobDetail, trigger);
//暂停任务
if (scheduleJob.getStatus() == ScheduleStatus.PAUSE.getValue()) {
pauseJob(scheduler, scheduleJob.getJobId());
}
} catch (SchedulerException e) {
throw new RRException("创建定时任务失败", e);
}
}

/**
* 更新定时任务
*/
public static void updateScheduleJob(Scheduler scheduler, ScheduleJobEntity scheduleJob) {
try {
TriggerKey triggerKey = getTriggerKey(scheduleJob.getJobId());
//表达式调度构建器
CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(scheduleJo
b.getCronExpression())
.withMisfireHandlingInstructionDoNothing();
CronTrigger trigger = getCronTrigger(scheduler, scheduleJob.getJobId());
//按新的cronExpression表达式重新构建trigger
trigger = trigger.getTriggerBuilder().withIdentity(triggerKey).withSchedule(sched
uleBuilder).build();
//参数
trigger.getJobDataMap().put(ScheduleJobEntity.JOB_PARAM_KEY, new Gson().toJson(sc
heduleJob));
scheduler.rescheduleJob(triggerKey, trigger);
//暂停任务
if (scheduleJob.getStatus() == ScheduleStatus.PAUSE.getValue()) {
pauseJob(scheduler, scheduleJob.getJobId());
}
} catch (SchedulerException e) {
throw new RRException("更新定时任务失败", e);
}
}

/**
* 立即执行任务
*/
public static void run(Scheduler scheduler, ScheduleJobEntity scheduleJob) {
try {
//参数
JobDataMap dataMap = new JobDataMap();
dataMap.put(ScheduleJobEntity.JOB_PARAM_KEY, new Gson().toJson(scheduleJob));
scheduler.triggerJob(getJobKey(scheduleJob.getJobId()), dataMap);
} catch (SchedulerException e) {
throw new RRException("立即执行定时任务失败", e);
}
}

/**
* 暂停任务
*/
public static void pauseJob(Scheduler scheduler, Long jobId) {
try {
scheduler.pauseJob(getJobKey(jobId));
} catch (SchedulerException e) {
throw new RRException("暂停定时任务失败", e);
}
}

/**
* 恢复任务
*/
public static void resumeJob(Scheduler scheduler, Long jobId) {
try {
scheduler.resumeJob(getJobKey(jobId));
} catch (SchedulerException e) {
throw new RRException("暂停定时任务失败", e);
}
}

/**
* 删除定时任务
*/
public static void deleteScheduleJob(Scheduler scheduler, Long jobId) {
try {
scheduler.deleteJob(getJobKey(jobId));
} catch (SchedulerException e) {
throw new RRException("删除定时任务失败", e);
}
}
}

以下是几个核心的方法:

  • createScheduleJob【创建定时任务】:在管理后台新增任务时,会调用该方法,把任务添加到Quartz 中,再根据cron表达式,定时执行任务。

  • updateScheduleJob【更新定时任务】:修改任务时,调用该方法,修改Quartz中的任务信息。

  • run【立即执行定时任务】:马上执行一次该任务,只执行一次。

  • pauseJob【暂停定时任务】:这个不是暂停正在执行的任务,而是以后不再执行这个定时任务了。正在 执行的任务,还是照常执行完。

  • resumeJob【恢复定时任务】:这个是针对pauseJob来的,如果任务暂停了,以后都不会再执行,要想再执行,则需要调用resumeJob,使定时任务恢复执行。

  • deleteScheduleJob【删除定时任务】:删除定时任务

其中, createScheduleJobupdateScheduleJob 在启动项目的时候,也会调用,把数据库里,新增或修 改的任务,更新到Quartz中,如下所示:

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

@Service("scheduleJobService")
public class ScheduleJobServiceImpl implements ScheduleJobService {
/**
* 项目启动时,初始化定时器
*/
@PostConstruct
public void init() {
List<ScheduleJobEntity> scheduleJobList = schedulerJobDao.queryList(new HashMap<>());
for (ScheduleJobEntity scheduleJob : scheduleJobList) {
CronTrigger cronTrigger = ScheduleUtils.getCronTrigger(scheduler, scheduleJob.getJobId());
//如果不存在,则创建
if (cronTrigger == null) {
ScheduleUtils.createScheduleJob(scheduler, scheduleJob);
} else {
ScheduleUtils.updateScheduleJob(scheduler, scheduleJob);
}
}
}
}

大家是不是还有疑问呢,怎么就能定时执行,刚才在管理后台新增的任务testTask呢? 下面我们再来分析 下 createScheduleJob 方法,创建定时任务的时候,要调用该方法,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
//构建一个新的定时任务,JobBuilder.newJob()只能接受Job类型的参数
//把ScheduleJob.class作为参数传进去,ScheduleJob继承QuartzJobBean,而QuartzJobBean实现了Job接口
JobDetail jobDetail=JobBuilder.newJob(ScheduleJob.class).withIdentity(getJobKey(scheduleJob.getJobId())).build();
//构建cron,定时任务的周期
CronScheduleBuilder scheduleBuilder=CronScheduleBuilder.cronSchedule(scheduleJob.getCronExpression()).withMisfireHandlingInstructionDoNothing();
//根据cron,构建一个CronTrigger
CronTrigger trigger=TriggerBuilder.newTrigger().withIdentity(getTriggerKey(scheduleJob.getJobId())).withSchedule(scheduleBuilder).build();
//放入参数,运行时的方法可以获取
jobDetail.getJobDataMap().put(ScheduleJobEntity.JOB_PARAM_KEY,new Gson().toJson(scheduleJob));
//把任务添加到Quartz中
scheduler.scheduleJob(jobDetail,trigger);

把任务添加到 Quartz 后,等cron定义的时间周期到了,就会执行 ScheduleJob 类的 executeInternal 方 法, ScheduleJob 代码如下所示:

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
public class ScheduleJob extends QuartzJobBean {
private Logger logger = LoggerFactory.getLogger(getClass());

@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
ScheduleJobEntity scheduleJob = (ScheduleJobEntity) context.getMergedJobDataMap()
.get(ScheduleJobEntity.JOB_PARAM_KEY);
//获取spring bean
ScheduleJobLogService scheduleJobLogService = (ScheduleJobLogService) SpringContextUt
ils.getBean("scheduleJobLogService");
//数据库保存执行记录
ScheduleJobLogEntity log = new ScheduleJobLogEntity();
log.setJobId(scheduleJob.getJobId());
log.setBeanName(scheduleJob.getBeanName());
log.setParams(scheduleJob.getParams());
log.setCreateTime(new Date());
//任务开始时间
long startTime = System.currentTimeMillis();
try {
//执行任务
logger.info("任务准备执行,任务ID:" + scheduleJob.getJobId());

Object target = SpringContextUtils.getBean(scheduleJob.getBeanName());
Method method = target.getClass().getDeclaredMethod("run", String.class);
method.invoke(target, scheduleJob.getParams());
//任务执行总时长
long times = System.currentTimeMillis() - startTime;
log.setTimes((int) times);
//任务状态 0 :成功 1 :失败

log.setStatus(0);
logger.info("任务执行完毕,任务ID:" + scheduleJob.getJobId() + " 总共耗时:" + tim es + "毫秒");
} catch (Exception e) {
logger.error("任务执行失败,任务ID:" + scheduleJob.getJobId(), e);
//任务执行总时长
long times = System.currentTimeMillis() - startTime;
log.setTimes((int) times);
//任务状态 0 :成功 1 :失败
log.setStatus(1);
log.setError(StringUtils.substring(e.toString(), 0, 2000));
} finally {
scheduleJobLogService.save(log);
}
}
}

6.13 云存储模块

图片、文件上传,使用的是七牛、阿里云、腾讯云的存储服务,不能上传到本地服务器。上传到本地 服务器,不利于维护,访问速度慢等缺点,所以推荐使用云存储服务。

6.13.1 七牛的配置

如果没有七牛账号,则需要注册七牛账号,才能进行配置,下面演示注册七牛账号并配置,步骤如 下:

  1. [注册七牛账号][34],并登录后,再创建七牛空间,如下图:

  1. 进入管理后端,填写七牛配置信息,如下图:

必填项有域名、AccessKey、SecretKey、空间名。其中,空间名就是才创建的空间名 ios-app ,填进去就可 以了。域名、AccessKey、SecretKey可以通过下图找到:

6.13.2 阿里云的配置

  • 进入管理后端,填写阿里云配置信息,如下图:

  • 进去阿里云管理后台,并创建Bucket,如下图:

  • 通过下面的界面,可以找到域名、BucketName、EndPoint

  • 通过下面的界面,可以找到AccessKeyId、AccessKeySecret

6.13.3 腾讯云的配置

  • 进入管理后端,填写腾讯云配置信息,如下图:

  • 进去腾讯云管理后台,并创建Bucket,如下图:

  • 通过下面的界面,可以找到域名、BucketName、Bucket所属地区

  • 通过下面的界面,可以找到AppId、SecretId、SecretKey

6.13.4 源码分析

本项目的文件上传,使用的是七牛、阿里云、腾讯云,则需要引入他们的SDK,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
<version>${qiniu.version}</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>${aliyun.oss.version}</version>
</dependency>
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>${qcloud.cos.version}</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>

定义抽象类 CloudStorageService ,用来声明上传的公共接口,如下所示:

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
public abstract class CloudStorageService {
/**
* 云存储配置信息
*/
CloudStorageConfig config;

/**
* 文件路径
*
* @param prefix 前缀
* @param suffix 后缀
* @return 返回上传路径
*/
public String getPath(String prefix, String suffix) {
//生成uuid
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
//文件路径
String path = DateUtils.format(new Date(), "yyyyMMdd") + "/" + uuid;
if (StringUtils.isNotBlank(prefix)) {
path = prefix + "/" + path;
}
return path + suffix;
}

/**
* 文件上传
*
* @param data 文件字节数组
* @param path 文件路径,包含文件名
* @return 返回http地址
*/
public abstract String upload(byte[] data, String path);

/**
* 文件上传
*
* @param data 文件字节数组
* @param suffix 后缀
* @return 返回http地址
*/
public abstract String uploadSuffix(byte[] data, String suffix);

/**
* 文件上传
*
* @param inputStream 字节流
* @param path 文件路径,包含文件名
* @return 返回http地址
*/
public abstract String upload(InputStream inputStream, String path);

/**
* 文件上传
*
* @param inputStream 字节流
* @param suffix 后缀
* @return 返回http地址
*/
public abstract String uploadSuffix(InputStream inputStream, String suffix);
}
  • 七牛上传的实现,只需继承 CloudStorageService ,并实现相应的上传接口,如下所示:
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 com.qiniu.common.Zone;
import com.qiniu.http.Response;
import com.qiniu.storage.Configuration;
import com.qiniu.storage.UploadManager;
import com.qiniu.util.Auth;
import io.renren.common.exception.RRException;
import org.apache.commons.io.IOUtils;

import java.io.IOException;
import java.io.InputStream;

/**
* 七牛云存储
*
* @author Mark sunlightcs@gmail.com
*/
public class QiniuCloudStorageService extends CloudStorageService {
private UploadManager uploadManager;
private String token;

public QiniuCloudStorageService(CloudStorageConfig config) {
this.config = config;
//初始化
init();
}

private void init() {
uploadManager = new UploadManager(new Configuration(Zone.autoZone()));
token = Auth.create(config.getQiniuAccessKey(), config.getQiniuSecretKey()).
uploadToken(config.getQiniuBucketName());
}

@Override
public String upload(byte[] data, String path) {
try {
Response res = uploadManager.put(data, path, token);
if (!res.isOK()) {
throw new RuntimeException("上传七牛出错:" + res.toString());
}
} catch (Exception e) {
throw new RRException("上传文件失败,请核对七牛配置信息", e);
}
return config.getQiniuDomain() + "/" + path;
}

@Override
public String upload(InputStream inputStream, String path) {
try {
byte[] data = IOUtils.toByteArray(inputStream);
return this.upload(data, path);
} catch (IOException e) {
throw new RRException("上传文件失败", e);
}
}

@Override
public String uploadSuffix(byte[] data, String suffix) {
return upload(data, getPath(config.getQiniuPrefix(), suffix));
}

@Override
public String uploadSuffix(InputStream inputStream, String suffix) {
return upload(inputStream, getPath(config.getQiniuPrefix(), suffix));
}
}
  • 阿里云上传的实现,只需继承 CloudStorageService ,并实现相应的上传接口,如下所示
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
import com.aliyun.oss.OSSClient;
import io.renren.common.exception.RRException;

import java.io.ByteArrayInputStream;
import java.io.InputStream;

/**
* 阿里云存储
*
* @author Mark sunlightcs@gmail.com
*/
public class AliyunCloudStorageService extends CloudStorageService {
private OSSClient client;

public AliyunCloudStorageService(CloudStorageConfig config) {
this.config = config;
//初始化
init();
}

private void init() {
client = new OSSClient(config.getAliyunEndPoint(), config.getAliyunAccessKeyId(),
config.getAliyunAccessKeySecret());
}

@Override
public String upload(byte[] data, String path) {
return upload(new ByteArrayInputStream(data), path);
}

@Override
public String upload(InputStream inputStream, String path) {
try {
client.putObject(config.getAliyunBucketName(), path, inputStream);
} catch (Exception e) {
throw new RRException("上传文件失败,请检查配置信息", e);
}
return config.getAliyunDomain() + "/" + path;
}

@Override
public String uploadSuffix(byte[] data, String suffix) {
return upload(data, getPath(config.getAliyunPrefix(), suffix));
}

@Override
public String uploadSuffix(InputStream inputStream, String suffix) {
return upload(inputStream, getPath(config.getAliyunPrefix(), suffix));
}
}
  • 腾讯云上传的实现,只需继承 CloudStorageService ,并实现相应的上传接口,如下所示:
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
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.request.UploadFileRequest;
import com.qcloud.cos.sign.Credentials;
import io.renren.common.exception.RRException;
import net.sf.json.JSONObject;
import org.apache.commons.io.IOUtils;

import java.io.IOException;
import java.io.InputStream;

/**
* 腾讯云存储
*
* @author Mark sunlightcs@gmail.com
*/
public class QcloudCloudStorageService extends CloudStorageService {
private COSClient client;

public QcloudCloudStorageService(CloudStorageConfig config) {
this.config = config;
//初始化
init();
}

private void init() {
Credentials credentials = new Credentials(config.getQcloudAppId(), config.getQcloudSe
cretId(),
config.getQcloudSecretKey());
//初始化客户端配置
ClientConfig clientConfig = new ClientConfig();
//设置bucket所在的区域,华南:gz 华北:tj 华东:sh
clientConfig.setRegion(config.getQcloudRegion());
client = new COSClient(clientConfig, credentials);
}

@Override
public String upload(byte[] data, String path) {
//腾讯云必需要以"/"开头
if (!path.startsWith("/")) {
path = "/" + path;
}
//上传到腾讯云
UploadFileRequest request = new UploadFileRequest(config.getQcloudBucketName(), path,
data);
String response = client.uploadFile(request);
JSONObject jsonObject = JSONObject.fromObject(response);
if (jsonObject.getInt("code") != 0) {
throw new RRException("文件上传失败," + jsonObject.getString("message"));
}
return config.getQcloudDomain() + path;
}

@Override
public String upload(InputStream inputStream, String path) {
try {
byte[] data = IOUtils.toByteArray(inputStream);
return this.upload(data, path);
} catch (IOException e) {
throw new RRException("上传文件失败", e);
}
}

@Override
public String uploadSuffix(byte[] data, String suffix) {
return upload(data, getPath(config.getQcloudPrefix(), suffix));
}

@Override
public String uploadSuffix(InputStream inputStream, String suffix) {
return upload(inputStream, getPath(config.getQcloudPrefix(), suffix));
}
}
  • 对外提供了OSSFactory工厂,可方便业务的调用,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public final class OSSFactory {
private static SysConfigService sysConfigService;

static {
OSSFactory.sysConfigService = (SysConfigService) SpringContextUtils.getBean("sysConfi
gService");
}

public static CloudStorageService build() {
//获取云存储配置信息
CloudStorageConfig config = sysConfigService.getConfigObject(ConfigConstant.CLOUD_STO
RAGE_CONFIG_KEY, CloudStorageConfig.class);
if (config.getType() == Constant.CloudService.QINIU.getValue()) {
return new QiniuCloudStorageService(config);
} else if (config.getType() == Constant.CloudService.ALIYUN.getValue()) {
return new AliyunCloudStorageService(config);
} else if (config.getType() == Constant.CloudService.QCLOUD.getValue()) {
return new QcloudCloudStorageService(config);
}
return null;
}
}
  • 文件上传的例子,如下: @RequestMapping(“/upload”)
1
2
3
4
5
6
7
8
9
@RequestMapping("/upload")
public R upload(@RequestParam("file") MultipartFile file)throws Exception{
if(file.isEmpty()){
throw new RRException("上传文件不能为空");
}
//上传文件,并返回文件的http地址
String url=OSSFactory.build().upload(file.getBytes());
}
}

6.14 APP 模块

APP模块,是针对APP使用的,如IOS、Android等,主要是解决用户认证的问题。

6.14.1 APP 的使用

APP的设计思路:用户通过APP,输入手机号、密码登录后,系统会生成与登录用户一一对应的 token,用户调用需要登录的接口时,只需把token传过来,服务端就知道是谁在访问接口,token如果过期,则拒绝访问,从而保证系统的安全性。

使用很简单,看看下面的例子,就会使用了。仔细观察,我们会发现,有 2 个自定义的注解。其中, @LoginUser注解是获取当前登录用户的信息,有哪些信息,下面会分析的。@Login注解则是需要用户认证,没有登录的用户,不能访问该接口。

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
import io.renren.modules.app.annotation.Login;
import io.renren.modules.app.annotation.LoginUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/app")
public class ApiTestController {
/**
* 获取用户信息
*/
@Login
@GetMapping("userInfo")
public R userInfo(@LoginUser UserEntity user) {
return R.ok().put("user", user);
}

/**
* 获取用户ID
*/
@Login
@GetMapping("userId")
public R userInfo(@RequestAttribute("userId") Integer userId) {
return R.ok().put("userId", userId);
}

/**
* 忽略Token验证测试
*/
@GetMapping("notToken")
public R notToken() {
return R.ok().put("msg", "无需token也能访问。。。");
}
}

6.14.2 源码分析

  • 我们先来看看,APP用户登录的时候,都干了那些事情,如下所示:
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

@RestController
@RequestMapping("/app")
@Api("APP登录接口")
public class ApiLoginController {
@Autowired
private UserService userService;
@Autowired
private JwtUtils jwtUtils;

/**
* 登录
*/
@PostMapping("login")
@ApiOperation("登录")
public R login(@RequestBody LoginForm form) {
//表单校验
ValidatorUtils.validateEntity(form);
//用户登录
long userId = userService.login(form);
//生成token
String token = jwtUtils.generateToken(userId);
Map<String, Object> map = new HashMap<>();
map.put("token", token);
map.put("expire", jwtUtils.getExpire());
return R.ok(map);
}
}
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
/**
* jwt工具类
*/
@ConfigurationProperties(prefix = "renren.jwt")
@Component
public class JwtUtils {
private Logger logger = LoggerFactory.getLogger(getClass());
private String secret;
private long expire;
private String header;

/**
* 生成jwt token
*/
public String generateToken(long userId) {
Date nowDate = new Date();
//过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId + "")
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}

public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
logger.debug("validate is token error ", e);
return null;
}
}

/**
* token是否过期
*
* @return true:过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}

public String getSecret() {
return secret;
}

public void setSecret(String secret) {
this.secret = secret;
}

public long getExpire() {
return expire;
}

public void setExpire(long expire) {
this.expire = expire;
}

public String getHeader() {
return header;
}

public void setHeader(String header) {
this.header = header;
}
}

我们从上面的代码,可以看到,用户每次登录的时候,都会生成一个唯一的token,这个token是通过jwt生成 的。

  • APP模块的核心配置,如下所示:
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
import io.renren.modules.api.interceptor.AuthorizationInterceptor;
import io.renren.modules.api.resolver.LoginUserHandlerMethodArgumentResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Autowired
private AuthorizationInterceptor authorizationInterceptor;
@Autowired
private LoginUserHandlerMethodArgumentResolver loginUserHandlerMethodArgumentResolver;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/app/**");
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(loginUserHandlerMethodArgumentResolver);
}
}

我们可以看到,配置了个Interceptor,用来拦截 /app 开头的所有请求,拦截后,会到 AuthorizationInterceptor类preHandle方法处理。只有以 /app开头的请求,API模块认证才会起作用,如果要以/api 开头,则需要修改此处。还配置了argumentResolver,别忽略了啊,下面会讲解。

温馨提示,别忘了配置shiro,不然会被shiro拦截掉的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

@Configuration
public class ShiroConfig {
@Bean("shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
//部分代码省略...
Map<String, String> filterMap = new LinkedHashMap<>();
//让shiro放过,以/app开头的请求
filterMap.put("/app/**", "anon");
//部分代码省略...
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
}
  • 分析AuthorizationInterceptor类,我们可以发现,拦截 /app 开头的请求后,都干了些什么,如下所示:
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
import io.jsonwebtoken.Claims;
import io.renren.common.exception.RRException;
import io.renren.modules.app.utils.JwtUtils;
import io.renren.modules.app.annotation.Login;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
* 权限(Token)验证
*/
@Component
public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
@Autowired
private JwtUtils jwtUtils;
public static final String USER_KEY = "userId";

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object
handler) throws Exception {
Login annotation;
if (handler instanceof HandlerMethod) {
annotation = ((HandlerMethod) handler).getMethodAnnotation(Login.class);
} else {
return true;
}
if (annotation == null) {
return true;
}
//获取用户凭证
String token = request.getHeader(jwtUtils.getHeader());
if (StringUtils.isBlank(token)) {
token = request.getParameter(jwtUtils.getHeader());
}
//凭证为空
if (StringUtils.isBlank(token)) {
throw new RRException(jwtUtils.getHeader() + "不能为空", HttpStatus.UNAUTHORIZED.v
alue());
}
Claims claims = jwtUtils.getClaimByToken(token);
if (claims == null || jwtUtils.isTokenExpired(claims.getExpiration())) {
throw new RRException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHO
RIZED.value());
}
//设置userId到request里,后续根据userId,获取用户信息
request.setAttribute(USER_KEY, Long.parseLong(claims.getSubject()));
return true;
}
}

我们可以发现,进入 /app 请求的接口之前,会判断请求的接口,是否加了@Login注解(需要token认证),如果没有@Login注解,则不验证token,可以直接访问接口。如果有@Login注解,则需要验证token的正确性,并把userId放到request的USER_KEY里,后续会用到。

  • 此时,@Login注解的作用,相信大家都明白了。再看看下面的代码,加了@LoginUser注解后,user对象里,就变成当前登录用户的信息,这是什么时候设置进去的呢?
1
2
3
4
5
6
7
/**
* 获取用户信息
*/
@GetMapping("userInfo")
public R userInfo(@LoginUser UserEntity user){
return R.ok().put("user",user);
}
  • 设置user对象进去,其实是在LoginUserHandlerMethodArgumentResolver里干的,LoginUserHandlerMethodArgumentResolver是我们自定义的参数转换器,只要实现HandlerMethodArgumentResolver接口即可,代码如下所示:
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
import io.renren.modules.api.annotation.LoginUser;
import io.renren.modules.api.entity.UserEntity;
import io.renren.modules.api.interceptor.AuthorizationInterceptor;
import io.renren.modules.api.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
public class LoginUserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
private UserService userService;

@Override
public boolean supportsParameter(MethodParameter parameter) {
//如果方法的参数是UserEntity,且参数前面有@LoginUser注解,则进入resolveArgument方法,进行
处理
return parameter.getParameterType().isAssignableFrom(UserEntity.class) && parameter.h
asParameterAnnotation(LoginUser.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container,
NativeWebRequest request, WebDataBinderFactory factory) thr

ows Exception

{
//获取用户ID,之前设置进去的,还有印象吧
Object object = request.getAttribute(AuthorizationInterceptor.USER_KEY, RequestAttrib
utes.SCOPE_REQUEST);
if (object == null) {
return null;
}
//通过userId,获取用户信息
UserEntity user = userService.queryObject((Long) object);
//把当前用户信息,设置到UserEntity参数的user对象里
return user;
}
}

第 7 章 生产环境部署

部署项目前,需要准备JDK8、Maven、MySQL5.5+环境,参考开发环境搭建。

7.1 jar 包部署

7.2 docker 部署

7.3 集群部署

7.1 jar 包部署

Spring Boot项目,推荐打成jar包的方式,部署到服务器上。

  • Spring Boot内置了Tomcat,可配置Tomcat的端口号、初始化线程数、最大线程数、连接超时时长、https 等等,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server:
tomcat:
uri-encoding: UTF-8
max-threads: 1000
min-spare-threads: 30
port: 8080
connection-timeout: 5000ms
servlet:
context-path: /renren-fast
session:
cookie:
http-only: true
ssl:
key-store: classpath:.keystore
key-store-type: JKS
key-password: 123456
key-alias: tomcat
  • 当然,还可以指定jvm的内存大小,如下所示:
1
java -Xms4g -Xmx4g -Xmn1g -server -jar renren-fast.jar
  • 在windows下部署,只需打开cmd窗口,输入如下命令:
1
java -jar renren-fast.jar --spring.profiles.active=prod
  • 在Linux下部署,只需输入如下命令,即可在Linux后台运行:
1
nohup java -jar renren-fast.jar --spring.profiles.active=prod > renren.log &
  • 在Linux环境下,我们一般可以创建shell脚本,用于重启项目,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#创建启动的shell脚本
[root@renren renren-fast]# vim start.sh
#!/bin/sh

process=`ps -fe|grep "renren-fast.jar" |grep -ivE "grep|cron" |awk '{print $2}'`
if [ !$process ];
then
echo "stop erp process $process ....."
kill -9 $process
sleep 1
fi

echo "start erp process....."
nohup java -Dspring.profiles.active=prod -jar renren-fast.jar --server.port=8080 --server.servlet.context-path=/renren-fast 2>&1 | cronolog log.%Y-%m-%d.out >> /dev/null &

echo "start erp success!"

#通过shell脚本启动项目
[root@renren renren-fast]# yum install -y cronolog
[root@renren renren-fast]# chomd +x start.sh
[root@renren renren-fast]# ./start.sh

7.2 docker 部署

安装docker环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#安装docker
[root@mark ~]# curl -sSL https://get.docker.com/ | sh

#启动docker
[root@mark ~]# service docker start

#查看docker版本信息
[root@mark ~]# docker version
Client:
Version: 17.07.0-ce
API version: 1.31
Go version: go1.8.3
Git commit: 8784753
Built: Tue Aug 29 17:42:01 2017
OS/Arch: linux/amd64
Server:
Version: 17.07.0-ce
API version: 1.31 (minimum version 1.12)
Go version: go1.8.3
Git commit: 8784753
Built: Tue Aug 29 17:43:23 2017
OS/Arch: linux/amd64
Experimental: false
  • 还需要准备java、maven环境,请自行安装
  • 通过maven插件,构建docker镜像

打包并构建项目镜像

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
[root@mark renren-fast]# mvn clean package docker:build
#省略打包log...
[INFO] Building image renren/fast
Step 1/6 : FROM java:8
---> d23bdf5b1b1b
Step 2/6 : EXPOSE 8080
---> Using cache
---> 8e33aadb2c18
Step 3/6 : VOLUME /tmp
---> Using cache
---> c5dc0c509062
Step 4/6 : ADD renren-fast-1.2.0.jar /app.jar
---> 831bc3ca84bc
Step 5/6 : RUN bash -c 'touch /app.jar'
---> Running in fe3ef9343e4c
---> b3d6dd6fc297
Removing intermediate container fe3ef9343e4c
Step 6/6 : ENTRYPOINT java -jar /app.jar
---> Running in 89adce4ae167
---> a4ae60970a77
Removing intermediate container 89adce4ae167
ProgressMessage{id=null, status=null, stream=null, error=null, progress=null, progressDetail=
null}
Successfully built a4ae60970a77
Successfully tagged renren/fast:latest


# 查看镜像
[root@mark renren-fast]# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
renren/fast latest a4ae60970a77 14 seconds ago 714MB
java 8 d23bdf5b1b1b 7 months ago 643MB
  • 安装docker-compose,用来管理容器
1
2
3
4
5
6
7
8
9
10
11
#下载地址:https://github.com/docker/compose/releases
#下载docker-compose
[root@mark renren-fast]# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
#增加可执行权限
[root@mark renren-fast]# chmod +x /usr/local/bin/docker-compose
#查看版本信息
[root@mark renren-fast]# docker-compose version
docker-compose version 1.16.1, build 6d1ac21
docker-py version: 2.5.1
CPython version: 2.7.13
OpenSSL version: OpenSSL 1.0.1t 3 May 2016

如果下载不了,可以用迅雷将https://github.com/docker/compose/releases/download/1.16.1/docker-compose-
Linux-x86_64下载到本地,再上传到服务器

  • 通过docker-compose,启动项目,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#启动项目
[root@mark renren-fast]# docker-compose up -d
Creating network "renrenfast_default" with the default driver
Creating renrenfast_campus_1 ...
Creating renrenfast_campus_1 ... done

#查看启动的容器
[root@mark renren-fast]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS
NAMES
f4e3fcdd8dd4 renren/fast "java -jar /app.jar" 55 seconds ago Up 3 seconds 0.0.0.0:8080->8080/tcp renrenfast_renren-fast_1



#停掉并删除,docker-compose管理的容器
[root@mark renren-fast]# docker-compose down
Stopping renrenfast_renren-fast_1 ... done
Removing renrenfast_renren-fast_1 ... done
Removing network renrenfast_default

7.3 集群部署

本系统支持集群部署,集群部署,只需启动多个节点,并配置Nginx即可。

  • 配置Nginx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
http {
upstream renren {
server localhost:8080;
server localhost:8081;
}

server {
listen 80;
server_name localhost;
location /renren-fast {
proxy_pass http://renren;
client_max_body_size 1024m;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}
}
}
l

超详细从0开始搭建 Spring Boot 项目

这个项目,我是打算作为种子项目的,作为种子项目,必须的“开箱即用”,必须要包含大部分 web 开发的相关功能,后期所有的 Spring Boot 项目都是将这个项目拿来,简单修改一下配置,就可以快速开发了。

源码

Github

1.0.0 创建项目

我使用的是 idea

创建项目 img

选择 Spring initializr ,如果点Next一直在转圈,可能是 start.spring.io/ 在国外,访问比较慢。可以科学上网或者,使用自定义的 start.spring.io/ img

主要改以下组织名称、项目名称和项目描述就好了 img

我创建项目的时候, Spring Boot 最新稳定版是 2.1.9 。要用就用最新的!!! 依赖先都不勾选,后期一项一项加

img

项目文件夹名称以及存放位置 img

添加maven镜像

添加maven镜像加快依赖下载速度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<repositories>
<repository>
<name>华为maven仓库</name>
<id>huawei</id>
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
</repository>
</repositories>

<pluginRepositories>
<pluginRepository>
<name>华为maven插件仓库</name>
<id>huawei_plugin</id>
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
</pluginRepository>
</pluginRepositories>

pom 文件

整体 .pom 文件内容如下

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.9.RELEASE</version>
<relativePath/>
</parent>

<groupId>com.wqlm</groupId>
<artifactId>boot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>boot</name>
<description>Spring Boot Demo</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

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

<repositories>
<repository>
<name>华为maven仓库</name>
<id>huawei</id>
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
</repository>
</repositories>

<pluginRepositories>
<pluginRepository>
<name>华为maven插件仓库</name>
<id>huawei_plugin</id>
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
</pluginRepository>
</pluginRepositories>

</project>

依赖结构图

img

番外

观察仔细的人应该发现了,spring-boot-starterspring-boot-starter-test 都没有指定版本,那它们是怎么确定版本的?

参考 为什么 maven 依赖可以不指定版本

1.1.0 添加 web 模块

在 pom 文件中添加 web 模块

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

由于 spring-boot-starter-web 包含 spring-boot-starter img

建议删掉如下 spring-boot-starter 依赖,以保证依赖的干净整洁

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

作用

引入 spring-boot-starter-web 后,我们可以

  • 编写 web 应用
  • 不需要配置容器即可运行 web 应用
  • 对请求参数进行校验
  • 将业务结果对象转换成 josn 返回

依赖结构图

img 从图中我们可以看到 spring-boot-starter-web 引入了几个关键的依赖

  • spring-boot-starter

  • spring-boot-starter-tomcat:spring boot 不需要 tomcat 也能启动就是因为它

  • spring-webmvc

  • spring-web

  • spring-boot-starter-json

    : 有了它,就可以使用 @ResponseBody 返回 json 数据

    • jackson :spring boot 默认的 json 解析工具
  • hibernate-validator

    :提供参数校验的注解,如

    @Range、@Length

    • javax.validation:提供参数校验的注解,如 @NotBlank、@NotNull、@Pattern

关于参数校验请参考 参数校验 Hibernate-Validator

1.2.0 集成 mysql

Spring Boot 集成 mysql 需要 JDBC 驱动mysql 驱动

引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
<!--JDBC-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

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

版本号可以不用填,spring boot 配置了默认的版本号。例如 spring boot 2.1.9.RELEASE 对应的 mysql-connector-java 版本为 8.0.17

配置 mysql

根据 mysql-connector-java 版本不同,配置的内容也有些许差异

1
2
3
4
5
6
7
8
9
10
11
12
13
# mysql-connector-java 6.0.x 以下版本配置
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/boot?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456


# mysql-connector-java 6.0.x 及以上版本配置
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/boot?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=123456

如下图,spring boot 2.1.9.RELEASE 对应的 mysql-connector-java 版本为 8.0.17 img

更多请参考MySQL JDBC 连接

创建示例数据库和表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 创建 boot 数据库
CREATE DATABASE
IF
NOT EXISTS boot DEFAULT CHARSET utf8 COLLATE utf8_bin;

-- 选择 boot 数据库
USE boot;

-- 创建 user 表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE
IF
EXISTS `user`;
CREATE TABLE `user` (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
`user_name` VARCHAR ( 255 ) COLLATE utf8_bin NOT NULL,
`pasword` VARCHAR ( 255 ) COLLATE utf8_bin NOT NULL,
`salt` VARCHAR ( 255 ) COLLATE utf8_bin NOT NULL,
PRIMARY KEY ( `id` )
) ENGINE = INNODB DEFAULT CHARSET = utf8 COLLATE = utf8_bin;
SET FOREIGN_KEY_CHECKS = 1;

1.3.0 多环境配置

详细参考spring profile 与 maven profile 多环境管理

spring 多环境配置

配置一个 dev 环境

创建 application-dev.properties 文件,并将 mysql 相关配置迁移过来 img

使用 dev 环境

application-dev.properties 指定要使用的环境

1
2
spring.profiles.active=dev

img

同理你也可以创建 test、prod 环境,但一般公共配置还是会放在 application.properties 中,只有非公共配置才会放在各自的环境中

maven 多环境配置

spring boot 多环境配置有两个缺点

  1. 每次切换环境要手动修改 spring.profiles.active 的值
  2. 打包的时候,要手动删除其它环境的配置文件,不然其它环境的敏感信息就都打包进去了

而 maven 的 profile 可以解决这两个问题

第一个问题

“每次切换环境要手动修改spring.profiles.active的值”

这个问题就可以通过配置 profile 解决,在pom的根节点下添加

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
<profiles>
<profile>
<id>dev</id>
<activation>
<!-- activeByDefault 为 true 表示,默认激活 id为dev 的profile-->
<activeByDefault>true</activeByDefault>
</activation>
<!-- properties 里面可以添加自定义节点,如下添加了一个env节点 -->
<properties>
<!-- 这个节点的值可以在maven的其他地方引用,可以简单理解为定义了一个叫env的变量 -->
<env>dev</env>
</properties>
</profile>
<profile>
<id>test</id>
<properties>
<env>test</env>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<env>prod</env>
</properties>
</profile>
</profiles>

如上,定义了三套环境,其中id为dev的是默认环境,三套环境中定义了叫 env的“变量”

如果你用的是idea编辑器,添加好后,maven控件窗口应该会多出一个 Profiles,其中默认值就是上面配置的dev

img

最小化的 profiles 已经配置好了,通过勾选上图中的Profiles,就可以快速切换 maven的 profile 环境。

现在 maven profile 可以通过 勾选上图中的Profiles 快速切换环境

Spring Profile 还得通过 手动修改spring.profiles.active的值来切环境

现在的问题是怎样让 maven profile的环境与Spring Profile一一对应,达到切换maven profile环境时,Spring Profile环境也被切换了

还记得maven profile 中定义的 env “变量”吗,现在只需要把

1
spring.profiles.active=dev

改成

1
spring.profiles.active=@env@

就将maven profile 与 Spring Profile 环境关联起来了

当maven profile 将 环境切换成 test 时,在pom中定义的id为test的profile环境将被激活,在该环境下env的值是test,maven插件会将 @env@ 替换为 test,这样Spring Profile的环境也随之发生了改变。从上面可以看出,自定义的”变量”env的值还不能乱写,要与Spring Profile的环境相对应。

img

总结

  • 第一步,在pom文件中配置 profiles
  • 第二步,在application.properties配置文件中添加 spring.profiles.active=@env@

第二个问题

打包的时候,要手动删除其它环境的配置文件,不然其它环境的敏感信息就都打包进去了

解决这个问题需要在pom根节点下中配置 build 信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<excludes>
<!--先排除application开头的配置文件-->
<exclude>application*.yml</exclude>
</excludes>
</resource>
<resource>
<directory>src/main/resources</directory>
<!--filtering 需要设置为 true,这样在include的时候,才会把
配置文件中的@env@ 这个maven`变量`替换成当前环境的对应值 -->
<filtering>true</filtering>
<includes>
<!--引入所需环境的配置文件-->
<include>application.yml</include>
<include>application-${env}.yml</include>
</includes>
</resource>
</resources>
</build>

  • directory:资源文件所在目录
  • includes:需要包含的文件列表
  • excludes:需要排除的文件列表

如上,配置了两个 <resource>,第一个先排除了src/main/resources目录下所有 application 开头是配置文件,第二个在第一个的基础上添加了所需的配置文件。注意 application-${env}.yml,它是一个动态变化的值,随着当前环境的改变而改变,假如当前环境是 id叫 dev的 profile,那么env的值为 dev。

这样配置后,maven在build时,就会根据配置先排除掉指定的配置文件,然后根据当前环境添加所需要的配置文件。

pom 文件

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.9.RELEASE</version>
<relativePath/>
</parent>

<groupId>com.wqlm</groupId>
<artifactId>boot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>boot</name>
<description>Spring Boot Demo</description>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>

<!--maven 多环境-->
<profiles>
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault> <!-- 为 true 表示,默认激活该 profile-->
</activation>

<properties> <!-- properties 里面可以添加自定义节点,如下添加了一个env节点 -->
<env>dev</env> <!-- 这个节点的值可以在maven的其他地方引用,可以简单理解为定义了一个叫env的变量 -->
</properties>
</profile>

<profile>
<id>test</id>
<properties>
<env>test</env>
</properties>
</profile>

<profile>
<id>prod</id>
<properties>
<env>prod</env>
</properties>
</profile>
</profiles>

<dependencies>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

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

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

<!--test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<excludes>
<!--先排除application开头的配置文件-->
<exclude>application*.yml</exclude>
</excludes>
</resource>
<resource>
<directory>src/main/resources</directory>
<!--filtering 需要设置为 true,这样在include的时候,才会把配置文件中的@env@ 这个maven`变量`替换成当前环境的对应值-->
<filtering>true</filtering>
<includes>
<!--引入所需环境的配置文件-->
<include>application.yml</include>
<include>application-${env}.yml</include>
</includes>
</resource>
</resources>
</build>

<repositories>
<repository>
<name>华为maven仓库</name>
<id>huawei</id>
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
</repository>
</repositories>

<pluginRepositories>
<pluginRepository>
<name>华为maven插件仓库</name>
<id>huawei_plugin</id>
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
</pluginRepository>
</pluginRepositories>
</project>

1.4.0 多模块配置

稍微大一点的项目一般都会采用多模块的形式管理项目,将一个大型项目升级成多模块项目,一般需要两大步骤

  1. 拆分现有项目
  2. 进行 maven 多模块配置

关于如何拆分现有项目maven 多模块配置的详细介绍请参考 Maven 多模块配置、管理

拆分现有项目

我是按照功能拆分项目的,但由于该项目本身不大,所以我简单的拆分为 user 模块和 common 模块

在boot下创建两个新的模块 img

子模块的pom img

此时,父模块的pom内容也发生了改变,添加了如下三行 img

再来创建 common 模块,创建过程同上,我就不演示了,创建好之后,pom如下 img

整个项目结构如下 img

下一步进行迁移工作,将原先 src 目录下面的内容迁移到对于的子模块中 img

这里我放到 user 模块中,迁移过程中注意路径和命名规范,过程就不展示了,迁移之后,结构如下。 img

多模块配置

多模块配置遵守以下原则

  • 公共、通用配置一定要配置在父pom中
  • 版本号由父 pom 统一管理

如下图,蓝色背景的元素都会被子项目全部继承 img

由于目前,只有 user 模块用到了如下依赖项,而 common 模块不需要用到这些依赖,所以,将依赖复制到 user 模块下,后删掉依赖 img

build中的配置目前也是只有 user 模块用到,也复制到 user 模块下,后删掉 img

父项目已经配置完成了,接下来配置 user 模块,如下 img

common 模块用到的时候在配置

多模块管理

多模块环境管理 我们在父 pom 中配置了 maven 多环境,子模块会继承这些配置。之后,我们只需要在 maven 插件中切换环境,所有的子模块的 maven 环境都会被切换 img

多模块构建管理 在 maven 插件中,通过 boot 项目对所以子模块进行、编译、测试、打包、清理… img

1.5.0 集成 mybatis

集成 mybatis 一般需要5步

  1. 引入依赖
  2. 创建 PO 层,存放我们的数据持久化对象
  3. 创建 DAO 层,存放数据库的增删改查方法
  4. 创建 mapper.xml 层, 对应增删改查语句
  5. 在启动类上配置 @MapperScan
  6. 其他。如配置 MyBatis Generator,用来帮我们生成 PO、DAO、Mapper.xml

添加依赖

在多模块项目中添加依赖要注意一下几点

  • 不要将依赖直接添加到父 pom 中,这样所以子模块都会继承该依赖
  • 多个模块引用了统一个依赖,最好保证依赖的版本一致

maven dependencyManagement 可以非常方便的管理多模块的依赖

关于 maven dependencyManagement 请参考 maven 依赖版本管理

这里我就不在解释,直接应用了

  1. 在父 pom 的根节点下, properties 里,定义mybatis-spring-boot-starter 版本号的变量

    1
    <mybatis-spring-boot-starter.version>2.1.0</mybatis-spring-boot-starter.version>

    img

  2. 在父 pom 的根节点下,申明 mybatis 依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <dependencyManagement>
    <dependencies>
    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>${mybatis-spring-boot-starter.version}</version>
    </dependency>
    </dependencies>
    </dependencyManagement>

    img

  3. 在 user 模块的 pom 文件中引入 mybatis 依赖

    1
    2
    3
    4
    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    </dependency>
  4. 由于 mybatis-spring-boot-starter 包含 spring-boot-starter-jdbc ,所以删除spring-boot-starter-jdbc依赖,保证依赖的整洁

img

依赖结构图

img

创建对应的文件夹

如图

img

com.wqlm.boot

  • controller :控制层
  • po : 存放与数据库中表相对应的java对象
  • dto : 存放数据传输对象,比如注册时,注册的信息可以用一个 dto 对象来接收
  • dao : 存放操作数据库的接口
  • service : 业务层
  • vo : 存放业务返回结果对象
  • qo : 封装了查询参数的对象

resources

  • mapper : 存放mapper.xml 文件

配置 mybatis

mybatis 需要知道有那些类是 mapper!有两种方式可以告诉 mybatis。

第一种

在启动类上配置 @MapperScan

1
2
# 指定你的 mapper接口所在的 package
@MapperScan("com.wqlm.boot.user.dao")

img

第二种 在接口上加 @Mapper 注解,如下 img

要我选我肯定选第一种配置方式,一劳永逸

除此之外,还要告诉 mybatis ,你的 mapper.xml 文件在哪

1
2
3
# mybatis
# mapper.xml文件的位置
mybatis.mapper-locations=classpath*:mapper/*.xml

由于这里的配置跟环境无关,所以应该配置在 application.propertiesimg

1.5.1 配置 MyBatis Generator

MyBatis Generator 是 MyBatis 提供的一个代码生成工具。可以帮我们生成 表对应的持久化对象(po)、操作数据库的接口(dao)、CRUD sql的xml(mapper)。

使用方法主要分为三步

  1. 引入并配置 MyBatis Generator 插件
  2. 配置 MyBatis Generator Config 文件
  3. 使用 MyBatis Generator 插件

详细说明请参考 MyBatis Generator 超详细配置

这里只给出一种最终配置

引入并配置 MyBatis Generator 插件

在user项目的pom文件的根节点下添加如下配置

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
<build>
<plugins>
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.3.7</version>
<configuration>
<!--mybatis的代码生成器的配置文件-->
<configurationFile>src/main/resources/mybatis-generator-config.xml</configurationFile>
<!--允许覆盖生成的文件-->
<overwrite>true</overwrite>
<!--将当前pom的依赖项添加到生成器的类路径中-->
<!--<includeCompileDependencies>true</includeCompileDependencies>-->
</configuration>
<dependencies>
<!--mybatis-generator插件的依赖包-->
<!--<dependency>-->
<!--<groupId>org.mybatis.generator</groupId>-->
<!--<artifactId>mybatis-generator-core</artifactId>-->
<!--<version>1.3.7</version>-->
<!--</dependency>-->
<!-- mysql的JDBC驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.17</version>
</dependency>
</dependencies>
</plugin>
</plugins>
<build>

img

配置 MyBatis Generator Config 文件

在user项目的 resources 目录下,创建mybatis-generator-config.xml,内容如下

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
<?xml version="1.0" encoding="UTF-8" ?>
<!--mybatis的代码生成器相关配置-->
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">

<generatorConfiguration>
<!-- 引入配置文件 -->
<properties resource="application-dev.properties"/>

<!-- 一个数据库一个context,context的子元素必须按照它给出的顺序
property*,plugin*,commentGenerator?,jdbcConnection,javaTypeResolver?,
javaModelGenerator,sqlMapGenerator?,javaClientGenerator?,table+
-->
<context id="myContext" targetRuntime="MyBatis3" defaultModelType="flat">

<!-- 这个插件给生成的Java模型对象增加了equals和hashCode方法 -->
<!--<plugin type="org.mybatis.generator.plugins.EqualsHashCodePlugin"/>-->

<!-- 注释 -->
<commentGenerator>
<!-- 是否不生成注释 -->
<property name="suppressAllComments" value="true"/>
<!-- 不希望生成的注释中包含时间戳 -->
<!--<property name="suppressDate" value="true"/>-->
<!-- 添加 db 表中字段的注释,只有suppressAllComments为false时才生效-->
<!--<property name="addRemarkComments" value="true"/>-->
</commentGenerator>


<!-- jdbc连接 -->
<jdbcConnection driverClass="${spring.datasource.driverClassName}"
connectionURL="${spring.datasource.url}"
userId="${spring.datasource.username}"
password="${spring.datasource.password}">
<!--高版本的 mysql-connector-java 需要设置 nullCatalogMeansCurrent=true-->
<property name="nullCatalogMeansCurrent" value="true"/>
</jdbcConnection>

<!-- 类型转换 -->
<javaTypeResolver>
<!--是否使用bigDecimal,默认false。
false,把JDBC DECIMAL 和 NUMERIC 类型解析为 Integer
true,把JDBC DECIMAL 和 NUMERIC 类型解析为java.math.BigDecimal-->
<property name="forceBigDecimals" value="true"/>
<!--默认false
false,将所有 JDBC 的时间类型解析为 java.util.Date
true,将 JDBC 的时间类型按如下规则解析
DATE -> java.time.LocalDate
TIME -> java.time.LocalTime
TIMESTAMP -> java.time.LocalDateTime
TIME_WITH_TIMEZONE -> java.time.OffsetTime
TIMESTAMP_WITH_TIMEZONE -> java.time.OffsetDateTime
-->
<!--<property name="useJSR310Types" value="false"/>-->
</javaTypeResolver>

<!-- 生成实体类地址 -->
<javaModelGenerator targetPackage="com.wqlm.boot.user.po" targetProject="src/main/java">
<!-- 是否让 schema 作为包的后缀,默认为false -->
<!--<property name="enableSubPackages" value="false"/>-->
<!-- 是否针对string类型的字段在set方法中进行修剪,默认false -->
<property name="trimStrings" value="true"/>
</javaModelGenerator>


<!-- 生成Mapper.xml文件 -->
<sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources">
<!--<property name="enableSubPackages" value="false"/>-->
</sqlMapGenerator>

<!-- 生成 XxxMapper.java 接口-->
<javaClientGenerator targetPackage="com.wqlm.boot.user.dao" targetProject="src/main/java" type="XMLMAPPER">
<!--<property name="enableSubPackages" value="false"/>-->
</javaClientGenerator>


<!-- schema为数据库名,oracle需要配置,mysql不需要配置。
tableName为对应的数据库表名
domainObjectName 是要生成的实体类名(可以不指定,默认按帕斯卡命名法将表名转换成类名)
enableXXXByExample 默认为 true, 为 true 会生成一个对应Example帮助类,帮助你进行条件查询,不想要可以设为false
-->
<table schema="" tableName="user" domainObjectName="User"
enableCountByExample="false" enableDeleteByExample="false" enableSelectByExample="false"
enableUpdateByExample="false" selectByExampleQueryId="false">
<!--是否使用实际列名,默认为false-->
<!--<property name="useActualColumnNames" value="false" />-->
</table>
</context>
</generatorConfiguration>

img

application-dev.properties 的配置

MyBatis Generator Config 引用的外部配置文件内容如下

1
2
3
4
5
6
# mysql
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/boot?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=123456

img

使用 MyBatis Generator 插件

配置好后,双击 maven 中的 MyBatis Generator 运行 img

1.5.2 集成 tk.mybatis (通用mapper)

上一节中,MyBatis Generator 为我们生成了一些常用的操作数据库的方法。其实我们也可以通过 集成 tk.mybatis (通用mapper) 来实现,集成之后,会有更多的通用方法,并且这些通用方法是不用配置mapper.xml 的。

springboot 集成 tk.mybatis (通用mapper) 一般需要3步

  1. 引入依赖
  2. 配置 tk.mybatis 的 MyBatis Generator 插件
  3. 启动类上配置要扫描的 dao 路径
  4. 配置通用 Mapper

tk.mybatis 的 GitHub

引入依赖

老规矩,还是在父 pom 中配置 tk.mybatis 的版本并申明 tk.mybatis 的依赖 img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<properties>
<tk.mybatis.mapper-spring-boot-starter.version>2.1.5</tk.mybatis.mapper-spring-boot-starter.version>
</properties>

<!--申明依赖-->
<dependencyManagement>
<dependencies>
<!--tk.mybatis 通用mapper-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
<version>${tk.mybatis.mapper-spring-boot-starter.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

然后在 user 模块的 pom 中引入依赖

1
2
3
4
5
6
7
<dependencies>
<!--tk.mybatis 通用mapper-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
</dependency>
</dependencies>

img

配置 tk.mybatis 的 MyBatis Generator 插件

tk.mybatis 为 MyBatis Generator 开发了一个插件,用于改变 MyBatis Generator 的原始生成策略,配置好之后,生成出来的文件更加精练、注释更加有意义。

整个配置过程分两步

  1. 引入插件依赖
  2. 修改 MyBatis Generator Config

引入插件依赖

在原来的 MyBatis Generator 插件的 dependencies 里面添加如下依赖 img

1
2
3
4
5
6
<!--4.15 是目前最新的版本-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper</artifactId>
<version>4.1.5</version>
</dependency>

修改 MyBatis Generator Config

主要有一下几点改动

首先是 targetRuntime 的值改为 MyBatis3SimpledefaultModelType 设置为 flat img

如果 targetRuntime="MyBatis3" 的话,生成出来的 mapper.xml 会多出一段无用代码,如下 img

然后添加 tk.mybatis 插件

1
2
3
4
5
6
<plugin type="tk.mybatis.mapper.generator.MapperPlugin">
<!--dao 要继承的接口-->
<property name="mappers" value="tk.mybatis.mapper.common.Mapper"/>
<!--是否区分大小写,默认false-->
<property name="caseSensitive" value="true"/>
</plugin>

img

其他地方都不需要改动,配置好之后,运行 MyBatis Generator 插件,生成出来的文件如下

po img 可以看到,相比与 MyBatis Generator 生成的注释,tk.mybatis 生成的注解跟简洁易懂。除此之外,它还多了几个注解

  • **@Table(name = “user”)**:意思是该po对应数据库的user表
  • @Id:表示该属性对应user表的主键
  • **@Column(name = “user_name”)**:表示该属性对应user表的 user_name 字段

dao img 相比于 MyBatis Generator 生成的代码,少了很多接口,但多继承了一个类,这个类就是你在 tk.mybatis 插件里面配置的类 img 你可能猜到了,少的那些接口,都在继承的这个tk.mybatis.mapper.common.Mapper类中有,如下图,userMapper 继承了这么多方法,而且这些方法都是可以直接使用的 img

mapper.xml img 相比于 MyBatis Generator 少了很多代码

启动类上配置要扫描的 dao 路径

这一步我们在集成 mybatis 时已经配置过了 img

但是集成 tk.mybatis 后,需要使用 tk.mybatis 包下的 @MapperScan ,因此需要修改一下 @MapperScan 的包路径 ![img](/Users/xh/Library/Application Support/typora-user-images/image-20220315161228552.png)

1
import tk.mybatis.spring.annotation.MapperScan;

配置通用 Mapper

img

1
2
3
4
5
# 通用 mapper
# 主键自增回写方法,默认值MYSQL
mapper.identity=MYSQL
# 设置 insert 和 update 中,字符串类型!=""才插入/更新,默认false
#mapper.not-empty=true

tk.mybatis 至此就全部集成完了

1.5.3 集成 pagehelper 分页插件

分页查询是web开发中一个很常见的功能,mybatis 虽然也可以实现分页,但那是基于内存的分页,即把数据一次性全查出来,然后放在内存中分多次返回。

而 pagehelper 分页插件是物理分页,是通过 sql 关键字来实现的。例如mysql中的limit,oracle中的rownum等。

pagehelper 分页插件和 tk.mybatis 是通一个作者写的pagehelper项目地址

集成 pagehelper 需要两步

  • 引入 pagehelper 依赖
  • 配置 pagehelper

引入 pagehelper 依赖

老规矩,父pom中定义依赖版本,并申明依赖 img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<properties>
<pagehelper-spring-boot-starter.version>1.2.12</pagehelper-spring-boot-starter.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>${pagehelper-spring-boot-starter.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

子模块中引入依赖 img

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
</dependencies>

配置 pagehelper

img

1
2
3
4
5
6
7
# 使用的sql方言
pagehelper.helperDialect=mysql
# 是否启用合理化,默认false,启用合理化时,如果 pageNum<1会查询第一页,如果pageNum>pages会查询最后一页
pagehelper.reasonable=true
# 是否支持通过Mapper接口参数来传递分页参数,默认false
#pagehelper.supportMethodsArguments=true
pagehelper.params=count=countSql

更多配置请参考官网

1.6.0 引入 lombok 插件

lombok 是一个简化代码的插件,能通过一个注解帮你生成 get、set、tostring、hash… 方法。

集成 lombok 十分简单

  1. 引入 lombok 依赖
  2. 安装 ide 对应的 lombok 插件

lombok 使用方法参考这篇文章 lombok 插件

引入 lombok 依赖

img

由于 spring-boot-starter-parent 中已经申明了lombok 依赖,我们只需要在子模块中引入就好了

img

1
2
3
4
5
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

安装 ide 对应的 lombok 插件

1.7.0 集成 redis

作为最常用的 nosql 数据库,Spring Boot 对 redis 做了非常好对支持,集成起来也很简单,只需要4步

  1. 引入依赖
  2. 配置 redis
  3. 自定义 RedisTemplate (推荐)
  4. 自定义 redis 操作类 (推荐)

详细的集成步骤我单独提出来写了篇文章,地址如下

Spring Boot 2.0 集成 redis

1.7.1 集成 spring cache

Spring Cache 是 Spring 为缓存场景提供的一套解决方案。通过使用 @CachePut、@CacheEvict、@Cacheable等注解实现对缓存的,存储、查询、删除等操作

由于我们已经引入 redis ,所以只需要简单配置一下,就可以使用 spring cache

详细的集成步骤我单独提出来写了篇文章,地址如下(在 Spring Cache 那一节)

Spring Boot 2.0 集成 redis

当然,不用也可以选择不去集成

1.8.0 开发用户模块

不同的系统对应不同的用户设计,掘金的用户系统和淘宝的用户系统肯定是不一样的,所以,该项目虽然是一个种子项目,但很难给出通用的用户模块设计。

因此,我转变思路,并不去追求什么通用的用户模块,而是力求在该模块中,将上面集成的技术,全部都应用起来。

这样,在以后使用到上面的某项技术的时候,也有一个参照。

用户表

img 建表语句在 配置 mysql 那一章

添加 controller 、service 、dto

先创建 controller 、service 、dto 目录 img

先从注册接口开始写

注意校验参数一定要加 @Valid 注解 img

其中 @Data 用到了 lombok 插件 r

数据库存的是密码加盐后的hash,也就是说就连我们自己也看不到用户的密码 img

1.8.1 自定义全局状态码和业务结果类

虽然向上面这样直接返回 注册成功注册失败 也没有什么问题,但却不太优雅,因为其他接口返回的可能不是简单的字符串。

我们其实可以自定义一个业务结果类,所有的接口,都返回该业务结果对象,这个业务结果类,除了有业务结果外,还有业务执行状态、业务消息等。

除了业务结果类,我还建议创建全局状态码类,就想蚂蚁金服的api接口一样,调用失败会返回一个状态码,方便错误排查

自定义全局状态码

新建 enums 目录 img

创建 ApplicationEnum 全局状态码类,我这里只写了几个,之后可以往里面加 m

创建业务结果类

如下,在 vo 目录下下创建 result 目录,并在里面创建业务结果类 img

img

为了方便使用,再创建一个 SuccessResult 和一个 FailResult img

改造注册接口

img

img

1.8.2 统一异常处理

业务执行过程中会产生的各种异常,对其进行统一处理是所有web项目的通用需求。Spring 提供了 @RestControllerAdvice、@ExceptionHandler 注解来帮助我们处理异常。

具体的异常处理该如何设计没有统一的标准,下面是我给出的设计,仅供参考。

我的做法是自定义一个异常类,然后在业务出错时抛出,最后在自定义异常处理类中处理。

更多内容请参考 基于spring 的统一异常处理

自定义异常类

每一种异常都对于一种ApplicationEnum img

自定义异常处理类

img

如果一个异常能匹配多个 @ExceptionHandler 时,选择匹配深度最小的Exception(即最匹配的Exception)

使用自定义异常

img

img

1.8.3 参数校验及异常处理

参数校验

首先在要校验的对象前加上 @valid 注解 img

然后在要校验的对象中使用适当的注解 img

详细内容请参考

参数校验 Hibernate-Validator
Spring 参数校验详解

异常处理

如果参数绑定不成功或者校验不通过,就会抛出异常!但是默认抛出的异常包含很多敏感信息,如下: img 因此我们应该对常见的异常进行捕获后再封装。

img

1
extends ResponseEntityExceptionHandler` 是为了重写几个常见异常的默认处理方式。当然,你也可以直接通过 `@ExceptionHandler()` 拦截,这样就不用`extends ResponseEntityExceptionHandler

img 一般只需要处理这三个异常就可以覆盖大部分需要手动处理参数异常的场景

  • org.springframework.validation.BindException
  • org.springframework.web.bind.MethodArgumentNotValidException
  • javax.validation.ConstraintViolationException

详细内容请参考

Spring 参数校验的异常处理

1.8.4 添加登陆、修改密码、获取用户信息的接口

主要是些业务逻辑,没有什么指的说的,具体代码参考项目源码!唯一指的提一嘴的是 使用 @Value("${}") 注解获取配置文件中的属性 img

1.8.5 添加认证拦截器

有些接口我们希望只有登陆的用户能访问,解决方案一般是添加一个登陆拦截器。

方法也很简单,主要分为3步

  • 定义哪些接口需要认证(登陆后才能访问),或者哪些接口不需要认证
  • 自定义 HandlerInterceptor , 在访问接口前进行拦截处理
  • 自定义 WebMvcConfigurer ,定义哪些接口需要拦截

1.确定免认证url img

2.自定义 HandlerInterceptor ,对拦截到的请求进行token有效性校验 img

3.自定义 WebMvcConfigurer ,拦截除免认证url列表之外的所有请求 img

1.8.6 统一映射自定义配置

之前我们都是哪里需要用到 properties 中的配置,就在那里使用 @Value("${}") 来获取。如下 img

我们也可以创建一个自定义配置类,将所有 properties 中的自定义属性全部映射到对应的属性上,如下 img

然后使用时,直接访问该类 img

1.9.0 配置日志

spring boot 已经对日志系统进行了默认的配置,但是如果你想显示 sql 或将日志输出到文件就需要进行进一步配置。

详细内容请参考

java 生态下的日志框架
Java 日志实现框架 Logback
Logback 配置样例
Spring Boot Logging

显示 sql

application.properties 中配置

1
2
# dao(com.wqlm.boot.user.dao) 层设置成 debug 级别以显示sql
logging.level.com.wqlm.boot.user.dao=debug

输出日志到文件

application.properties 中配置

1
2
3
4
5
6
# 当前活动的日志文件名
logging.file.name=logs/user/user.log
# 最多保留多少天的日志
logging.file.max-history=30
# 单个日志文件最大容量
logging.file.max-size=10MB

精细化配置

application.properties 中只能进行有限的配置,如果想进一步配置,就需要使用对应日志框架的配置文件了!

spring boot 使用 logback 作为日志实现框架,spring boot 推荐使用 logback-spring.xml 作为配置文件的名称。

以下是一个参考的配置样例

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
<?xml version="1.0" encoding="UTF-8"?>

<!-- scan :开启"热更新" scanPeriod:"热更新"扫描周期,默认 60 seconds(60秒)-->
<configuration scan="true" scanPeriod="300 seconds">

<!-- 引入颜色转换器 -->
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/>

<!-- 自定义变量 name :变量名 scope : 在哪个环境中查找 source : 使用哪个属性 defaultValue :没找到时的默认值-->
<springProperty name="env" scope="context" source="spring.profiles.active" defaultValue="env"/>

<!-- 应用名称 -->
<property name="APP_NAME" value="user"/>

<!-- 自定义变量,用于配置日志输出格式,这个格式是尽量偏向 spring boot 默认的输出风格
%date:日期,默认格式 yyyy-MM-dd hhh:mm:ss,SSS 默认使用本机时区,通过 %d{yyyy-MM-dd hhh:mm:ss,SSS} 来自定义
%-5level:5个占位符的日志级别,例如" info"、"error"
%thread : 输出日志的线程
%class : 输出日志的类的完全限定名,效率低
%method : 输出日志的方法名
%line : 输出日志的行号,效率低
%msg : 日志消息内容
%n : 换行
-->
<property name="LOG_PATTERN" value="%date %-5level ${PID:- } --- [%thread] %class.%method/%line : %msg%n"/>

<!-- 彩色日志格式 -->
<property name="LOG_PATTERN_COLOUR"
value="${env} %date %clr(%-5level) %magenta(${PID:- }) --- [%thread] %cyan(%class.%method/%line) : %msg%n"/>


<!--日志输出器. ch.qos.logback.core.ConsoleAppender : 输出到控制台-->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- 配置日志输出格式 -->
<pattern>${LOG_PATTERN_COLOUR}</pattern>
<!-- 使用的字符集 -->
<charset>UTF-8</charset>
</encoder>
</appender>

<!-- 日志输出器。ch.qos.logback.core.rolling.RollingFileAppender : 滚动输出到文件 -->
<appender name="ROLLING" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 活动中的日志文件名(支持绝对和相对路径) -->
<file>logs/${APP_NAME}/${APP_NAME}.log</file>
<!-- 滚动策略. ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy : 按照大小和时间滚动-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 何时触发滚动,如何滚动,以及滚动文件的命名格式
%d : 日期,默认格式 yyyy-MM-dd,通过 %d{yyyy-MM-dd hhh:mm:ss} 来自定义格式。logback 就是通过 %d 知道了触发滚动的时机
%i : 单个滚动周期内的日志文件的序列号
.zip : 将日志文件压缩成zip。不想压缩,可以使用.log 结尾
如下每天0点以后的第一日志请求触发滚动,将前一天的日志打成 zip 压缩包存放在 logs/app1/backup 下,并命名为 app1_%d_%i.zip
-->
<fileNamePattern>logs/${APP_NAME}/backup/${APP_NAME}_%d{yyyy-MM-dd}_%i.zip</fileNamePattern>

<!--单个日志文件的最大大小-->
<maxFileSize>10MB</maxFileSize>

<!--删除n个滚动周期之前的日志文件(最多保留前n个滚动周期的历史记录)-->
<maxHistory>30</maxHistory>
<!-- 在有 maxHistory 的限制下,进一步限制所有日志文件大小之和的上限,超过则从最旧的日志开始删除-->
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<!-- 日志输出格式 -->
<pattern>${LOG_PATTERN}</pattern>
<!-- 使用的字符集 -->
<charset>UTF-8</charset>
</encoder>
</appender>


<!-- 非 prod 环境下使用以下配置 -->
<springProfile name="!prod">
<!-- 记录器 name : 包名或类名, level : 要记录的日志的起始级别, additivity : 是否追加父类的 appender -->
<logger name="com.wqlm.boot.dao" level="debug" additivity="false">
<appender-ref ref="STDOUT"/>
<appender-ref ref="ROLLING"/>
</logger>
</springProfile>


<!-- 根记录器 -->
<root level="info">
<!-- 使用 STDOUT、ROLLING 输出记录的日志-->
<appender-ref ref="STDOUT"/>
<appender-ref ref="ROLLING"/>
</root>
</configuration>

相对路径的位置 img

l

xkeysnail for ubuntu 键盘映射

1
2
3
sudo xkeysnail xkeysnail.py --device /dev/input/event4    'AT Translated Set 2 keyboard' 
xhost +SI:localuser:root
sudo xkeysnail --watch xkeysnail-gte60.py --device /dev/input/event24 'GT BLE60 0AEBCB Keyboard'
l