胖猪猪

胖猪猪 查看完整档案

河源编辑  |  填写毕业院校别问,问就不知道  |  菜鸡全栈 编辑 xiaozha.top 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

胖猪猪 收藏了文章 · 2月13日

PowerShell设置命令别名Alias

前言

PowerShell在Windows10中的地位被慢慢提高,在最近的系统版本中,Powershell已经在资源管理器中完全代替以前的cmd出现。所以我本人也逐渐把运行命令的习惯向PowerShell迁移。
在使用过程中发现PowerShell虽然默认支持类似Unix系统的ls命令,但是其默认展示方式是非常详细的,包括ModeLastWriteTime,LengthName共4个字段。这里的ls其实是PowerShell命令Get-ChildItem的一个别名,显示效果如下:

PS D:\apktool> get-childitem


    Directory: D:\apktool


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----        2/23/2016     20:15            159 apktool.bat
-a----        2/23/2016     20:16        6433219 apktool.jar

而在实际工作中,其实我是比较喜欢用ls命令只显示文件名。那么在PowerShell中默认的命令需要输入ls -NameGet-ChildItem -Name来实现。
下面来说说一些关于PowerShell中设置命令别名Alias的常用相关命令和方法:

相关方法

以下命令中的大小写不敏感

查看别名

  • 查看此Session中已经设定的所有别名:Get-Aliasgal
PS C:\> Get-Alias
CommandType     Name
-----------     ----
Alias           % -> ForEach-Object
Alias           ? -> Where-Object
Alias           ac -> Add-Content
Alias           asnp -> Add-PSSnapin
  • 查看某别名的原命令,如ls的原命令:> Get-Alias ls
  • 查看某原命令的别名,如Get-ChildItem的别名:> Get-Alias -Definition Get-ChildItem

创建或更改别名

创建不带参数的别名

使用Set-Alias命令创建或更改别名。注意使用该命令设定的别名只在目前的Windows PowerShell session中生效。也就是说在关闭此会话后这个别名将会失效。如何创建永久的别名?往下看
如为Get-ChildItem命令设定别名list

PS C:\> Set-Alias -Name list -Value get-childitem

或简单一些:

PS C:\> Set-Alias list get-childitem

注意:对于系统默认设定的别名,不可在删除此别名之前重新对这个别名赋值。
另外,PowerShell中还有一个命令New-Alias,该命令和Set-Alias基本功能一样,只是前者不能更改别名,只能创建别名。当试图使用New-Alias命令创建已存在的别名时,会报错。

创建或更改带参数的别名

如果命令带参数如想为Get-ChildItem -Name设定别名为ls则我们需要两步,第一步为这个带参数原命令设定一个中间function,第二步为这个function指定别名:

PS C:\> function getlist {Get-ChildItem -Name}
PS C:\> Set-Alias ls getlist

删除别名

使用Remove-Item alias命令删除已设定的别名。
如删除别名ls

PS C:\> Remove-Item alias:\ls

创建永久的别名

在PowerShell中直接使用Set-AliasNew-Alias命令创建的别名在关闭此Session后即会失效,防止此现象的方法是将此命令写入Windows PowerShell profile文件。
查看此文件在计算机中的位置:

PS C:\> $profile

一般该文件在没有创建前是不存在的,使用以下命令为当前用户创建profile命令并返回文件地址:

PS C:\> New-Item -Type file -Force $profile

一般创建的位置在~\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1
打开文件,键入文件内容为Get-ChildItem -Name创建别名ls

function getFileName{
    Get-ChildItem -Name
}
Remove-Item alias:\ls
Set-Alias ls getFileName

这里首先为Get-ChildItem -Name创建了方法getFileName作为中介,然后为该方法赋予别名ls,但是因为ls是Windows PowerShell中的默认别名,因此必须先删除再创建,所以先使用Remove-Item再使用Set-AliasNew-Alias
以后每次在打开PowerShell会话框的时候其会先读取$profile文件中的内容。

试试效果:

PS D:\apktool> ls
apktool.bat
apktool.jar
查看原文

胖猪猪 赞了文章 · 2月13日

PowerShell设置命令别名Alias

前言

PowerShell在Windows10中的地位被慢慢提高,在最近的系统版本中,Powershell已经在资源管理器中完全代替以前的cmd出现。所以我本人也逐渐把运行命令的习惯向PowerShell迁移。
在使用过程中发现PowerShell虽然默认支持类似Unix系统的ls命令,但是其默认展示方式是非常详细的,包括ModeLastWriteTime,LengthName共4个字段。这里的ls其实是PowerShell命令Get-ChildItem的一个别名,显示效果如下:

PS D:\apktool> get-childitem


    Directory: D:\apktool


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----        2/23/2016     20:15            159 apktool.bat
-a----        2/23/2016     20:16        6433219 apktool.jar

而在实际工作中,其实我是比较喜欢用ls命令只显示文件名。那么在PowerShell中默认的命令需要输入ls -NameGet-ChildItem -Name来实现。
下面来说说一些关于PowerShell中设置命令别名Alias的常用相关命令和方法:

相关方法

以下命令中的大小写不敏感

查看别名

  • 查看此Session中已经设定的所有别名:Get-Aliasgal
PS C:\> Get-Alias
CommandType     Name
-----------     ----
Alias           % -> ForEach-Object
Alias           ? -> Where-Object
Alias           ac -> Add-Content
Alias           asnp -> Add-PSSnapin
  • 查看某别名的原命令,如ls的原命令:> Get-Alias ls
  • 查看某原命令的别名,如Get-ChildItem的别名:> Get-Alias -Definition Get-ChildItem

创建或更改别名

创建不带参数的别名

使用Set-Alias命令创建或更改别名。注意使用该命令设定的别名只在目前的Windows PowerShell session中生效。也就是说在关闭此会话后这个别名将会失效。如何创建永久的别名?往下看
如为Get-ChildItem命令设定别名list

PS C:\> Set-Alias -Name list -Value get-childitem

或简单一些:

PS C:\> Set-Alias list get-childitem

注意:对于系统默认设定的别名,不可在删除此别名之前重新对这个别名赋值。
另外,PowerShell中还有一个命令New-Alias,该命令和Set-Alias基本功能一样,只是前者不能更改别名,只能创建别名。当试图使用New-Alias命令创建已存在的别名时,会报错。

创建或更改带参数的别名

如果命令带参数如想为Get-ChildItem -Name设定别名为ls则我们需要两步,第一步为这个带参数原命令设定一个中间function,第二步为这个function指定别名:

PS C:\> function getlist {Get-ChildItem -Name}
PS C:\> Set-Alias ls getlist

删除别名

使用Remove-Item alias命令删除已设定的别名。
如删除别名ls

PS C:\> Remove-Item alias:\ls

创建永久的别名

在PowerShell中直接使用Set-AliasNew-Alias命令创建的别名在关闭此Session后即会失效,防止此现象的方法是将此命令写入Windows PowerShell profile文件。
查看此文件在计算机中的位置:

PS C:\> $profile

一般该文件在没有创建前是不存在的,使用以下命令为当前用户创建profile命令并返回文件地址:

PS C:\> New-Item -Type file -Force $profile

一般创建的位置在~\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1
打开文件,键入文件内容为Get-ChildItem -Name创建别名ls

function getFileName{
    Get-ChildItem -Name
}
Remove-Item alias:\ls
Set-Alias ls getFileName

这里首先为Get-ChildItem -Name创建了方法getFileName作为中介,然后为该方法赋予别名ls,但是因为ls是Windows PowerShell中的默认别名,因此必须先删除再创建,所以先使用Remove-Item再使用Set-AliasNew-Alias
以后每次在打开PowerShell会话框的时候其会先读取$profile文件中的内容。

