My studying notes for Java,Ruby,Ajax and other any interesting things.

星期二, 八月 01, 2017

mysql性能问题排查及故障诊断思路


思路:
——————————————————————— 
1.找到瓶颈处
2.针对瓶颈进行优化
3.数据库优化策略分为三步走:
  • 配置优化,cpu,内存,io
  • 表结构优化:表引擎,索引,表拆分(大表转小表,减少大字段,控制表数量及表大小),分表分库,主从分离,读写分离,多主
  • SQL优化:简化SQL,减少join,减少sql计算,减少存储过程,使用preparedstatement,找到慢sql逐个优化
4.程序优化:
  • 减少事务,降低锁的概率
  • 使用缓存应对读(但要保证数据的及时性)
  • 异步写操作(但要保证事务顺序和成功)


实践:
——————————————————————— 
寻找瓶颈,有两个思路:
  • 根据监控找到耗时较大的功能、业务:
    • 程序级别的监控,每个请求的时间记录,access_log或者程序在某个controller的切面中将时间打印出来;
    • 根据用户反馈,这个往往不准,因为用户看到慢的时候可能已经形成了雪崩效应;
    • 捋日志,找第一个慢的,或者找到累计最多的,然后单独测试该功能复现和模拟;
    • 原因可能会很多:比如数据库锁住了,内存不够,SQL性能太低....根据原因找;
  • 根据性能监控,查看资源使用情况
    • 在应用服务器,数据库服务器多方面监控资源使用情况,核心关注CPU,内存,IO和网络三点的使用情况
    • CPU忙:
      • 通常在进行计算,计算包括数据处理,图像,正则,科学计算等;从直观上来看,就是循环多,有很多for循环..
      • 常用监控命令:vmstat,mpstat
      • 如果要看某个进程的情况,可以使用"while :; do ps -eo pid,ni,pri,pcpu,psr,comm | grep 'firefox'; sleep 1; done" 类似的语句来查看
    • IO忙:大数据load到内存中,比如数据库、文件检索,本身匹配并不耗CPU,但是数据比较大,比较多需要频繁换页;
      • 监控换页数量,io读写次数,以及iowait数量
      • 常用命令:iostat,lsof
      • 需要注意,swap其实挺慢的,可以通过top、cat /proc/meminfo、vmstat 来监控swap的使用情况;
    • 网络监控:
      • 监控起来最复杂,主要监控网络流出和流入情况,以及丢包、延迟等;
      • 在linux下面可以使用:iptraf,netperf,tcpdump,tcptrace,tcpcopy
  • 工具:salt+zabbix,negios等都可以实现细粒度的监控;
  • 压力测试:针对某些怀疑的点进行压力测试,模拟发现对应的问题,同时检查系统的吞吐能力;
  • 常用工具及命令:Vmstat、sar、iostat、netstat、free、ps、top等
  • 常用组合方式
    • 用vmstat、sar、iostat检测是否是CPU瓶颈
    • 用free、vmstat检测是否是内存瓶颈
    • 用iostat检测是否是磁盘I/O瓶颈
    • 用netstat检测是否是网络带宽瓶颈

针对瓶颈优化:
  • 不同的问题原因不同,优化方案也不一样,通常来讲,分为代码优化和配置优化两条腿,配置优化又分为操作系统,服务和应用的优化。

mysql配置优化:
————————— 
  • 安装以后在mysql的配置文件中有:my-huge.cnf,my-mediam.cnf,my-small.cnf,可以根据需要自己拷贝一份做修改;/usr/local/share/mysql/
  • innodb_buffer_pool_size:数据缓冲池,是数据和索引缓存的地方,这个值越大越好,这能保证你在大多数的读取操作时使用的是内存而不是硬盘。典型的值是5-6GB(8GB内存),20-25GB(32GB内存),100-120GB(128GB内存);根据内存情况来设定。
  • innodb_log_file_size:redo日志的大小,用于确保写操作快速可靠,且在崩溃的时候可以恢复;默认512M,4G比较合适;
  • max_connections:最大连接数,默认151,根据实际情况扩大,但要注意程序端不会无限的增加(连接池)
  • innodb专属设置:
    • innodb_file_per_table,建议为on,每个表单独有自己的idb文件,可以再drop,truncate或者delete的时候会收表空间,如果表特别多的话,不建议使用(比如超过10k)
    • innodb_flush_log_at_trx_commit,是否支持事务一致性,通常为1,当性能要求大于数据一致性要求是可以设为0,比如从库;
    • innodb_flush_method,数据和日志写入方式,如果有raid,且不会down机,使用O_Driect,否则用fdatasync,可以用sysbench来测试;
    • inodb_log_buffer_size,为未执行的事务分配的缓存,默认1M,可以根据innodb_log_waits状态来看,如果不够的话可以适当增加;
  • 其他设置:
    • query_cache_size,查询缓存,这个可能会有坑,建议设为0,通过其他方式来提升缓存;
    • 设定主从:log_bin,
    • 是否跳过域名解析:skip_name_resolve,这样数据中只能使用ip来进行授权;
    • table_cache,表缓存,可以根据open_tables(show global status)来进行优化

mysql性能诊断命令:
  • show global status;show status like 'xxx';
  • show variables
  • show innodb status
  • show processlist
  • show tables status
  • show processlist
  • 事务和锁的检查
mysql的慢查询开启:
  • slow_query_log,设置为on
  • long_query_time,超时时间,比如设置为1,就是1s
  • slow_query_log_file,记录慢日志的文件
  • log_queries_not_using_indexes,记录没有使用到索引的查询语句

根据某个语句进行优化:
  • desc(explain) 某条语句,查看执行计划,可以看到该语句是否使用了索引,临时表、文件排序检索行数等;
  • 设置profiling,然后执行语句,查看profiling的日志:set profiling=1;execute sql;show profiles;show profile for query 1;