试试效果:

PS D:\apktool> ls
apktool.bat
apktool.jar
查看原文

赞 6 收藏 3 评论 0

胖猪猪 收藏了文章 · 1月18日

Git分支命名规范

分支命名说明
主分支master主分支,所有提供给用户使用的正式版本,都在这个主分支上发布
开发主分支dev开发分支,永远是功能最新最全的分支
功能分支feature-*新功能分支,某个功能点正在开发阶段
发布版本release-*发布定期要上线的功能
修复发布版本分支bugfix-release-*修复测试bug
紧急修复分支bugfix-master-*紧急修复线上代码的 bug
主分支 Master

代码库应该有一个、且仅有一个主分支。所有提供给用户使用的正式版本,都在这个主分支上发布。
主分支必须是可用的、稳定的、可直接发布的版本,不能直接在主分支上开发。

开发主分支Dev

Master主分支只用来发布重大版本,日常开发应该在另一个分支上完成,我们把开发用的分支,叫做Dev。
这个分支可以用来生成代码的最新隔夜版本(nightly)。
如果想正式对外发布,就在Master分支上对Dev分支进行合并(merge)。
Dev分支代码永远是最新的,所有新功能以这个分支来创建自己的开发分支,该分支只做合并操作,不能直接在该分支上开发。

功能分支Feature

功能分支的名字,可以采用feature-*的形式命名,以自己开发的功能命名。
功能分支是分配开发不同的功能用的,从Dev创建功能分支,然后完成相应功能开发后合并回Dev分支并删除该功能分支

预发布分支Release

预发布分支名字,可以采用release-*的形式命名
预发布分支是指发布正式版本之前(即合并到Master分支之前),我们可能需要有一个预发布的版本进行测试。
预发布分支是从Dev分支上分出来的,预发布结束之后(即测试没有问题之后),必须合并进Dev和Master。

修复预发布分支Release-bug

修复预发布分支的bug,可以采用release-bug-*的形式命名
在预发布版本测试出现bug时,从release分支创建分支进行bug修复,bug修复完成后在合并会release分支

紧急修补分支Bug

修补分支的名字,可以采用bugfix-master-*的形式。
该分支是为了紧急修复线上的bug。
软件正式发布之后,难免会出现bug。这时就需要创建一个分支,进行bug修补。
修补bug分支是从Master分支上面分出来的。修补结束之后,再合并进Master和Dev分支。
尽量避免线上问题的出现

注:一个分支尽量开发一个功能模块,不要多个功能模块在一个分支上开发.
feature分支申请合并之前,最好先pull一下dev分支下来,看一下有没有冲突,如果有冲突就先解决冲突后再合并回dev

查看原文

胖猪猪 赞了文章 · 1月18日

Git分支命名规范

分支命名说明
主分支master主分支,所有提供给用户使用的正式版本,都在这个主分支上发布
开发主分支dev开发分支,永远是功能最新最全的分支
功能分支feature-*新功能分支,某个功能点正在开发阶段
发布版本release-*发布定期要上线的功能
修复发布版本分支bugfix-release-*修复测试bug
紧急修复分支bugfix-master-*紧急修复线上代码的 bug
主分支 Master

代码库应该有一个、且仅有一个主分支。所有提供给用户使用的正式版本,都在这个主分支上发布。
主分支必须是可用的、稳定的、可直接发布的版本,不能直接在主分支上开发。

开发主分支Dev

Master主分支只用来发布重大版本,日常开发应该在另一个分支上完成,我们把开发用的分支,叫做Dev。
这个分支可以用来生成代码的最新隔夜版本(nightly)。
如果想正式对外发布,就在Master分支上对Dev分支进行合并(merge)。
Dev分支代码永远是最新的,所有新功能以这个分支来创建自己的开发分支,该分支只做合并操作,不能直接在该分支上开发。

功能分支Feature

功能分支的名字,可以采用feature-*的形式命名,以自己开发的功能命名。
功能分支是分配开发不同的功能用的,从Dev创建功能分支,然后完成相应功能开发后合并回Dev分支并删除该功能分支

预发布分支Release

预发布分支名字,可以采用release-*的形式命名
预发布分支是指发布正式版本之前(即合并到Master分支之前),我们可能需要有一个预发布的版本进行测试。
预发布分支是从Dev分支上分出来的,预发布结束之后(即测试没有问题之后),必须合并进Dev和Master。

修复预发布分支Release-bug

修复预发布分支的bug,可以采用release-bug-*的形式命名
在预发布版本测试出现bug时,从release分支创建分支进行bug修复,bug修复完成后在合并会release分支

紧急修补分支Bug

修补分支的名字,可以采用bugfix-master-*的形式。
该分支是为了紧急修复线上的bug。
软件正式发布之后,难免会出现bug。这时就需要创建一个分支,进行bug修补。
修补bug分支是从Master分支上面分出来的。修补结束之后,再合并进Master和Dev分支。
尽量避免线上问题的出现

注:一个分支尽量开发一个功能模块,不要多个功能模块在一个分支上开发.
feature分支申请合并之前,最好先pull一下dev分支下来,看一下有没有冲突,如果有冲突就先解决冲突后再合并回dev

查看原文

赞 5 收藏 3 评论 0

胖猪猪 发布了文章 · 1月15日

记录一次mysqlbinlog恢复过程

最近老友线上一个不太重要的数据库被自己误删了,于是找到小猪本猪我恢复数据,早已把"经验大于一切"思想贯彻全身的小猪肯定是不会放过这次机会滴,滴,滴,滴,滴.

开始枯燥的恢复环节

恢复方案选择

老友平时备份都是手动备份,这样子好像也只能选择旧备份数据+binlog来恢复了.

保护现场

  1. 关闭服务器,禁止数据继续写入
  2. 因为我要使用的是binlog方法恢复,所以要在数据库执行一段

    flush logs
    表示开启新的一段binlog

本地数据库版本

老友服务器上的mysql版本是5.6
我本地5.7重新安装数据库太麻烦了,docker安装也麻烦.然后开始找有5.6版本的一键环境...

  • PhpStudy没
  • WampServer太粉(通过截图看好像也没有5.6)
  • APMServ没
  • phpenv有

....找啊找啊终于找到一个说的清清楚楚支持mysql5.6一键环境,然后就是下载安装巴拉巴拉的5.6环境搭建好了.

本地mysql版本开启binlog

image.png
可以看到我本地未开启binlog, no:开启 off:未开启

  1. 打开my.ini
# mysqld配置下增加以下内容

# binlog文件前缀,配置好这个就表示开启了bin
log-bin = mysql-bin
或者
log_bin=ON
log_bin_basename=/var/lib/mysql/mysql-bin
log_bin_index=/var/lib/mysql/mysql-bin.index

### 以下配置看你需要,不过只恢复数据的话下面不用管
# 前缀
log_bin_basename
# 设置此参数是指定二进制索引文件的路径与名称
log_bin_index
# 此参数表示只记录指定数据库的二进制日志
# 这个参数的使用方法有点坑.不是通过,分隔的哦具体百度一下
binlog_do_db
# 此参数表示不记录指定的数据库的二进制日志.不是通过,分隔的哦具体百度一下
binlog_ignore_db
# 此参数表示binlog使用的内存最大的尺寸
max_binlog_cache_size
# 此参数表示binlog使用的内存大小,可以通过状态变量binlog_cache_use和binlog_cache_disk_use来帮助测试。
binlog_cache_size
# 使用二进制日志缓存的事务数量
binlog_cache_use
# 使用二进制日志缓存但超过
binlog_cache_disk_use
# 值并使用临时文件来保存事务中的语句的事务数量
binlog_cache_size
# 最大值,最大和默认值是1GB,该设置并不能严格控制Binlog的大小,尤其是Binlog比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性,不可能做切换日志的动作,只能将该事务的所有SQL都记录进当前日志,直到事务结束
max_binlog_sizeBinlog

使用mysqlbinlog.exe恢复

#目录切换到mysql/bin下面
# 执行
.\mysqlbinlog.exe --database test(要恢复的数据库) -s ../data/mysql-bin.000707 > sqls-x\000707-all.txt
# 以上命令的意思是
# 提取mysql-bin.000707文件中的test数据库的语句(只显示日志中包含的语句,不显示其它信息。)

mysqlbinlog.exe其他指令中文翻译

恢复了几个文件之后发现太大啦!!!
于是想着先恢复几个较为重要的表数据,发现mysql提供的工具并不支持恢复到某个表或语句,然后各种搜索引擎发现了几个开源项目binlog2sqlmysqlbinlog_flashback

使用binlog2sql恢复

binlog2sql
这二款工具里就它star最多,毫无疑问就是它了.

安装
# 克隆项目
git clone https://github.com/danfengcao/binlog2sql.git && cd binlog2sql
# 安装依赖
pip install -r requirements.txt
环境安装和mysql配置
  1. 安装python2.7,3.4+ python的安装教程网上有非常多
  2. 修改本地mysql配置
server_id = 1
# 这个的值不一定非要一样
log_bin = /var/log/mysql/mysql-bin.log
max_binlog_size = 1G
binlog_format = row
binlog_row_image = full 
设置mysql权限
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'root'@'127.0.0.1';  
使用
# 切换到项目目录
1. cd 项目目录/binlog2sql/
2. 执行命令 python binlog2sql.py -h127.0.0.1 -P3306 -uroot -proot -dtest -t wp_userinfo wp_balance --sql-type INSERT DELETE --start-file=mysql-bin.000715 --stop-file=mysql-bin.000726 > C:\phpEnv\server\mysql\mysql-5.6\bin\sqls-x\all.sql
3. 等待执行完毕即可
4. 将这些语句导入到数据库就ok啦

-h127.0.0.1(数据库链接)
-P3306(数据库端口)
-uroot(数据库账号)
-proot(数据库密码)
-dtest(要恢复的数据库)
-t wp_userinfo wp_balance(导出的指定表)
--sql-type INSERT DELETE(要导出的指定语句)\
--start-file=mysql-bin.000715 --stop-file=mysql-bin.000726(开始binlog到结束binlog)
C:\phpEnv\server\mysql\mysql-5.6\bin\sqls-x\all.sql(将打印内容输出大指定文件)
成果

image.png

使用过程中遇到的问题
编码问题

出现编码问题的地方会有很多,具体还是要看报错.

  • 导致的问题

    1. 报错直接停止恢复
  • 解决方法

    1. 忽略该编码
    (self.schema_length - 1).decode("utf-8", "ignore")

image.png

建议

1.线上一定要记着开启binlog
2.再不济也要配置一下任务计划备份哦
3.有条件的伙伴可以使用XtraBackup进行备份

查看原文

赞 0 收藏 0 评论 0

胖猪猪 发布了文章 · 2020-12-15

onlyoffice踩坑记录

以下问题都是PHP环境下遇到的

1.因file_get_contents函数未正确配置导致的问题

  • 导致的问题

    1. 重新打开同一个文档 无限弹出 “文件版本已更改。页面将被重新加载”
    2. 在线编辑的内容未被真正的保存到服务器真实的文件中
    3. 在线编辑的内容保存后重新打开还是空白的
  • 解决方法

    1. 检测PHP配置中是否开启了 extension=php_openssl.dll 和 allow_url_fopen=on , 检测是否禁用了file_get_contents

    错误
    这个是因为file_get_contents未能够被正常开启,导致在保存回调中内容一直无法写入

2.因未正确配置apache环境下的Authorization

  • 导致的问题

    1. 开启token后文档无法打开一直弹出 “无法保存文档” 或 “下载失败”
  • 解决方法

    1. 这个是因为apache的原因 没办法正常的解析Authorization。在入口文件中.htaccess文件添加下面的代码
    <IfModule mod_rewrite.c>
        RewriteCond %{HTTP:Authorization} .
        RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
    </IfModule> 

3.服务器中间件(nginx OR apache)未设置文件下载跨域

  • 导致的问题

    1. 打开协作->版本历史无法正常使用,一直“正在打开文件”
  • 解决方法

    1. 这个原因是因为,Demo打开版本历史时会下载一个zip文件,但是因为没有跨域导致这个zip文件无法被正常下载.

    我们只需要在nginx或apache的对应域名的配置文件中,设置跨域即可,以下nginx举例

    server {
            listen        80;
            server_name  test.com;
            root   "C:/Users/root/Desktop/onlyoffice-php";
            add_header 'Access-Control-Allow-Origin' '*';
            add_header 'Access-Control-Allow-Methods' '*';
            add_header 'Access-Control-Allow-Headers' '*';
    }

4.因开启jwt导致历史记录无法正常打开

  • 导致的问题
    1.点历史版本弹出(官方例子也会这样子)
    image
    image
  • 原因方法
    1.这个是因为文档服务器开启了jwt验证,但您的历史数据又未携带上jwt生成的token导致的问题(2020.12.20官方的例子也会这样子哦)。
  • 解决方法
    1.在官方的例子上修改(php 例子), 打开doceditor.php->找到getHistory函数.红框内的就是生成一遍token.这样子就好了
    image

===========================分隔符=================

  1. 文件服务器对外的web项目路径是/var/www/onlyoffice/documentserver/web-apps/apps

知道这个路径,你可以直接在文档中注入js(你懂的)
里面js文件直接改是没用的哦,改完之后要把对应的.gz文件改名或删掉

查看原文

赞 0 收藏 0 评论 0

胖猪猪 收藏了文章 · 2020-11-17

你想知道的优惠券业务,SkrShop来告诉你

经过两年的更新「SkrShop」已经构成了下面的架构:

图中紫色的内容就是本编文章的主要内容:营销体系的基础服务「优惠券服务」。但是呢,首先要说的是关于不断被催更的事。

关于催更?

我给出了如下解释:人逢假日懒🤷‍♀️(我没错😭)、工作紧、需要保证质量,就酱。但是我一定能保证的是一直会更新下去,希望得到大家理解。

关于下期内容?

之前在Github上的Issues大家一致想看关于订单相关的内容,所以更新完本期「优惠券」之后就开始了订单之旅

Issues如下:

1. https://github.com/skr-shop/manuals/issues/25
2. https://github.com/skr-shop/manuals/issues/18

进入正题,营销体系的基础服务「优惠券服务」。通过如下问题来介绍优惠券:

  • 优惠券有哪些类型
  • 优惠券有哪些适用范围
  • 优惠券有哪些常见的场景
  • 优惠券服务要有哪些服务能力
  • 优惠券服务的风控怎么做?

优惠券有哪些类型?

对于获取优惠券的用户而言:关注的是优惠券的优惠能力,所以按优惠能力维度优惠券主要分为下面三类:

优惠能力维度描述
满减券满多少金额(不含邮费)可以减多少金额
现金券抵扣多少现金(无门槛)
抵扣券抵扣某Sku全部金额(一个数量)
折扣券打折

对于发放优惠券的运营人员而言:

一种是「固定有效期」,优惠券的生效时间戳和过期时间戳,在创建优惠券的时候已经确定。用户在任意时间领取该券,该券的有效时间都是之前设置的有效时间的开始结束时间。

另一种是「动态有效期」,创建优惠券设置的是有效时间段,比如7天有效时间、12小时有效时间等。这类优惠券以用户领取优惠券的时间为优惠券的有效时间的开始时间,以以用户领取优惠券的时间+有效时间为有效时间的结束时间。

有效期维度优惠券类型优惠券生效时间优惠券失效时间描述
固定固定有效期优惠券类型被创建时已确定优惠券类型被创建时已确定无论用户什么时间领取该优惠券,优惠券生效的时间都是设置好的统一时间
动态动态有效期用户领取优惠券时,当前时间戳用户领取优惠券时,当前时间戳 + N*24*60*60优惠券类型被创建时,只确定了该优惠券的有效,例如6小时、7天、一个月

小结如下:

优惠券有哪些适用范围?

运营策略

运营策略描述
(非)指定SkuSku券
(非)指定SpuSpu券
(非)指定类别类别券
指定店铺店铺券
全场通用平台券

适用终端

适用终端(复选框)描述
Android安卓端
iOSiOS端
PC网页电脑端
Mobile网页手机端
Wechat微信端
微信小程序微信小程序
All以上所有

适用人群

适用人群描述
白名单测试用户
会员会员专属

小结如下:

优惠券有哪些常见的场景?

领取优惠券场景

领取优惠券场景描述
活动页面大促、节假日活动页面展示获取优惠券的按钮
游戏页面通过游戏获取优惠券
店铺首页店铺首页展示领券入口
商品详情商品详情页面展示领券入口
积分中心积分兑换优惠券

展示优惠券场景

展示优惠券场景描述
活动页面大促、节假日活动页面展示可以领取的优惠券
商品详情商品详情页面展示可以领取、可以使用的优惠券列表
个人中心-我的优惠券我的优惠券列表
订单结算页面结算页面,适用该订单的优惠券列表以及推荐
积分中心展示可以兑换的优惠券详情

选择优惠券场景

选择优惠券场景描述
商品详情商品详情页面展示该用户已有的,且适用于该商品的优惠券
订单结算页面-优惠券列表选择可用优惠券结算
订单结算页面-输入优惠码输入优惠码结算

返还优惠券场景

返还优惠券场景描述
未支付订单取消未支付的订单,用户主动取消返还优惠券,或超时关单返还优惠券
已支付订单全款取消已支付的订单,订单部分退款不返还,当整个订单全部退款返还优惠券

场景示例

场景示例描述
活动页领券大促、节假日活动页面展示获取优惠券的按钮
游戏发券游戏奖励
商品页领券-
店铺页领券-
购物返券购买某个Sku,订单妥投后发放优惠券
新用户发券新用户注册发放优惠券
积分兑券积分换取优惠券

小结如下:

优惠券服务要有哪些服务能力?

服务能力1: 发放优惠券

发放方式描述
同步发放适用于用户点击领券等实时性要求较高的获取券场景
异步发放适用于实时性要求不高的发放券场景,比如新用户注册发券等场景
发放能力描述
单张发放指定一个优惠券类型ID,且指定一个UID只发一张该券
批量发放指定一个优惠券类型ID,且指定一批UID,每个UID只发一张该券
发放类型描述
优惠券类型标识通过该优惠券类型的身份标识发放,比如创建一个优惠券类型时会生成一个16位标识码,用户通过16位标识码领取优惠券;这里不使用自增ID(避免对外泄露历史创建了的优惠券数量),
优惠码code创建一个优惠券类型时,运营人员会给该券填写一个6位左右的Ascall码,比如SKR6a6,用户通过该码领取优惠券

服务能力2: 撤销优惠券

撤销能力描述
单张撤销指定一个优惠券类型ID,且指定一个UID只撤销一张该券
批量撤销指定一个优惠券类型ID,且指定一批UID,每个UID撤销一张该券

服务能力3: 查询优惠券

用户优惠券列表子类描述
全部-查询该用户所有的优惠券
可以使用全部查询该用户所有可以使用的优惠券
-适用于某个spu或sku查询该用户适用于某个spu或sku可以使用的优惠券
-适用于某个类别查询该用户适用于某个类别可以使用的优惠券
-适用于某个店铺查询该用户适用于某个店铺可以使用的优惠券
无效全部查询该用户所有无效的优惠券
-过期查询该用户所有过期的优惠券
-失效查询该用户所有失效的优惠券

服务能力4: 结算页优惠券推荐

订单结算页面推荐一张最适合该订单的优惠券

小结如下:

优惠券服务的风控怎么做?

一旦有发生风险的可能则触发风控:

  • 对用户,提示稍后再试或联系客服
  • 对内部,报警提示,核查校验报警是否存在问题

频率限制

领取描述
设备ID每天领取某优惠券的个数限制
UID每天领取某优惠券的个数限制
IP每天领取某优惠券的个数限制
使用描述
设备ID每天使用某优惠券的个数限制
UID每天使用某优惠券的个数限制
IP每天使用某优惠券的个数限制
手机号每天使用某优惠券的个数限制
邮编比如注重邮编的海外地区,每天使用某优惠券的个数限制

用户风险等级

依托用户历史订单数据,得到用户成功完成交易(比如成功妥投15天+)的比率,根据此比率对用户进行等级划分,高等级进入通行Unblock名单,低等级进入Block名单,根据不同用户级别设置限制策略。等其他大数据分析手段。

阈值

  • 发券预算
  • 实际使用券预算

根据预算值设置发券总数阈值,当触发阈值时阻断并报警。

优惠券不要支持虚拟商品

优惠券尽量不要支持虚拟商品以防止可能被利用的不法活动。


3911642037-d2bb08d8702e7c91_articlex.jpg

查看原文

胖猪猪 收藏了文章 · 2020-07-06

如何使用Git Rebase

我们在日常开发中使用 Git 做分支合并的时候有两种方式:merge 和 rebase。merge 是最常用的,rebase 使用的没有 merge 这么多,那么 rebase 和 merge 有什么区别呢?什么时候使用 rebase?使用 rebase 的时候有什么注意事项呢?接下来我来介绍下这三个问题。

基础

首先我们先了解下 merge 和 rebase 的运行机制有什么不同。假设我们有两个分支(master 和 feature)。feature 是基于 master 的 C1 节点建立的分支,然后开发人员分别在两个分支各自开发:

图片描述

merge

现在我们想要把 feature 分支开发的内容合并到 master,使用 merge 命令:

$ git checkout master
$ git merge feature

图片描述

Git 会把 C2 和 C3 的内容合并一下产生一个新的修改 C4,把 C4 提交到 master 分支。那么 master 就有了所有最新的代码(红色是 master 分支线,蓝色是 feature 分支线)。

rebase

我们回到还是未合并之前的状态,看看使用 rebase 合并会有什么效果。使用 rebase 有两种用法,先看第一种:

$ git checkout master
$ git rebase feature

图片描述

Git 把 C2 备份成 C2',删除 C2,然后把 C2' 追加到 C3 后面,把 master 指向 C2'。看图上红线,成为了新的 master,这条线上同样有所有的最新代码,起到的作用也是合并了两个分支。那么和 merge 有什么区别呢?就是历史时间线的区别。merge 保留了你所有的操作记录,而 rebase 把提交的修改节点变成了线性的时间线。如果分支 merge 很多的话,时间线会错综复杂,这个时候 rebase 的好处就出现了,对人肉追溯比较友好。