mysql结构优化:
————————— 
  • 每个表有主键,
  • 字段简单:
    • 类型选择好,
      • 数字:尽量用整型,包括枚举、时间、ip等都可以数字化,decimal少用
      • 文本:varchar/char,禁用text
      • 时间:性能考虑可以用bigint,否则用timestamp(不用datetime)
    • 二进制、文本、图片等不要用数据库存储,大字段可以分开表存放,减少io;
    • 字符集编码统一,join的时候可能会出现字符集不匹配无法使用索引的问题;
    • 尽量not null
    • 可以适当冗余字段,减少join
    • 拆表拆库:主要将资源分配到更多cpu和内存上,对结构简化有帮助
    • 单表不超过:1000w,有字符串的尽量在500w以内,单库不超过400张表;字段在30-50个;
    • 每个表都有主键,且建议为数据库自增int
使用语句优化:
————————— 
  • 原则:每个sql尽量小,尽量不用大事务,
  • 尽量每条语句都要是用到索引
  • 语句中尽量少join
  • 在语句中少使用子查询,如果一定要,用join比子查询好;
  • 不用函数或在SQL中进行计算,包括类型转换
  • 少用or
  • union all 代替union
  • 少select *,而是需要什么查询什么
  • 尽早过滤
  • 尽量少排序
  • 优先优化数量多,性能没那么好的SQL;再优化频率低,杀伤力大的sql
  • 大量数据插入用load data,且放到低峰期执行
索引创建合理:
  • 索引数量不是越多越好,而是用得越多越好
  • 索引字段不要在sql中计算,可能无法使用索引
  • 尽量不用外键等约束,由程序来保障,避免高并发死锁

程序优化:
——————————————————— 
1.控制事务大小
2.充分利用应用服务器的资源
3.在应用端缓存,分布式、文件、本地都可以




Refer:

星期一, 七月 31, 2017

tmux使用简单记录

简单研究tmux的使用,类似于screen,不过有分屏功能:

有个详细的文章: tmux终端复用


大体有几个关键:
1.安装,mac下面brew install tmux,linux下面用yum或者apt-get都可以正常安装
2.基本使用:

创建和连接session:
  tmux new -s session_name
  tmux a -t session_name
  ctrl+b d, detache一个session

创建新的panel:
  ctrl+b %,纵向拆分一个面板
  ctrl+b " 横向拆分一个面板
  如果有鼠标模式,可以鼠标点击进入不同的panel;
  如果没有鼠标,可以使用ctrl+b 方向键,来切换不同的panel


创建新窗口:
  ctrl+b c 创建一个新的窗口
  ctrl+b n,p 进入上一个或者下一个窗口


3.配置:
在~/.tmux.conf中编辑相关配置,可以设置相关的键盘绑定,基本上默认即可,此处有个简单参考 : http://www.opstool.com/article/253


这里有个文章说如何打造更好的tmux配置: https://segmentfault.com/a/1190000008188987

tmux使用简单记录

一直使用screen,但是在远程的时候开多个窗口比较麻烦,用iterm2的cmd+d拆分也可以实现,但会产生多个ssh链接。无意中发现tmux,试用了一下感觉不错,简单记录。

tmux使用简单记录

简单研究tmux的使用,类似于screen,不过有分屏功能:

有个详细的文章: tmux终端复用


大体有几个关键:
1.安装,mac下面brew install tmux,linux下面用yum或者apt-get都可以正常安装
2.基本使用:

创建和连接session:
  tmux new -s session_name
  tmux a -t session_name
  ctrl+b d, detache一个session

创建新的panel:
  ctrl+b %,纵向拆分一个面板
  ctrl+b " 横向拆分一个面板
  如果有鼠标模式,可以鼠标点击进入不同的panel;
  如果没有鼠标,可以使用ctrl+b 方向键,来切换不同的panel


创建新窗口:
  ctrl+b c 创建一个新的窗口
  ctrl+b n,p 进入上一个或者下一个窗口


3.配置:
在~/.tmux.conf中编辑相关配置,可以设置相关的键盘绑定,基本上默认即可,此处有个简单参考 : http://www.opstool.com/article/253


这里有个文章说如何打造更好的tmux配置: https://segmentfault.com/a/1190000008188987
印象笔记,让记忆永存。下载印象笔记

星期二, 五月 16, 2017

windows下imagemagick水印处理脚本



近期在使用imagemagick处理水印,mac和linux下面都很实用,一个同事是windows系统,也需要类似功能。于是做了一个windows下面的脚本,同时学习了一下windows的bat处理脚本。(真心不好用)

大概记录一下脚本和流程:

— 脚本1:watermark.bat,负责调度和创建目录
echo off
SET IDTY="C:\Program Files\ImageMagick-7.0.5-Q16\identify.exe"
SET SOURCE_DIR="source"
SET LOGO="C:\windows.logo\logo.png"
SET TARGET_DIR="out"

%IDTY% -format "%%[fx:w] %%[fx:h]" %LOGO% > logo.txt

for /f "tokens=1,2" %%a in (logo.txt) do set lw=%%a&set lh=%%b


for /r %SOURCE_DIR% %%i in (*) do if not exist %TARGET_DIR%%%~pi mkdir %TARGET_DIR%%%~pi

for /r %SOURCE_DIR% %%i in (*) do (
      call logo.bat %%i
)

— logo.bat,负责进行logo处理和输出:

SET TARGET_DIR="out"
SET IMCONV="C:\Program Files\ImageMagick-7.0.5-Q16\convert.exe"
SET IDTY="C:\Program Files\ImageMagick-7.0.5-Q16\identify.exe"
SET SOURCE_DIR="source"
SET TARGET_DIR="out"
SET LOGO="C:\Users\zhangwenhui\Desktop\windows.logo++\logo.png"

for /f "tokens=1,2" %%a in (logo.txt) do set lw=%%a&set lh=%%b

@rem echo %lw% %lh%

%IDTY% -format "%%[fx:w] %%[fx:h]" %1 > %1.txt
for /f "tokens=1,2" %%a in (%1.txt) do set w=%%a&set h=%%b
del %1.txt
set /a width=%w%*30/100
set /a height=%lh%*%width%/%lw%
@rem echo w:%w% ,width:%width%,height:%height%

composite -gravity southeast ( %LOGO% -resize %width%x%height% ) %1 %TARGET_DIR%\%~pn1.jpg


有几点注意:
1.windows的bat脚本中的%(百分号)是个坑:
  case1:
    set xx='asdfbc'
    引用xx的时候使用%xx%
   case2:
    在命令行中 for /r /d %d in(*)  do echo %d
    在for里面使用的时候需要用%d的形式访问,然后在后续使用该变量的时候可以使用%d进行访问
  case3:
    在批处理脚本中的for,需要加两个百分号
    for /r /d %%d  in (*) do echo %%d
      如果在for中再进行set xx,就基本上搞不正确了,所以使用了call来调用另外一个脚本;

    2.批处理的执行顺序,在windows的bat处理的时候,感觉会类似递归的调用顺序,没有搞太明白。比如 block a中的block b,在执行的时候是block a把每个block单独输出到命令行被执行,而不是有一个解释器把整个脚本进行调度。
    3.bat中有替换功能,但是好像只能针对%a%的变量,而不能针对%a这样的变量
    4.imagemagick在windows上面默认不安装legacy utilities,所以convert,composite等功能都无法调用,在安装的时候需要选中;在composite的时候与mac略有不同,不需要使用转义括号。
    5.目录中如果有空格,则需要使用引号给引起来;

    印象笔记,让记忆永存。下载印象笔记

    星期三, 十二月 14, 2016

    shell中的if判断

    #!/bin/sh        myPath="/var/log/httpd/"    myFile="/var /log/httpd/access.log"        #这里的-x 参数判断$myPath是否存在并且是否具有可执行权限    if [ ! -x "$myPath"]; then      mkdir "$myPath"    fi    

    #这里的
    -d 参数判断$myPath是否存在  if [ ! -d "$myPath"]; then    mkdir "$myPath"  fi    #这里的-f参数判断$myFile是否存在  if [ ! -f "$myFile" ]; then    touch "$myFile"  fi    #其他参数还有-n,-n是判断一个变量是否是否有值  if [ ! -n "$myVar" ]; then    echo "$myVar is empty"    exit 0  fi    #两个变量判断是否相等  if [ "$var1" = "$var2" ]; then    echo '$var1 eq $var2'  else    echo '$var1 not eq $var2'  fi 


    印象笔记,让记忆永存。下载印象笔记

    星期四, 十二月 01, 2016

    java8的Stream API

    java8中增加了Stream API,在coding过程中经常使用到,每次用的时候总觉的有些新意,没有系统的学习和研究一下。

    今天抽时间,研究一下看看stream api的具体功能和用法:


    总览:
    • stream是java8的一个心功能,这个流与java.io中的流的概念不同,而是对Collection集合类的一个增强,专注与如何高效和便利的处理java集合(包括list,set,map...)
    • stream在处理的时候利用到了新的Lambda表达式(函数式),链式调用,实现了计算时加载,可以处理更大量的数据,同时代码的可读性增强,代码块变小;
    • 同时也支持并行和串行的两种汇聚操作模式,可以充分利用CPU多核优势;

    集合的操作:

    通常集合的操作有这样几种:遍历,过滤, 排序,group by,求最大(最小),求均值等操作。在java7及以前的代码,常用的做法是循环这个集合,获取到集合中的具体元素,针对集合元素进行处理和操作。
    eg:
    for(Type item: items){
       item.doSth();
       if(item.suitable){
          dosth();sort();
       }
    }


    在java8中可以这样写:

    items.stream().filter(item->item.suitable()).map(Item::getXXX).collect(toList());

    相比java7的代码来的更加简洁易懂;但是引入了好几个函数,比如stream(),filter(),map(),collect() 等方法,需要详细了解下其用处。

    详解Stream:

    Stream有点类似于Iterator,是单向的,不可以往复,数据只能遍历一次,遍历一次就用完了,无法再使用。同时Stream支持并行运算,这个比Iterator要更高效。并行API从java.1.4就开始引入(Thread),经历了concurent,phasers,fork/join等方法的发展以后,到了java8引入了Lambda,对stream起了很大的推进作用。

    一个流的组成:
    一个流通常分为三个过程,如下图:获取数据 —> 数据转换 —> 执行操作,每次转换的时候Stream对象不改变,但是会返回一个新的Stream对象,就可以向链式调用一样进行多次转换或者操作。

    生成流有几种办法:

    从Collection或者数组中来:
      Collection.stream();
      Collection.parallelStream()
      Arrays.stream(T array)   Stream.of()(java8)
      java.io.BufferedReader().lines
    静态生成方法:
      java.uti.stream.IntStream.range()
      java.nio.file.Files.walk()
    自己构建:
      java.util.Spliterator
      …



    Stream的操作分为两种类型:
    - Intermediate,一个流可以做零个或者多个这样的操作,目的是打开流,并进行数据的映射,过滤等操作,然后返回一个新的流;这些方法都是惰性(lazy)的,并不会真正的进行操作,只有进行一个Terminal操作的时候,才会被执行。
    - Terminal , 一个流进行了Terminal操作,就被使用光了,无法再被操作。所以,这个操作是流的最后一个操作。做这个操作的时候,所有的Intermediate的操作也才会真正的发生,并产生一个结果。

    还有一种:short-circurting操作,针对intermediate和terminal操作意义不同:
    — 针对intermediate操作是接受一个无限大的Stream,返回一个有限的Stream;
    — 针对terminal操作,是接受一个无限大的stream,返回一个有限

    可以理解为,Intermediate操作只是注入了一个函数(比如过滤,循环等Lambda代码块)。

    比如:
    int sum = widgets.stream()
    .filter(w -> w.getColor() == RED)
    .mapToInt(w -> w.getWeight())
    .sum();


    这个代码块中:widgets.stream() 产生一个流,filter,map都是Intermediate操作,sum是一个Terminal操作;


    流的使用:
    • 数值的流有IntStream,LongStream,DoubleStream,可以直接对Int,Long,Double等原始类型进行操作。

    Intermediate操作有:

        map(maptoInt,floatMap)、filter、distinct、sorted、peek、limit、skip、parallel、sequential、unsorted
    Terminal操作有:
        forEach,forEachOrdered,toArray,reduce,collect,min,max,count,anyMatch,allMatch,noneMatch,findFirst,findAny,iterator
    Short-circuiting:
        anyMatch,allMatch,noneMatch,findFirst,findAny,limit

    典型操作:

    map/flatMap:
      把input stream的每个元素映射成为output stream的另外一个元素。
    eg.1:

    List<String> output = wordList.stream().map(String::toUpperCase).collect(Collectors.toList())


    这段代码中的map做做了一个String::toUpperCase的操作,把inputStream的每个元素进行了一个大写操作,并作为一个outputStream输出,再进行了collect操作转化为一个List。

    eg.2:

    List<Integer> nums = Arrays.asList(1,2,3,4,5);
    List<Integer) squareNums = nums.stream().map(n->n*n).collect(Collectors.toList());

    这个map操作将第一个List中的每个元素进行了平方操作。

    Map生成的映射是1:1的,每个源stream都按照规则转换为另外一个目标steram中的元素。部分情况会产生一对多的关系,1个input stream的元素可能会产生多个output stream的元素,这时候需要是用到flatMap:

    eg.3:

    Stream<List<Integer>> inputSteram = Stream.of(
       Arrays.asList(1),
       Arrays.asList(2,3,),
       Arrays.asList(4,5,6)
     );
    Stream<Integer> outputSteram = inputSteam.flatMap((childList)->childList.Stream());

    这个例子中的flatMap就将input stream中的每一个childList都进行了扁平化,将元素抽取出来放在一个output stream中,这样output stream中就没有List了,只有一堆数字。

    filter:
    filter对input stream进行测试,测试通过的元素留下来生成一个新的Stream。

    eg.4:
    Integer[] sixNums = {1,2,3,4,5,6};
    Integer[] evens = Stream.of(sixNums).filter(n->n/2==0).toArray(Integer[]::new);

    这个操作就将sixNums中的偶数挑选出来了。

    eg.5:
    List<String> output = reader.lines().
           flatMap(line->Stream.of(line.split(REGEXP)).
           filter(word->word.length()>0).
           collect(Collectors.toList()); 

    这段代码将reader中的每一行读取并进行flatMap拉平到一个新的stream,每个元素都是切割好的,并过滤出来长度大于0的单次,形成整篇文章的全部单词。

    forEach: Terminal
    forEach接收一个lambda表达式,对stream的每个元素都执行一遍表达式的内容。

    eg.6:
    workers.stream().
          filter(w->w.getGender() == Person.Sex.MALE).
          forEach(w->System.out.println(w->getName()));

    pre java8 :
    for ( Woker p : wokers) {
       if ( p.getGender() == Person.Sex.MALE){
          System.out.println(p.getName());
      }
    }

    可以明显看得出来,通过stream的操作代码变得更加简洁紧凑,更易阅读。而且当数据量较多的时候,可以使用parallelSteram.forEach来提升性能(但是要注意次序没有办法保证)。forEach 与 for循环严格意义上性能并没有太大差别,更多的在于风格差异。

    此外,forEach() 是一个terminal操作,操作完毕后stream就被消费完毕,将无法再进行使用。peek() 是一个intermediate操作,可以实现forEach()的功能,但是会返回一个新的Stream。

    eg.7.:
    Stream.of("one","two","three","four").
        filter(e->e.length()>3).
        peek(e->System.out.println("Filtered value:"+e)).
        map(String::toUpperCase).
        peek(e->System.out.println("Mapped value:" +e)).
        collect(Collectors.toList())

    forEach不能修改自己包含的本地变量只,也不能break和return。 

    findFirst: terminal & short-circuiting

    返回Stream的第一个元素,或者空。他的返回值是一个Optional,是模拟scala的一个概念,是一个容器,可能包含或者不包含。使用它可以避免NullPointerException。

    Optional可以使代码的可读性更好,同时提供了编译时检查,可以降低NPE的概率, 在编码的时候就需要处理空值问题,而不是运行时发现和debug。Stream的findAny,max/min,reduce等方法都是返回Optional值。

    reduce:terminal:
    这个方法主要作用是把Stream的元素组合起来。提供一个初始值,然后按照一定的运算规则,与前面的第一个,第二个,第n个元素组合。比如字符串拼接,数值sum,min,max,average都是特殊的reduce操作。比如sum也可以写成:

    Integer sum = integers.reduce(0,(a,b)->a+b)
    or
    Integer sum = integers.reduce(0,Integer::sum);

    如果没有提供初始值,会把Stream的前两个元素组合起来,返回一个Optional的值。

    eg.8:
    //拼接
    String concat = Stream.of("A","B","C","D","E").reduce("",Stream::concat);
    //求最小值,最大值则相反
    double minValue = Stream.of(-1.5,1.0,-3.0,-2.0).reduce(Double.MAX_VALUE,Double::min);
    //数字求和,初始为0
    int sumValue = Stream.of(1,2,3,4,5).reduce(0,Integer::sum);
    //求和,无初始值
    int sumValue = Stream.of(1,2,3,4,5).reduce(Integer::sum).get();
    //过滤再拼接
    concat = Stream.of("a","b","c","d","e","f").filter(x->x.compareTo("Z")>0).reduce("",Stream::concat));

    limit/skip:
    limit返回前n个元素,skip扔掉前n个元素;
    需要注意:
    1.短路操作,前n个元素直接跳过,执行的数量也会下降;
    2.如果与sort集成,可能会导致无法短路;
    3.并行的时候limit和skip的代价会增加

    sorted:
    对对象进行排序;
    stream的排序比数组强大,可以进行map,filter,limit,distinct让元素减少以后再进行排序,可以明显缩短执行时间。
    尽量把sort放在最后,这样代价会比较小。

    max/min/distinct:
    max和min的实现可以使用stream.sorted().findFirst,但是性能会更差。max/min的效率为O(n),sorted是O(n log n)。

    eg.9:
    List<String> words = br.lines().
    flatMap(line -> Stream.of(line.split(" "))).
    filter(word -> word.length() > 0).
    map(String::toLowerCase).
    distinct().
    sorted().
    collect(Collectors.toList());
    br.close();
    System.out.println(words);


    上述代码读入的代码进行lowerCase然后distinct排序并返回一个list;

    Match方法:
    判断元素中是否有符合条件的数据,返回一个boolean值。
    有三个match的方法,allMatch,anyMatch,noneMatch。

    自己生成Stream方法:

    Stream.of:
      — 有两种,可以接收一个变长参数,也可以接受单一值
    Stream.generate:
      — 生成一个无限长度的stream
    Stream.generate(new Supplier<Double>(){
       @override
       public Double get(){
          return Math.random();
       }
    });
    Stream.generate(()->Math.random());
    Stream.generate(Math::random);

    Stream.iterate:生成无限长度的Stream,生成的时候可以根据种子来调用指定函数生成。可以认为是seed,f(seed),f(f(seed))的循环:

    Stream.iterate(1,item->item+1).limit(10).forEach(System.out::println)



    Refer:

    印象笔记,让记忆永存。下载印象笔记

    星期二, 十一月 29, 2016

    落日

    某日,路过奥森,天色将晚,酒色的余晖洒落在公园里,有温暖的感觉。公园门口的高塔,泛出七彩,钢铁也变得温暖起来,怀念公园中奔跑的少年


    印象笔记,让记忆永存。下载印象笔记

    星期五, 四月 22, 2016

    使用graphviz绘制流程图


    前言

    日常的开发工作中,为代码添加注释是代码可维护性的一个重要方面,但是仅仅提供注释是不够的,特别是当 系统功能越来越复杂,涉及到的模块越来越多的时候,仅仅靠代码就很难从宏观的层次去理解。因此我们需要图例的支持,图例不仅仅包 含功能之间的交互,也可以包含复杂的数据结构的示意图,数据流向等。

    但是,常用的UML建模工具,如VISIO等都略显复杂,且体积庞大。对于开发人员,特别是后台开发人 员来说,命令行,脚本才是最友好的,而图形界面会很大程度的限制开发效率。相对于鼠标,键盘才是开发人员最好的朋友。

    graphviz简介

    本文介绍一个高效而简洁的绘图工具graphviz。graphviz是贝尔实验室开发的一个开源的工 具包,它使用一个特定的DSL(领域特定语言):dot作为脚本语言,然后使用布局引擎来解析此脚本,并完成自动布局。 graphviz提供丰富的导出格式,如常用的图片格式,SVG,PDF格式等。

    graphviz中包含了众多的布局器:

    1. dot 默认布局方式,主要用于有向图
    2. neato 基于spring-model(又称force-based)算法
    3. twopi 径向布局
    4. circo 圆环布局
    5. fdp 用于无向图

    graphviz的设计初衷是对有向图/无向图等 进行自动布局,开发人员使用dot脚本定义图形元素,然后选择算法进行布局,最终导出结果。

    首先,在dot脚本中定义图的顶点和边,顶点和边都具有各自的属性,比如形状,颜色,填充模式,字体, 样式等。然后使用合适的布局算法进行布局。布局算法除了绘制各个顶点和边之外,需要尽可能的将顶点均匀的分布在画布上,并且尽可 能的减少边的交叉(如果交叉过多,就很难看清楚顶点之间的关系了)。所以使用graphviz的一般流程为:

    1. 定义一个图,并向图中添加需要的顶点和边
    2. 为顶点和边添加样式
    3. 使用布局引擎进行绘制

    一旦熟悉这种开发模式,就可以快速的将你的想法绘制出来。配合一个良好的编辑器(vim/emacs) 等,可以极大的提高开发效率,与常见的GUI应用的所见即所得模式对应,此模式称为所思即所得。比如在我的机器上,使用vim编辑dot脚本,然后将F8映射为调 用dot引擎去绘制当前脚本,并打开一个新的窗口来显示运行结果:

    对于开发人员而言,经常会用到的图形绘制可能包括:函数调用关系,一个复杂的数据结构,系统的模块组成,抽象语法树等。

    基础知识

    graphviz包含3中元素,图,顶点和边。每个元素都可以具有各自的属性,用来定义字体,样式,颜色,形状等。下面是一些简单的示例,可以帮助我们快速的了解graphviz的基本用法。

    第一个graphviz图

    比如,要绘制一个有向图,包含4个节点a,b,c,d。其中a指向b,b和c指向d。可以定义下列脚本:

       1: digraph abc{  
       2: a;  
       3: b;  
       4: c;  
       5: d;  
       6:    
       7: a -> b;  
       8: b -> d;  
       9: c -> d;  
      10: }  

     

    使用dot布局方式,绘制出来的效果如下:

    图 1

    默认的顶点中的文字为定义顶点变量的名称,形状为椭圆。边的默认样式为黑色实线箭头,我们可以在脚本中做一下修改,将顶点改为方形,边改为虚线。

    定义顶点和边的样式

    在digraph的花括号内,添加顶点和边的新定义:

       1: node [shape="record"];  
       2: edge [style="dashed"];  
    则绘制的效果如下:

    进一步修改顶点和边样式

    进一步,我们将顶点a的颜色改为淡绿色,并将c到d的边改为红色,脚本如下:

       1: digraph abc{  
       2: node [shape="record"];  
       3: edge [style="dashed"];  
       4:    
       5: a [style="filled", color="black", fillcolor="chartreuse"];  
       6: b;  
       7: c;  
       8: d;  
       9:    
      10: a -> b;  
      11: b -> d;  
      12: c -> d [color="red"];  
      13: }  

    绘制的结果如下:

    应当注意到,顶点和边都接受属性的定义,形式为在顶点和边的定义之后加上一个由方括号括起来的key-value列表,每个key-value对由逗号隔开。如果图中顶点和边采用统一的风格,则可以在图定义的首部定义node, edge的属性。比如上图中,定义所有的顶点为方框,所有的边为虚线,在具体的顶点和边之后定义的属性将覆盖此全局属性。如特定与a的绿色,c到d的边的红色。

    子图的绘制

    graphviz支持子图,即图中的部分节点和边相对对立(软件的模块划分经常如此)。比如,我们可以将顶点c和d归为一个子图:

       1: digraph abc{  
       2:    
       3: node [shape="record"];  
       4: edge [style="dashed"];  
       5:    
       6: a [style="filled", color="black", fillcolor="chartreuse"];  
       7: b;  
       8:    
       9:     subgraph cluster_cd{  
      10:     label="c and d";  
      11:     bgcolor="mintcream";  
      12:     c;  
      13:     d;  
      14:     }  
      15:    
      16: a -> b;  
      17: b -> d;  
      18: c -> d [color="red"];  
      19: }  

    将c和d划分到cluster_cd这个子图中,标签为"c and d",并添加背景色,以方便与主图区分开,绘制结果如下:

    应该注意的是,子图的名称必须以cluster开头,否则graphviz无法设别。

    数据结构的可视化

    实际开发中,经常要用到的是对复杂数据结构的描述,graphviz提供完善的机制来绘制此类图形。

    一个hash表的数据结构

    比如一个hash表的内容,可能具有下列结构:

       1: struct st_hash_type {  
       2:     int (*compare) ();  
       3:     int (*hash) ();  
       4: };  
       5:    
       6: struct st_table_entry {  
       7:     unsigned int hash;  
       8:     char *key;  
       9:     char *record;  
      10:     st_table_entry *next;  
      11: };  
      12:    
      13: struct st_table {  
      14:     struct st_hash_type *type;  
      15:     int num_bins; /* slot count */  
      16:     int num_entries; /* total number of entries */  
      17:     struct st_table_entry **bins; /* slot */  
      18: };  
    绘制hash表的数据结构

    从代码上看,由于结构体存在引用关系,不够清晰,如果层次较多,则很难以记住各个结构之间的关系,我们可以通过下图来更清楚的展示:

    脚本如下:

       1: digraph st2{  
       2: fontname = "Verdana";  
       3: fontsize = 10;  
       4: rankdir=TB;  
       5:    
       6: node [fontname = "Verdana", fontsize = 10, colo  r="skyblue", shape="record"];  
       7:    
       8: edge [fontname = "Verdana", fontsize = 10, colo  r="crimson", style="solid"];  
       9:    
      10: st_hash_type [label="{<head>st_hash_type|(*compa  re)|(*hash)}"];  
      11: st_table_entry [label="{<head>st_table_entry|hash|key|record|  <next>next}"];  
      12: st_table [label="{st_table|<type>type|num_bins|n  um_entries|<bins>bins}"];  
      13:    
      14: st_table:bins -> st_table_entry:head;  
      15: st_table:type -> st_hash_type:head;  
      16: st_table_entry:next -> st_table_entry:head [style="  dashed", color="forestgreen"];  
      17: }  

    应该注意到,在顶点的形状为"record"的时候,label属性的语法比较奇怪,但是使用起来非常灵活。比如,用竖线"|"隔开的串会在绘制出来的节点中展现为一条分隔符。用"<>"括起来的串称为锚点,当一个节点具有多个锚点的时候,这个特性会非常有用,比如节点st_table的type属性指向st_hash_type,第4个属性指向st_table_entry等,都是通过锚点来实现的。

    我们发现,使用默认的dot布局后,绿色的这条边覆盖了数据结构st_table_entry,并不美观,因此可以使用别的布局方式来重新布局,如使用circo算法:

    则可以得到更加合理的布局结果。

    hash表的实例

    另外,这个hash表的一个实例如下:

    脚本如下:

       1: digraph st{  
       2:    
       3: fontname = "Verdana";  
       4: fontsize = 10;  
       5: rankdir = LR;  
       6: rotate = 90;  
       7:    
       8: node [ shape="record", width=.1, height=.1];  
       9: node [fontname = "Verdana", fontsize = 10, color="skyblue", shape="record"];  
      10:    
      11: edge [fontname = "Verdana", fontsize = 10, color="crimson", style="solid"];  
      12: node [shape="plaintext"];  
      13:    
      14: st_table [label=<  
      15:     <table border="0" cellborder="1" cellspacing="0" align="left">  
      16:     <tr>  
      17:     <td>st_table</td>  
      18:     </tr>  
      19:     <tr>  
      20:     <td>num_bins=5</td>  
      21:     </tr>  
      22:     <tr>  
      23:     <td>num_entries=3</td>  
      24:     </tr>  
      25:     <tr>  
      26:     <td port="bins">bins</td>  
      27:     </tr>  
      28:     </table>  
      29: >];  
      30:    
      31: node [shape="record"];  
      32: num_bins [label=" <b1> | <b2> | <b3>   | <b4> | <b5> ", height=2];  
      33: node[ width=2 ];  
      34:    
      35: entry_1 [label="{<e>st_table_entry|<next>next}"]  ;  
      36: entry_2 [label="{<e>st_table_entry|<next>n  ull}"];  
      37: entry_3 [label="{<e>st_table_entry|<next>null}"]  ;  
      38:    
      39: st_table:bins -> num_bins:b1;  
      40: num_bins:b1 -> entry_1:e;  
      41: entry_1:next -> entry_2:e;  
      42: num_bins:b3 -> entry_3:e;  
      43:    
      44: }  

    上例中可以看到,节点的label属性支持类似于HTML语言中的TABLE形式的定义,通过行列的数目来定义节点的形状,从而使得节点的组成更加灵活。

    软件模块组成图

    Apache httpd模块关系

    IDPV2后台的模块组成关系

    在实际的开发中,随着系统功能的完善,软件整体的结构会越来越复杂,通常开发人员会将软件划分为可理解的多个子模块,各个子模块通过协作,完成各种各样的需求。

    下面有个例子,是在IDPV2设计时的一个草稿:

    IDP支持层为一个相对独立的子系统,其中包括如数据库管理器,配置信息管理器等模块,另外为了提供更大的灵活性,将很多其他的模块抽取出来作为外部模块,而支持层提供一个模块管理器,来负责加载/卸载这些外部的模块集合。

    这些模块间的关系较为复杂,并且有部分模块关系密切,应归类为一个子系统中,上图对应的dot脚本为:

       1: digraph idp_modules{  
       2:    
       3: rankdir = TB;  
       4: fontname = "Microsoft YaHei";  
       5: fontsize = 12;  
       6:    
       7: node [ fontname = "Microsoft YaHei", fontsize = 12, shape =   "record" ];   
       8: edge [ fontname = "Microsoft YaHei", fontsize =   12 ];  
       9:    
      10:     subgraph cluster_sl{  
      11:         label="IDP支持层";  
      12:         bgcolor="mintcream";  
      13:         node [shape="Mrecord", color="skyblue", style="filled"];  
      14:         network_mgr [label="网络管理器"];  
      15:         log_mgr [label="日志管理器"];  
      16:         module_mgr [label="模块管理器"];  
      17:         conf_mgr [label="配置管理器"];  
      18:         db_mgr [label="数据库管理器"];  
      19:     };  
      20:    
      21:     subgraph cluster_md{  
      22:         label="可插拔模块集";  
      23:         bgcolor="lightcyan";  
      24:         node [color="chartreuse2", style="filled"];  
      25:         mod_dev [label="开发支持模块"];  
      26:         mod_dm [label="数据建模模块"];  
      27:         mod_dp [label="部署发布模块"];  
      28:     };  
      29:    
      30: mod_dp -> mod_dev [label="依赖..."];  
      31: mod_dp -> mod_dm [label="依赖..."];  
      32: mod_dp -> module_mgr [label="安装...", col  or="yellowgreen", arrowhead="none"];  
      33: mod_dev -> mod_dm [label="依赖..."];  
      34: mod_dev -> module_mgr [label="安装...", co  lor="yellowgreen", arrowhead="none"];  
      35: mod_dm -> module_mgr [label="安装...", color="yellowgreen", arrowhead="none"];  
      36:    
      37: }  
      38:    

    状态图

    有限自动机示意图

    上图是一个简易有限自动机,接受a及a结尾的任意长度的串。其脚本定义如下:

       1: digraph automata_0 {  
       2:    
       3: size = "8.5, 11";  
       4: fontname = "Microsoft YaHei";  
       5: fontsize = 10;  
       6:    
       7: node [shape = circle, fontname = "Microsoft YaHei", fontsize   = 10];  
       8: edge [fontname = "Microsoft YaHei", fontsize =   10];  
       9:    
      10: 0 [ style = filled, color=lightgrey ];  
      11: 2 [ shape = doublecircle ];  
      12:    
      13: 0 -> 2 [ label = "a " ];  
      14: 0 -> 1 [ label = "other " ];  
      15: 1 -> 2 [ label = "a " ];  
      16: 1 -> 1 [ label = "other " ];  
      17: 2 -> 2 [ label = "a " ];  
      18: 2 -> 1 [ label = "other " ];  
      19:    
      20: "Machine: a" [ shape = plaintext ];  
      21: }  

    形状值为plaintext的表示不用绘制边框,仅展示纯文本内容,这个在绘图中,绘制指示性的文本时很有用,如上图中的"Machine: a"。

    OSGi中模块的生命周期图

    OSGi中,模块具有生命周期,从安装到卸载,可能的状态具有已安装,已就绪,正在启动,已启动,正在停止,已卸载等。如下图所示:

    对应的脚本如下:

       1: digraph module_lc{  
       2:    
       3: rankdir=TB;  
       4: fontname = "Microsoft YaHei";  
       5: fontsize = 12;  
       6:    
       7: node [fontname = "Microsoft YaHei", fontsize = 12, shape = <  span style="padding: 0px; border: 0px; font-style: inherit; font-variant: inherit; font-weight: inherit; font-stretch: inherit; font-family: inherit; font-size: 10.6667px; vertical-align: baseline; color: rgb(0, 96, 128);">"Mrecord", color="skyblue", style="filled"];   
       8: edge [fontname = "Microsoft YaHei", fontsize =   12, color="darkgreen" ];  
       9:    
      10: installed [label="已安装状态"];  
      11: resolved [label="已就绪状态"];  
      12: uninstalled [label="已卸载状态"];  
      13: starting [label="正在启动"];  
      14: active [label="已激活(运行)状态"];  
      15: stopping [label="正在停止"];  
      16: start [label="", shape="circle", width=0.5, fixedsize=true, style="filled", color="black"];  
      17:    
      18: start -> installed [label="安装"];  
      19: installed -> uninstalled [label="卸载"];  
      20: installed -> resolved [label="准备"];  
      21: installed -> installed [label="更新"];  
      22: resolved -> installed [label="更新"];  
      23: resolved -> uninstalled [label="卸载"];  
      24: resolved -> starting [label="启动"];  
      25: starting -> active [label=""];  
      26: active -> stopping [label="停止"];  
      27: stopping -> resolved [label=""];  
      28:    
      29: }  

    其他实例

    一棵简单的抽象语法树(AST)

    表达式 (3+4)*5 在编译时期,会形成一棵语法树,一边在计算时,先计算3+4的值,最后与5相乘。

    对应的脚本如下:

       1: digraph ast{  
       2: fontname = "Microsoft YaHei";  
       3: fontsize = 10;  
       4:    
       5: node [shape = circle, fontname = "Microsoft YaHei", fontsize   = 10];  
       6: edge [fontname = "Microsoft YaHei", fontsize =   10];  
       7: node [shape="plaintext"];  
       8:    
       9: mul [label="mul(*)"];  
      10: add [label="add(+)"];  
      11:    
      12: add -> 3  
      13: add -> 4;  
      14: mul -> add;  
      15: mul -> 5;  
      16: }  
      17:    
    简单的UML类图

    下面是一简单的UML类图,Dog和Cat都是Animal的子类,Dog和Cat同属一个包,且有可能有联系(0..n)。

    脚本:

       1: digraph G{  
       2:    
       3: fontname = "Courier New"  
       4: fontsize = 10  
       5:    
       6: node [ fontname = "Courier New", fontsize = 10,   shape = "record" ];  
       7: edge [ fontname = "Courier New", fontsize = 10 ];  
       8:    
       9: Animal [ label = "{Animal |+ name : String\l+ age : int\l|+ die() :   void\l}" ];  
      10:    
      11:     subgraph clusterAnimalImpl{  
      12:         bgcolor="yellow"  
      13:         Dog [ label = "{Dog||+ bark() : void\l}" ];  
      14:         Cat [ label = "{Cat||+ meow() : void\l}" ];  
      15:     };  
      16:    
      17: edge [ arrowhead = "empty" ];  
      18:    
      19: Dog->Animal;  
      20: Cat->Animal;  
      21: Dog->Cat [arrowhead="none", label="0..*"];  
      22: }  
    状态图

    脚本:

       1: digraph finite_state_machine {  
       2:    
       3: rankdir = LR;  
       4: size = "8,5"  
       5:    
       6: node [shape = doublecircle];   
       7:    
       8: LR_0 LR_3 LR_4 LR_8;  
       9:    
      10: node [shape = circle];  
      11:    
      12: LR_0 -> LR_2 [ label = "SS(B)" ];  
      13: LR_0 -> LR_1 [ label = "SS(S)" ];  
      14: LR_1 -> LR_3 [ label = "S($end)" ];  
      15: LR_2 -> LR_6 [ label = "SS(b)" ];  
      16: LR_2 -> LR_5 [ label = "SS(a)" ];  
      17: LR_2 -> LR_4 [ label = "S(A)" ];  
      18: LR_5 -> LR_7 [ label = "S(b)" ];  
      19: LR_5 -> LR_5 [ label = "S(a)" ];  
      20: LR_6 -> LR_6 [ label = "S(b)" ];  
      21: LR_6 -> LR_5 [ label = "S(a)" ];  
      22: LR_7 -> LR_8 [ label = "S(b)" ];  
      23: LR_7 -> LR_5 [ label = "S(a)" ];  
      24: LR_8 -> LR_6 [ label = "S(b)" ];  
      25: LR_8 -> LR_5 [ label = "S(a)" ];  
      26:    
      27: }  

    附录

    事实上,从dot的语法及上述的示例中,很容易看出,dot脚本很容易被其他语言生成。比如,使用一些简单的数据库查询就可以生成数据库中的ER图的dot脚本。

    如果你追求高效的开发速度,并希望快速的将自己的想法画出来,那么graphviz是一个很不错的选择。

    当然,graphviz也有一定的局限,比如绘制时序图(序列图)就很难实现。graphviz的节点出现在画布上的位置事实上是不确定的,依赖于所使用的布局算法,而不是在脚本中出现的位置,这可能使刚开始接触graphviz的开发人员有点不适应。graphviz的强项在于自动布局,当图中的顶点和边的数目变得很多的时候,才能很好的体会这一特性的好处:

    比如上图,或者较上图更复杂的图,如果采用手工绘制显然是不可能的,只能通过graphviz提供的自动布局引擎来完成。如果仅用于展示模块间的关系,子模块与子模块间通信的方式,模块的逻辑位置等,graphviz完全可以胜任,但是如果图中对象的物理位置必须是准确的,如节点A必须位于左上角,节点B必须与A相邻等特性,使用graphviz则很难做到。毕竟,它的强项是自动布局,事实上,所有的节点� �与布局引擎而言,权重在初始时都是相同的,只是在渲染之后,节点的大小,形状等特性才会影响权重。

    本文只是初步介绍了graphviz的简单应用,如图的定义,顶点/边的属性定义,如果运行等,事实上还有很多的属性,如画布的大小,字体的选择,颜色列表等,大家可以通过graphviz的官网来找到更详细的资料。