我们再看下合并两个分支 rebase 的第二种用法:

$ git checkout feature
$ git rebase master
$ git checkout master
$ git merge feature

图片描述

前两条命令我们先在 feature 上 rebase on master,产生的效果和第一种方法类似,只是把 feature 分支的改动追加到了 master 分支后面(红色是 master 分支线,蓝色是 feature 分支线)。

图片描述

后两条命令是把 feature 分支 merge 到 master 分支。由于 Git 发现不需要合并代码,只需要移动头指针就可以了,所以快速移动(fast forward)头指针到最前面,master 分支这时就有所有最新代码,同样起到了合并分支的作用。

那么第一种和第二种有什么区别呢?直观的看就是时间线上 C2,C3 顺序的区别,对于我们合并分支最终的意图是没有什么区别的。

注意点

如果你是一人开发,且只有本地仓库,那么上面 rebase 用哪种问题都不会太大。但是如果你是多人协作且有远程仓库,那么区别就巨大了。因为你合并完分支之后还需要 push 到远程仓库供别人使用,当你使用第一种方法后,C2 被放到了 C3 后面,你本地的 master 时间线变成了 C1 - C3 - C2',而远程仓库 master 的时间线是 C1 - C2。虽然 C2 和 C2' 是一样的,但是两者时间线历史完全不一样了,Git 认为你这是两个不一样的分支,不允许你 push。这个时候只能用 $ git push --force 强迫提交,用本地的 master 覆盖远程仓库的 master。但是 master 分支别人还在用啊,他们也需要同步 master 分支。但是别人本地的 master 分支并不知道你 rebase 过了远程仓库的 master,所以当他们 pull master 的时候,变成了如下时间线:

图片描述

其他人本地的 master 时间线上会出现两个 C2(C2 和 C2'),然后如果这人把上图的 master 再 push 到远程仓库,会带上上图所有的历史,多来几次以后整个仓库就没有办法看了。而第二种 rebase 的方法是把新的 commit 不断的追加到 master 后面,只要所有人在往 master 上合并代码的时候都遵循第二种方法,那么就不会产生极其混乱的时间线。

什么时候使用

按我们上面看到的,在现实的开发过程中,严格禁止在公共分支上 rebase on 其他分支(譬如不允许在 master 分支上直接运行 git rebase branchname)。使用 merge 是最保险的合并分支方式,如果你对时间线清晰度要求不是那么高的话。但是如果你对时间线的清晰程度有比较高的要求,那么在合并分支的时候按第二种方法 rebase 就能帮助形成清晰的线性时间线,但是 rebase 的坏处是丢失了一部分提交操作历史。

同步分支

除了合并不同分支这种情况,还有一个十分常见的情况,多人在同一个分支上开发,如何保证同一条分支具有清晰的时间线。我们假设有三个人在开发 feature 分支,通常开发习惯是:

  1. checkout feature 分支到本地。
  2. 开发,并把开发的内容提交到本地 feature。
  3. 使用 pull 把远程仓库中的 feature 和本地 feature 同步(pull 的默认方式是把远程的代码和本地代码做 merge 操作)。
  4. push 同步完的 feature 分支到远程仓库。

按上述步骤,我们看下时间线示意图:
先是第1,第2步

图片描述

用户1执行3,4步

图片描述

用户2执行3,4步

图片描述

用户3执行3,4步

图片描述

因为 pull 默认是通过 merge 远程仓库和本地仓库实现同步的,所以每次 pull 都会多出一个提交记录。但是我们可以通过指定参数来指定 pull 的时候使用 rebase 策略:$ git pull --rebase。让我们看下每次执行第3步的时候使用 pull rebase 那么会是什么样子。

用户1执行3,4步

图片描述

用户2执行3,4步

图片描述

用户3执行3,4步

图片描述

通过图示我们看到,如果使用 pull 的 rebase 模式,那么多人协作同一个分支也可以做到时间线清晰。最后再通过之前提到的 rebase 方式把 feature 合并到 master 分支上。那么整个开发过程时间线就呈现出清晰的时间线。

总结

  1. 和远程仓库同步当前分支的时候使用 pull --rebase 的方式。
  2. 合并分支的使用 feature rebase on master,master merge feature 的方式。
查看原文

胖猪猪 赞了文章 · 2020-07-06

如何使用Git Rebase

我们在日常开发中使用 Git 做分支合并的时候有两种方式:merge 和 rebase。merge 是最常用的,rebase 使用的没有 merge 这么多,那么 rebase 和 merge 有什么区别呢?什么时候使用 rebase?使用 rebase 的时候有什么注意事项呢?接下来我来介绍下这三个问题。

基础

首先我们先了解下 merge 和 rebase 的运行机制有什么不同。假设我们有两个分支(master 和 feature)。feature 是基于 master 的 C1 节点建立的分支,然后开发人员分别在两个分支各自开发:

图片描述

merge

现在我们想要把 feature 分支开发的内容合并到 master,使用 merge 命令:

$ git checkout master
$ git merge feature

图片描述

Git 会把 C2 和 C3 的内容合并一下产生一个新的修改 C4,把 C4 提交到 master 分支。那么 master 就有了所有最新的代码(红色是 master 分支线,蓝色是 feature 分支线)。

rebase

我们回到还是未合并之前的状态,看看使用 rebase 合并会有什么效果。使用 rebase 有两种用法,先看第一种:

$ git checkout master
$ git rebase feature

图片描述

Git 把 C2 备份成 C2',删除 C2,然后把 C2' 追加到 C3 后面,把 master 指向 C2'。看图上红线,成为了新的 master,这条线上同样有所有的最新代码,起到的作用也是合并了两个分支。那么和 merge 有什么区别呢?就是历史时间线的区别。merge 保留了你所有的操作记录,而 rebase 把提交的修改节点变成了线性的时间线。如果分支 merge 很多的话,时间线会错综复杂,这个时候 rebase 的好处就出现了,对人肉追溯比较友好。

我们再看下合并两个分支 rebase 的第二种用法:

$ git checkout feature
$ git rebase master
$ git checkout master
$ git merge feature

图片描述

前两条命令我们先在 feature 上 rebase on master,产生的效果和第一种方法类似,只是把 feature 分支的改动追加到了 master 分支后面(红色是 master 分支线,蓝色是 feature 分支线)。

图片描述

后两条命令是把 feature 分支 merge 到 master 分支。由于 Git 发现不需要合并代码,只需要移动头指针就可以了,所以快速移动(fast forward)头指针到最前面,master 分支这时就有所有最新代码,同样起到了合并分支的作用。

那么第一种和第二种有什么区别呢?直观的看就是时间线上 C2,C3 顺序的区别,对于我们合并分支最终的意图是没有什么区别的。

注意点

如果你是一人开发,且只有本地仓库,那么上面 rebase 用哪种问题都不会太大。但是如果你是多人协作且有远程仓库,那么区别就巨大了。因为你合并完分支之后还需要 push 到远程仓库供别人使用,当你使用第一种方法后,C2 被放到了 C3 后面,你本地的 master 时间线变成了 C1 - C3 - C2',而远程仓库 master 的时间线是 C1 - C2。虽然 C2 和 C2' 是一样的,但是两者时间线历史完全不一样了,Git 认为你这是两个不一样的分支,不允许你 push。这个时候只能用 $ git push --force 强迫提交,用本地的 master 覆盖远程仓库的 master。但是 master 分支别人还在用啊,他们也需要同步 master 分支。但是别人本地的 master 分支并不知道你 rebase 过了远程仓库的 master,所以当他们 pull master 的时候,变成了如下时间线:

图片描述

其他人本地的 master 时间线上会出现两个 C2(C2 和 C2'),然后如果这人把上图的 master 再 push 到远程仓库,会带上上图所有的历史,多来几次以后整个仓库就没有办法看了。而第二种 rebase 的方法是把新的 commit 不断的追加到 master 后面,只要所有人在往 master 上合并代码的时候都遵循第二种方法,那么就不会产生极其混乱的时间线。

什么时候使用

按我们上面看到的,在现实的开发过程中,严格禁止在公共分支上 rebase on 其他分支(譬如不允许在 master 分支上直接运行 git rebase branchname)。使用 merge 是最保险的合并分支方式,如果你对时间线清晰度要求不是那么高的话。但是如果你对时间线的清晰程度有比较高的要求,那么在合并分支的时候按第二种方法 rebase 就能帮助形成清晰的线性时间线,但是 rebase 的坏处是丢失了一部分提交操作历史。

同步分支

除了合并不同分支这种情况,还有一个十分常见的情况,多人在同一个分支上开发,如何保证同一条分支具有清晰的时间线。我们假设有三个人在开发 feature 分支,通常开发习惯是:

  1. checkout feature 分支到本地。
  2. 开发,并把开发的内容提交到本地 feature。
  3. 使用 pull 把远程仓库中的 feature 和本地 feature 同步(pull 的默认方式是把远程的代码和本地代码做 merge 操作)。
  4. push 同步完的 feature 分支到远程仓库。

按上述步骤,我们看下时间线示意图:
先是第1,第2步

图片描述

用户1执行3,4步

图片描述

用户2执行3,4步

图片描述

用户3执行3,4步

图片描述

因为 pull 默认是通过 merge 远程仓库和本地仓库实现同步的,所以每次 pull 都会多出一个提交记录。但是我们可以通过指定参数来指定 pull 的时候使用 rebase 策略:$ git pull --rebase。让我们看下每次执行第3步的时候使用 pull rebase 那么会是什么样子。

用户1执行3,4步

图片描述

用户2执行3,4步

图片描述

用户3执行3,4步

图片描述

通过图示我们看到,如果使用 pull 的 rebase 模式,那么多人协作同一个分支也可以做到时间线清晰。最后再通过之前提到的 rebase 方式把 feature 合并到 master 分支上。那么整个开发过程时间线就呈现出清晰的时间线。

总结

  1. 和远程仓库同步当前分支的时候使用 pull --rebase 的方式。
  2. 合并分支的使用 feature rebase on master,master merge feature 的方式。
查看原文

赞 10 收藏 8 评论 0

胖猪猪 收藏了文章 · 2019-10-26

在数据库中存储一棵树,实现无限级分类

原文发表于我的博客: https://blog.kaciras.com/article/6/store-tree-in-database

在一些系统中,对内容进行分类是必需的功能。比如电商就需要对商品做分类处理,以便于客户搜索;论坛也会分为很多板块;门户网站、也得对网站的内容做各种分类。

分类对于一个内容展示系统来说是不可缺少的,本博客也需要这么一个功能。众所周知,分类往往具有从属关系,比如铅笔盒钢笔属于笔,笔又是文具的一种,当然钢笔还可以按品牌来细分,每个品牌下面还有各种系列...

这个例子中从属关系具有5层,从上到下依次是:文具-笔-钢笔-XX牌-A系列,但实际中分类的层数却是无法估计的,比如生物中的界门纲目科属种有7级。显然对分类的级数做限制是不合理的,一个良好的分类系统,其应当能实现无限级分类。

本博客的分类标签

在写自己的博客网站时,刚好需要这么一个功能,听起来很简单,但是在实现时却发现,用关系数据库存储无限级分类并非易事。

1.需求分析

首先分析一下分类之间的关系是怎样的,很明显,一个分类下面有好多个下级分类,比如笔下面有铅笔和钢笔;那么反过来,一个下级分类能够属于几个上级分类呢?这其实并不确定,取决于如何对类型的划分。比如有办公用品和家具,那么办公桌可以同时属于这两者,不过这会带来一些问题,比如:我要显示从顶级分类到它之间的所有分类,那么这种情况下就很难决定办公用品和家具显示哪一个,并且如果是多对一,实现上将更加复杂,所以这里还是限定每个分类仅有一个上级分类。

现在,分类的关系可以表述为一父多子的继承关系,正好对应数据结构中的树,那么问题就转化成了如何在数据库中存储一棵树,并且对分类所需要的操作有较好的支持。

对于本博客来说,分类至少需要以下操作:

  1. 对单个分类的增删改查等基本操作
  2. 查询一个分类的直属下级和所有下级,在现实某一分类下所有文章时需要使用
  3. 查询出由顶级分类到文章所在分类之间的所有分类,并且是有序的,用于显示在博客首页文章的简介的左下角
  4. 查询分类是哪一级的,比如顶级分类是1,顶级分类的直属下级是2,再往下依次递增
  5. 移动一个分类,换句话说就是把一个子树(或者仅单个节点)移动到另外的节点下面,这个在分类结构不合理,需要修改时使用
  6. 查询某一级的所有分类

在性能的衡量上,这些操作并不是平等的。查询操作使用的更加频繁,毕竟分类不会没事就改着玩,性能考虑要以查询操作优先,特别是2和3分别用于搜索文章和在文章简介中显示其分类,所以是重中之重。

另外,每个分类除了继承关系外,还有名字,简介等属性,也需要存储在数据库中。每个分类都有一个id,由数据库自动生成(自增主键)。

无限级多分类多存在于企业应用中,比如电商、博客平台等,这些应用里一般都有缓存机制,对于分类这种不频繁修改的数据,即使在底层数据库中存在缓慢的操作,只要上层缓存能够命中,一样有很快的响应速度。但是对于抽象的需求:在关系数据库中存储一棵树,并不仅仅存在于有缓存的应用中,所以设计一个高效的存储方案,仍然有其意义。

下面就以这个卖文具的电商的场景为例,针对这6条需求,设计一个数据库存储方案(对过程没兴趣可以直接转到第4节)。

2.一些常见设计方案的不足

2.1 直接记录父分类的引用

在许多编程语言中,继承关系都是一个类仅继承于一个父类,添加这种继承关系仅需要声明一下父类即可,比如JAVA中extends xxx。根据这种思想,我们仅需要在每个分类上添加上直属上级的id,即可存储它们之间的继承关系。

父id字段存储继承关系

表中parent即为直属上级分类的id,顶级分类设为0。这种方案简单易懂,仅存在一张表,并且没有冗余字段,存储空间占用极小,在数据库层面是一种很好的设计。

那么再看看对操作的支持情况怎么样,第一条单个增改查都是一句话完事就不多说了,删除的话记得把下级分类的id全部改成被删除分类的上级分类即可,也就多一个UPDATE。

第二条可就麻烦了,比如我要查文具的下级分类,预期结果是笔、铅笔、钢笔三个,但是并没有办法通过文具一次性就查到铅笔盒钢笔,因为这两者的关系间接存储在笔这个分类里,需要先查出直属下级(笔),才能够往下查询,这意味着需要递归,性能上一下子就差了很多。

第三条同样需要递归,因为通过一个分类,数据库中只存储了其直属父类,需要通过递归到顶级分类才能获取到它们之间的所有分类信息。

综上所述,最关键的两个需求都需要使用性能最差的递归方式,这种设计肯定是不行的。但还是继续看看剩下的几条吧。

第4个需求:查询分类是哪一级的?这个还是得需要递归或循环,查出所有上级分类的数量即为分类的层级。

移动分类倒是非常简单,直接更新父id即可,这也是这种方案的唯一优势了吧...如果你的分类修改比查询还多不妨就这么做吧。

最后一个查询某一级的所有分类,对于这个设计简直是灾难,它需要先找出所有一级分类,然后循环一遍,找出所有一级分类的子类就是二级分类...如此循环直到所需的级数为之。所以这种设计里,这个功能基本是废了。

这个方式也是一开始就能想到的,在数据量不大(层级不深)的情况下,因为其简单直观的特点,不失为一个好的选择,不过对于本项目来说还不够(本项目立志成为一流博客平台!!!)。

2.2 路径列表

从2.1节中可以看出,__之所以速度慢,就是因为在分类中仅仅存储了直属上级的关系,而需求却要查询出非直属上级。__针对这一点,我们的表中不仅仅记录父节点id,而是将它到顶级分类之间所有分类的id都保存下来。这个字段所保存的信息就像文件树里的路径一样,所以就叫做path吧。

路径列表设计

如图所示,每个分类保存了它所有上级分类的列表,用逗号隔开,从左往右依次是从顶级分类到父分类的id。

查询下级时使用Like运算符来查找,比如查出所有笔的下级:

SELECT id,name FROM pathlist WHERE path LIKE '1,%'

一句话搞定,LIKE的右边是笔的path字段的值加上模糊匹配,并且左联接能够使用索引,的效率也过得去。

查询笔的直属下级也同样可以用LIKE搞定:

SELECT id,name FROM pathlist WHERE path LIKE '%2'

而找出所有上级分类这个需求,直接查出path字段,然后在应用层里分割一下即可获得获得所有上级,并且顺序也能保证。

查询某一级的分类也简单,因为层级越深,path就越长,使用LENGTH()函数作为条件即可筛选出合适的结果。反过来,根据其长度也能够计算出分类的级别。

移动操作需要递归,因为每一个分类的path都是它父类的path加上父类的id,将分类及其所有子分类的path设为其父类的path并在最后追加父类的id即可。

在许多系统中都使用了这种方案,其各方面都具有可以接受的性能,理解起来也比较容易。但是其有两点不足:1.就是不遵守数据库范式,将列表数据直接作为字符串来存储,这将导致一些操作需要在上层解析path字段的值;2.就是字段长度是有限的,不能真正达到无限级深度,并且大字段对索引不利。如果你不在乎什么范式,分类层级也远达不到字段长度的限制,那么这种方案是可行的。

2.3 前序遍历树

这是一种在数据库里存储一棵树的解决方案。它的思想不是直接存储父节点的id,而是以前序遍历中的顺序来判断分类直接的关系。

前序遍历树

假设从根节点开始以前序遍历的方式依次访问这棵树中的节点,最开始的节点(“Food”)第一个被访问,将它左边设为1,然后按照顺序到了第二个阶段“Fruit”,给它的左边写上2,每访问一个节点数字就递增,访问到叶节点后返回,在返回的过程中将访问过的节点右边写也写上数字。这样,在遍历中给每个节点的左边和右边都写上了数字。最后,我们回到了根节点“Food”在右边写上18。下面是标上了数字的树,同时把遍历的顺序用箭头标出来了。

我们称这些数字为左值和右值(如,“Meat”的左值是12,右值是17),这些数字包含了节点之间的关系。因为“Red”有3和6两个值,所以,它是有拥有1-18值的“Food”节点的后续。同样的,可以推断所有左值大于2并且右值小于11的节点,都是有2-11的“Fruit” 节点的后续。这样,树的结构就通过左值和右值储存下来了。

这里就不贴表结构了,这种方式不如前面两种直观。效率上,查询全部下级的需求被很好的解决,而直属下级也可以通过添加父节点id的parent字段来解决。

因为左值更大右值更小的节点是下级节点,反之左值更小、右值更大的就是上级,故需求3:查询两个分类直接的所有分类可以通过左右值作为条件来解决,同样是一次查询即可。

添加新分类和删除分类需要修改在前序遍历中所有在指定节点之后的节点,甚至包括非父子节点。而移动分类也是如此,这个特性就非常不友好,在数据量大的情况下,改动一下可是很要命的。

查询某一级的所有分类,和查询分类是哪一级的,这两个需求无法解决,只能通过parent字段想第一种方式一样慢慢遍历。

综上所述,对于本项目而言,它还不如第二种,所以这个很复杂的方案也得否决掉。

3.新方案的思考

上面几种方案最接近理想的就是第二种,如果能解决字段长度问题和不符合范式,以及需要上层参与处理的问题就好了。不过不要急,先看看第二种方案的的优缺点的本质是什么。

在分析第二种方案的开头就提到,要确保效率,必须要在分类的信息中包含所有上级分类的信息,而不能仅仅只含有直属上级,所以才有了用一个varchar保存列表的字段。但反过来想想,数据库的表本身不就是用来保存列表这样结构化数据集合的工具吗,为何不能做一张关联表来代替path字段呢?

在路径列表的设计中,关键字段path的本质是存储了两种信息:一是所有上级分类的id,二是从顶级分类到每个父分类的距离。 所以另增一张表,含有三个字段:一个是本分类的所有上级的id,一个是本分类的id,再加上该分类到每个上级分类的距离。这样这张表就能够起到与path字段相同的作用,而且还不违反数据库范式,最关键的是它不存在字段长度的限制!

经过一番折腾,终于找到了这个比较完美的方案。事实上在之后的查阅资料中,发现这个方案早就在一些系统中使用了,名叫ClosureTable。

4.基于ClosureTable的无限级分类存储

ClosureTable直译过来叫闭包表?不过不重要,ClosureTable以一张表存储节点之间的关系、其中包含了任何两个有关系(上下级)节点的关联信息

ClosureTable演示

定义关系表CategoryTree,其包含3个字段:

  • ancestor 祖先:上级节点的id
  • descendant 子代:下级节点的id
  • distance 距离:子代到祖先中间隔了几级

这三个字段的组合是唯一的,因为在树中,一条路径可以标识一个节点,所以可以直接把它们的组合作为主键。以图为例,节点6到它上一级的节点(节点4)距离为1在数据库中存储为ancestor=4,descendant=6,distance=1,到上两级的节点(节点1)距离为2,于是有ancestor=1,descendant=6,distance=2,到根节点的距离为3也是如此,最后还要包含一个到自己的连接,当然距离就是0了。

这样一来,不尽表中包含了所有的路径信息,还在带上了路径中每个节点的位置(距离),对于树结构常用的查询都能够很方便的处理。下面看看如何用用它来实现我们的需求。

4.1 子节点查询

查询id为5的节点的直属子节点:

SELECT descendant FROM CategoryTree WHERE ancestor=5 AND distance=1

查询所有子节点:

SELECT descendant FROM CategoryTree WHERE ancestor=5 AND distance>0

查询某个上级节点的子节点,换句话说就是查询具有指定上级节点的节点,也就是ancestor字段等于上级节点id即可,第二个距离distance决定了查询的对象是由上级往下那一层的,等于1就是往下一层(直属子节点),大于0就是所有子节点。这两个查询都是一句完成。

4.2 路径查询

查询由根节点到id为10的节点之间的所有节点,按照层级由小到大排序

SELECT ancestor FROM CategoryTree WHERE descendant=10 ORDER BY distance DESC

查询id为10的节点(含)到id为3的节点(不含)之间的所有节点,按照层级由小到大排序

SELECT ancestor FROM CategoryTree WHERE descendant=10 AND 
distance<(SELECT distance FROM CategoryTree WHERE descendant=10 AND ancestor=3) 
ORDER BY distance DESC

查询路径,只需要知道descendant即可,因为descendant字段所在的行就是记录这个节点与其上级节点的关系。如果要过滤掉一些则可以限制distance的值。

4.3 查询节点所在的层级(深度)

查询id为5的节点是第几级的

SELECT distance FROM CategoryTree WHERE descendant=5 AND ancestor=0

查询id为5的节点是id为10的节点往下第几级

SELECT distance FROM CategoryTree WHERE descendant=5 AND ancestor=10

查询层级(深度)非常简单,因为distance字段就是。直接以上下级的节点id为条件,查询距离即可。

4.4 查询某一层的所有节点

查询所有第三层的节点

SELECT descendant FROM CategoryTree WHERE ancestor=0 AND distance=3

这个就不详细说了,非常简单。

4.5 插入

插入和移动就不是那么方便了,当一个节点插入到某个父节点下方时,它将具有与父节点相似的路径,然后再加上一个自身连接即可。

所以插入操作需要两条语句,第一条复制父节点的所有记录,并把这些记录的distance加一,因为子节点到每个上级节点的距离都比它的父节点多一。当然descendant也要改成自己的。

例如把id为10的节点插入到id为5的节点下方(这里用了Mysql的方言)

INSERT INTO CategoryTree(ancestor,descendant,distance) (SELECT ancestor,10,distance+1 FROM CategoryTree WHERE descendant=5)

然后就是加入自身连接的记录。

INSERT INTO CategoryTree(ancestor,descendant,distance) VALUES(10,10,0)

4.6 移动

节点的移动没有很好的解决方法,因为新位置所在的深度、路径都可能不一样,这就导致移动操作不是仅靠UPDATE语句能完成的,这里选择删除+插入实现移动。

另外,在有子树的情况下,上级节点的移动还将导致下级节点的路径改变,所以移动上级节点之后还需要修复下级节点的记录,这就需要递归所有下级节点。

删除id=5节点的所有记录

DELETE FROM CategoryTree WHERE descendant=5

然后配合上面一节的插入操作实现移动。具体的实现直接上代码吧。

ClosureTableCategoryStore.java是主要的逻辑,这里只展示部分代码

    /**
     * 将一个分类移动到目标分类下面(成为其子分类)。被移动分类的子类将自动上浮(成为指定分类
     * 父类的子分类),即使目标是指定分类原本的父类。
     * <p>
     * 例如下图(省略顶级分类):
     *       1                                     1
     *       |                                   / | \
     *       2                                  3  4  5
     *     / | \             move(2,7)               / \
     *    3  4  5         --------------->          6   7
     *         / \                                 /  / | \
     *       6    7                               8  9  10 2
     *      /    /  \
     *     8    9    10
     *
     * @param id 被移动分类的id
     * @param target 目标分类的id
     * @throws IllegalArgumentException 如果id或target所表示的分类不存在、或id==target
     */
    public void move(int id, int target) {
        if(id == target) {
            throw new IllegalArgumentException("不能移动到自己下面");
        }
        moveSubTree(id, categoryMapper.selectAncestor(id, 1));
        moveNode(id, target);
    }

    /**
     * 将一个分类移动到目标分类下面(成为其子分类),被移动分类的子分类也会随着移动。
     * 如果目标分类是被移动分类的子类,则先将目标分类(连带子类)移动到被移动分类原来的
     * 的位置,再移动需要被移动的分类。
     * <p>
     * 例如下图(省略顶级分类):
     *       1                                     1
     *       |                                     |
     *       2                                     7
     *     / | \           moveTree(2,7)         / | \
     *    3  4  5         --------------->      9  10  2
     *         / \                                   / | \
     *       6    7                                 3  4  5
     *      /    /  \                                     |
     *     8    9    10                                   6
     *                                                    |
     *                                                    8
     *
     * @param id 被移动分类的id
     * @param target 目标分类的id
     * @throws IllegalArgumentException 如果id或target所表示的分类不存在、或id==target
     */
    public void moveTree(int id, int target) {
        /* 移动分移到自己子树下和无关节点下两种情况 */
        Integer distance = categoryMapper.selectDistance(id, target);
        if (distance == null) {
            // 移动到父节点或其他无关系节点,不需要做额外动作
        } else if (distance == 0) {
            throw new IllegalArgumentException("不能移动到自己下面");
        } else {
            // 如果移动的目标是其子类,需要先把子类移动到本类的位置
            int parent = categoryMapper.selectAncestor(id, 1);
            moveNode(target, parent);
            moveSubTree(target, target);
        }

        moveNode(id, target);
        moveSubTree(id, id);
    }

    /**
     * 将指定节点移动到另某节点下面,该方法不修改子节点的相关记录,
     * 为了保证数据的完整性,需要与moveSubTree()方法配合使用。
     *
     * @param id 指定节点id
     * @param parent 某节点id
     */
    private void moveNode(int id, int parent) {
        categoryMapper.deletePath(id);
        categoryMapper.insertPath(id, parent);
        categoryMapper.insertNode(id);
    }

    /**
     * 将指定节点的所有子树移动到某节点下
     * 如果两个参数相同,则相当于重建子树,用于父节点移动后更新路径
     *
     * @param id     指定节点id
     * @param parent 某节点id
     */
    private void moveSubTree(int id, int parent) {
        int[] subs = categoryMapper.selectSubId(id);
        for (int sub : subs) {
            moveNode(sub, parent);
            moveSubTree(sub, sub);
        }
    }

其中的categoryMapper 是Mybatis的Mapper,这里只展示部分代码

    /**
     * 查询某个节点的第N级父节点。如果id指定的节点不存在、操作错误或是数据库被外部修改,
     * 则可能查询不到父节点,此时返回null。
     *
     * @param id 节点id
     * @param n 祖先距离(0表示自己,1表示直属父节点)
     * @return 父节点id,如果不存在则返回null
     */
    @Select("SELECT ancestor FROM CategoryTree WHERE descendant=#{id} AND distance=#{n}")
    Integer selectAncestor(@Param("id") int id, @Param("n") int n);

    /**
     * 复制父节点的路径结构,并修改descendant和distance
     *
     * @param id 节点id
     * @param parent 父节点id
     */
    @Insert("INSERT INTO CategoryTree(ancestor,descendant,distance) " +
            "(SELECT ancestor,#{id},distance+1 FROM CategoryTree WHERE descendant=#{parent})")
    void insertPath(@Param("id") int id, @Param("parent") int parent);

    /**
     * 在关系表中插入对自身的连接
     *
     * @param id 节点id
     */
    @Insert("INSERT INTO CategoryTree(ancestor,descendant,distance) VALUES(#{id},#{id},0)")
    void insertNode(int id);

    /**
     * 从树中删除某节点的路径。注意指定的节点可能存在子树,而子树的节点在该节点之上的路径并没有改变,
     * 所以使用该方法后还必须手动修改子节点的路径以确保树的正确性
     *
     * @param id 节点id
     */
    @Delete("DELETE FROM CategoryTree WHERE descendant=#{id}")
    void deletePath(int id);

5.结语

在分析推论后,终于找到了一种既有查询简单、效率高等优点,也符合数据库设计范式,而且是真正的无限级分类的设计。本方案的写入操作虽然需要递归,但相比于前序遍历树效率仍高出许多,并且在本博客系统中分类不会频繁修改。可见对于在关系数据库中存储一棵树的需求,ClosureTable是一种比较完美的解决方案。

完整的JAVA实现代码见 https://github.com/Kaciras/ClosureTableCateogryStore

查看原文

认证与成就

  • 获得 0 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-12-13
个人主页被 366 人浏览