coolEx

Today will be better

mesos + marathon集群搭建(2)

本地存储卷

前面示例已经启动了redis镜像,然后为了验证marathon的资源隔离(主要是内存)特性,做了以下尝试:

  1. 将redis容器的内存限制为32M;
  2. 通过脚本循环向redis写入长字符串;
  3. 发现当任务资源监控超过16M的时候,内存占用又会重新清零。

这个过程我们可以知道,将数据写入redis之后,任务监控到redis实例的内存占用在不断增加。然后超过内存限制的一半之后,任务被kill,几秒钟之后marathon重新启动了任务,本地redis客户端重新可用。这里内存超过一半是因为默认redis开启了rdb文件保存(bgsave),redis需要新fork出进程进行rdb文件写入,导致此时虚拟内存翻倍,容器占用的内存超过了cgroup限制,被内核oom kill。我们可以通过内核日志发现一次oom kill的日志。最后marathon虽然重新启动了任务,但是由于docker容器的隔离性,之前写入硬盘的内容(rdb)文件都已经消失,导致内存清零,之前写入的大字符串消失。

如果我们希望redis任务重新启动之后能够保留数据(虽然对于测试场景无效,因为内存不足,如果数据恢复,进程也会立即被内核kill),就需要将容器中的redis工作目录持久化保存。

marathon文档中描述了两种持久化的方式,对于我们来说,最简单的就是使用本地卷进行持久化。本地卷的大致方式是向mesos master申请指定大小的卷,并将该卷和任务容器关联起来(对于docker容器使用volume-from参数关联即可)。

启动redis任务,其中和卷相关的配置为:

"volumes": [
     {
       "containerPath": "/data",
       "hostPath": "redis",
       "mode": "RW"
     },
     {
       "containerPath": "redis",
       "mode": "RW",
       "persistent": {
         "size": 1024
       }
     }
    ]

该配置表示在slave机器上申请名为redis的卷,大小为1024MB,权限为读写。同时设置将主机的redis卷挂载到容器的/data目录中,挂载权限为读写。

提交任务之后,发现使用了本地卷之后的任务一直在unsheduled状态,slave上没有任务分派的数据。查了半天文档也没有好的解释。于是重新按照marathon文档进行了设置,特别关注其中的Prerequisites章节。其中描述了marathon启动参数中必须设置mesos_authentication_principalmesos_role两个参数。但是其中没有太明确描述需要设置什么值。

由于这两个参数主要作为在mesos master执行操作时后的标识,首先先在marathon机器上的/etc/marathon/conf/目录中分别创建mesos_authentication_principal和mesos_role两个文件:

echo 'marathon' > /etc/marathon/conf/mesos_authentication_principal
echo 'marathon' > /etc/marathon/conf/mesos_role

重启marathon服务之后,在任务详情中就可以看见新增的volume标签页。但情况并没有好转,任务长时间处于waiting状态,卷的状态一直为detached。最终在mesos master(leader那台)上找到了一些错误日志:

Dropping RESERVE offer operation from framework … A reserve operation was attempted with no principal, but there is a reserved resource in the request with principal ‘marathon’ set in ReservationInfo

紧接着一条错误:

Dropping CREATE offer operation from framework…Invalid CREATE Operation: Insufficient disk resources

查询了文档之后大概知道,由于principal设置的问题,mesos master没有认可marathon发起的RESERVE指令(既让slave预留一部分资源,这里是硬盘空间),随后由于没有预留足够的硬盘空间,发送的创建指令由于没有剩余空间而失败。

从错误日志基本可以断定是由于principal设置问题导致的,从marathon任务相关文档中,终于了解了mesos master acl相关的配置,,以及这些配置对frameworks的影响。直接抄文档中的acls内容会有问题,因为其中只是指定了run_tasks和register_frameworks两个操作的acl,通过mesos文档,我们可以查阅到mesos master支持的所有acl项。对于我们的需求,需要授权marathon支持reserve_resources操作,最终将acls参数内容更新为:

{
    "run_tasks": [
        {
            "principals": {
                "type": "ANY"
            },
            "users": {
                "type": "ANY"
            }
        }
    ],
    "register_frameworks": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            }
        }
    ],
    "reserve_resources": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            },
            "resources": {
                "type": "ANY"
            }
        }
    ],
    "unreserve_resources": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            },
            "reserver_principals": {
                "type": "ANY"
            }
        }
    ],
    "create_volumes": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            }
        }
    ],
    "destroy_volumes": [
        {
            "principals": {
                "type": "ANY"
            },
            "roles": {
                "type": "ANY"
            },
            "creator_principals": {
                "type": "ANY"
            }
        }
    ]
}

这里只是为了能够正常分配,没有做任何访问控制。将上述内容保存到文件(/etc/mesos-acls)然后添加到mesos-master的参数:

echo 'file:///etc/mesos-acls' > /etc/mesos-master/acls

重启所有mesos master,之前一直在waiting状态的任务,已经可以正常申请卷,并启动容器。进入分配到的slave服务器,我们可以看见多了一个挂载点:

/dev/vda1 on /var/lib/mesos/slaves/fb9d0623-c0bf-402e-9288-06206b30ea54-S4/frameworks/487f9562-5998-4cfb-bc92-bbc3a441c69c-0000/executors/redis_single.bd21b767-41b8-11e6-9d7a-024259ae7178/runs/8c9304a1-b960-4aa0-a169-f368a4d77ed9/redis type xfs (rw,relatime,attr2,inode64,noquota)

同时通过docker inspect命令查看redis容器的参数,可以发现其中卷挂载有如下配置:

"Mounts": [
    {
        "Source": "/var/lib/mesos/slaves/fb9d0623-c0bf-402e-9288-06206b30ea54-S4/frameworks/487f9562-5998-4cfb-bc92-bbc3a441c69c-0000/executors/redis_single.bd21b767-41b8-11e6-9d7a-024259ae7178/runs/8c9304a1-b960-4aa0-a169-f368a4d77ed9/redis",
        "Destination": "/data",
        "Mode": "rw",
        "RW": true,
        "Propagation": "rprivate"
    },
    {
        "Source": "/var/lib/mesos/slaves/fb9d0623-c0bf-402e-9288-06206b30ea54-S4/frameworks/487f9562-5998-4cfb-bc92-bbc3a441c69c-0000/executors/redis_single.bd21b767-41b8-11e6-9d7a-024259ae7178/runs/8c9304a1-b960-4aa0-a169-f368a4d77ed9",
        "Destination": "/mnt/mesos/sandbox",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
    }
]

既创建的挂载点已经被挂到容器的指定路径了。

然后我们进行验证:
首先通过redis客户端连接到redis上,然后设置工作路径为我们挂载的路径:

redis-cli -h 1.1.1.1 CONFIG SET dir /data

然后写入数据:

redis-cli -h 1.1.1.1 set a b

为了确保能够正常持久化,执行:

redis-cli -h 1.1.1.1 bgsave

查看挂载卷路径,可以发现已经有dump.rdb文件产生。此时kill掉redis任务,等待marathon重新启动。再次启动之后,通过redis-cli连接进入redis之后,可以看见之前设置的key仍然存在:

> redis-cli -h 1.1.1.1 keys *
1) "a"

然后获取这个key的值:

> redis-cli -h 1.1.1.1 get a
1) "b"

从这个实验可以看出,marathon的本地存储卷可以确保服务重新启动之后原来的数据仍然存在,对于存储类的应用(如redis、mysql等)非常有用。

mesos + marathon集群搭建(1)

机器准备

机器准备和mesos本身没有关系,主要由于本身机器申请机制有关,这段主要记录下如何通过配置systemd,将agent启动并且正确注册到服务器上。
这里的核心问题是需要开机获取本机IP,然后按照顺序启动docker和agent的镜像。

由于docker0设备的存在,之前agent获取本机IP的方式会出现问题,所有基于docker启动的agent都需要通过环境变量传入本机IP。因此首先需要在镜像启动前,获取本机IP。systemd本身可以通过%H来指代主机名,但是没有提供本机IP的变量。这边通过开机自动运行脚本,按照k=v的方式写入文件,提供给其他unit file作为environment file使用。

#cat ip.service
[Unit]
Description=get host ip and write to /etc/ip.txt

[Service]
ExecStart=/etc/host-ip.sh

[Install]
WantedBy=multi-user.target

对应的脚本文件内容为:

#cat /etc/host-ip.sh
#!/bin/sh
ip=$(/sbin/ifconfig eth0 | awk '/inet/ {print $2}')
echo IP=$ip > /etc/ip.txt;

这样每次服务启动之后,会写入到ip.txt文件,其内容类似:

IP=1.1.1.1

将unit文件放置在/etc/systemd/system目录中,别忘了设置成自动启动:

systemctl enable ip.service

搞定机器IP之后,就是agent的启动了。首先需要确保的是docker服务本身已经配置成自动启动。

systemctl enable docker

然后创建agent对应的unit文件:

#cat agent.service
[Unit]
Description=aenv agent
Requires=docker.service ip.service
After=docker.service ip.service

[Service]
EnvironmentFile=/etc/ip.txt
Restart=on-failure
RestartSec=10
ExecStartPre=-/usr/bin/docker kill agent
ExecStartPre=-/usr/bin/docker rm agent
ExecStart=/usr/bin/docker run --net host --env HOST_IP=$IP --name agent agent:1.0
ExecStop=-/usr/bin/docker stop agent

[Install]
WantedBy=multi-user.target

这里需要注意几个地方:

  • 依赖关系:在unit部分中,必须指定当前配置文件的依赖关系,这里指定依赖docker和ip两个服务,同时指定当前服务在这两个服务启动之后再启动。
  • 环境变量文件:之前的ip服务已经自动将ip地址写入到了ip.txt文件中,unit文件中可以直接将该文件设置为EnvironmentFile。这样可以通过引用文件中的KEY来获取实际的数据。
  • 启动命令:这里指定了几个阶段的命令,分别是预启动命令,如果当前已经有agent容器,直接杀死并删除;启动命令,设置agent容器启动命令,特别注意设置I变量;关闭命令,关闭服务时执行的命令,直接关闭agent容器。

最后别忘了设置开机自动启动:

systemctl enable agent.service

mesos+marathon集群搭建

mesos和marathon的基本安装可以参照mesosphere的文档进行。这次尝试安装使用的操作系统镜像是Centos 7,因此只要按照文中关于Centos 7的步骤即可。由于之前已经完成了zk集群的搭建,这里不再需要安装zk。

为了能够组建高可用集群,mesos master和marathon都使用3台机器搭建。因此需要修改部分配置文件。

对于mesos master,首先修改/etc/mesos中的zk文件,里面修改成已经搭建完成的zk集群,多个地址之前用逗号分割,mesos master之间通过zk来相互感知和进行选举。其他需要修改的文件都在/etc/mesos-master目录中,对于集群来说,首要设置的是quorum文件。按照官方文档说明,对于3台master,quorum需要设置为2,以进行单点失效后的选举。按照文档,这两个文件设置完成之后,就可以启动三台mesos master机器上的mesos-master服务了:

systemctl start mesos-master

但是,此时当三台机器都启动完成之后,访问任意一台的5050端口,会出现两个问题:

  1. 当访问机器不是当前leader时,网页无法正确跳转,直接跳转到了服务器的hostname(目前主机名只能通过机房DNS才能解析);
  2. 跳转到leader机器之后,过几秒钟就会跳转到其他机器,查看日志发现mesos master在不停的失效,不停的选举;

对于问题1,由于mesos master默认获取的主机名在非机房DNS中都无法解析,最方便的做法就是将hostname直接设置成机器IP。既在/etc/mesos-master目录中创建文件hostname,并将其内容设置为机器IP。

对于问题2,查了很多文档,也尝试了很多方式,最终尝试给mesos-master指定ip为当前机器IP后解决。既在/etc/mesos-master目录中创建文件ip,并将其内容设置为机器IP。这里需要特别注意的是,mesos master会将内容缓存在work_dir中,其值通过/etc/mesos-master目录中的work_dir文件内容指定。通过mesosphere rpm包安装之后默认值为/var/lib/mesos。因此修改了ip文件之后,需要清空其中内容,才会生效。

完成上面两步之后,mesos master集群启动成功。之后可以将mesos-master服务设置为开机自动启动:

systemctl enable mesos-master

然后就是安装marathon集群,还是直接使用rpm包进行安装。其中只有一个需要注意的地方(另一个大坑后面会介绍),就是设置机器的hostname为IP,原因前面已经介绍过。marathon的配置在/etc/marathon/conf目录中,对于主机名,创建hostname文件,并在其中设置当前机器IP即可。marathon启动之后,默认监听8080端口。

最后将marathon服务设置为开机自动启动:

systemctl enable marathon

添加slave和执行docker任务

前面已经介绍了mesos master集群和marathon集群的搭建,要真正能够分配资源并运行任务,还需要向master注册slave。slave和master使用了相同的机器镜像,确保rpm包已经正确安装,同时zk都已经完成配置(slave也使用/etc/mesos/zk的配置)。对于slave的所有配置,都在/etc/mesos-slave目录中,配置方式和mesos相同,防止和参数名称相同的文件即可。最简单的配置和master类似,设置hostname即可。既创建/etc/mesos-slave/hostname文件,并写入当前机器IP。其他服务器可用资源可以让slave自动检测并上报。

设置完成之后,就可以启动slave了。可以在master上看见注册上来的slave及其资源信息。同时记得将slave设置为开机自动启动:

systemctl enable mesos-slave

启动之后,尝试在marathon上提交一个docker任务:启动redis:alpine镜像。但是发现任务没有能够正确的在slave上启动。参照marathon文档介绍,要让slave支持docker,需要设置containerizers参数。执行:

echo 'docker,mesos' > /etc/mesos-slave/containerizers
echo '5mins' > /etc/mesos-slave/executor_registration_timeout

后面那个参数主要是由于docker任务启动的时候,需要去远程拉取镜像,一般刚开始容易超时。(虽然这样设置了之后还是会超时,不过由于marathon的重试机制,超时的任务会稍后重新启动,并且docker镜像本地也会缓存,目前感觉影响不大。)

完成这些配置之后,再重启mesos-slave服务,即可在marathon上提交刚才无法成功的redis:alpine镜像了。

另外,marathon还提供了任务的健康检测。按照其文档描述,在mesos master v0.23.1和v0.24.1以后,针对docker任务也已经支持通过命令来进行健康检测(实际查看slave日志后发现,执行的命令最终被转化成了docker exec命令,进入容器之后运行指定的命令)。对于redis任务,我们可以配置redis-cli info命令来进行健康检测,如果命令执行超时则判定为失败。不过marathon的健康检测还不支持对命令返回值的断言。

slides库reveal.js简介

简介

reveal.js是一个功能强大的在线演示文稿库。尝试它的主要原因有:

  • 文稿内容可以使用html标签或者markdown,可以使用任何编辑器
  • 支持浏览器直接打开
  • 支持使用node作为服务提供在线分享
  • 支持通过chrome的打印成pdf文档

编辑

编写文稿比较方便,直接下载release版本的包,或者clone工程目录即可。
其中release版本可以在https://github.com/hakimel/reveal.js/releases
页面中选择最新版本下载即可。

基本的功能使用非常简单,可以直接编辑代码中的index.html文件,按照里面的格式编辑即可。
html文件中的section的标签对应slides的一页,其中按照标准的html标签编写。例如官方示例的第一页:

<section>
    <h1>Reveal.js</h1>
    <h3>The HTML Presentation Framework</h3>
    <p>
        <small>Created by <a href="http://hakim.se">Hakim El Hattab</a> / <a href="http://twitter.com/hakimel">@hakimel</a></small>
    </p>
</section>

当然,对我来说,reveal.js最方便的地方在于可以直接在源码中使用markdown语法。markdown使用详细介绍文档
使用时需要将markdown代码放在script标签中,并将type设置为text/template

<section data-markdown>
    <script type="text/template">
        ## Markdown support

        Write content using inline or external Markdown.
        Instructions and more info available in the [readme](https://github.com/hakimel/reveal.js#markdown).
    </script>
</section>

这样就可以容易的将markdown文档和slides结合起来了。

演示和分享

slides最大的作用当然是演示了,使用reveal.js标准结构的slides演示最方便的方式,就是直接在浏览器中打开index.html。这样可以方便的在没有网络的情况下,
单机进行演示。如果需要在线分享,需要安装nodejs,然后通过node安装grunt,最后通过npm install安装reveal.js依赖。

npm install
grunt serve

默认启动端口为8000。

最后,一般演讲完之后,还需要分享内容。这时可以通过chrome的打印功能,直接打印成pdf文档。
打印时有两个地方需要特别注意:

  1. 必须在访问的url上增加print-pdf参数。例如原url为/index.html,改成/index.html?print-pdf
  2. 打印页面中“布局”需要设置为“横向”,并且选项中勾选“背景图形”。

然后保存为pdf文件即可。

empty:expect的替代

之前为了能够处理命令交互,编译了静态链接的expect,结果遇到了大问题导致在没有tcl的机器上还是无法运行。找了半天,发现empty是一个不错的替代。

优势

首先,empty是一个纯C实现的,不依赖任何其他库的工具。这对于需要在不同机器上分发执行非常关键,之前expect就是因为无法确保机器上都有tcl或者其初始化脚本,导致无法使用。

其次,empty使用非常简单,可以方便的嵌入到shell脚本中,而无须像expect那样使用单独的脚本。这能够大大降低学习成本和后续维护成本。

使用

empty和一般应用安装方式类似,直接make即可。整个源码包中核心只有一个C文件,结构非常简单。下载地址在:http://sourceforge.net/project/showfiles.php?group_id=136798 这里。安装方式可以参考项目首页命令。

编译完成之后,就可以直接使用empty这个可执行程序了。它的help输出不是很友好,比较简单,大致参数有这几个:

  • -f:应该是fork的意思吧,这个参数表示会执行后面的命令,同时这个命令可以通过-i和-o指定命令的输入和输出管道。如果不指定。empty会以当前进程id和子进程id作为标识生成对应的文件。
  • -w:应该是watch的意思吧,该参数可以执行监听的标准输入管道(一般为-f的标准输出管道)和写入的标准输出管道(一般为-f的标准输入管道),然后后续以键值对的方式追加多个监听的key,和输出的value,即发现标准输入包含key时,向标准输出写入value。这个键值对可以有多个。
  • -i:标准输入对应的管道文件。对于-f参数指定的是执行命令的标准输入,后续可以通过这个标准输入管道,向执行命令发送标准输入内容。特别注意,对于-w参数这里表示监听输入的来源,需要设置为-f的标准输出管道。
  • -o:标准输出对应的管道文件。对于-f参数指定的是执行命令的标准输出,后续可以通过读取这个管道文件,监听命令的输出。对于-w参数,这里表示输出写入的管道,需要设置为-f的标准输入管道。
  • -s:应该是send的缩写吧,可以直接指定向标准输出管道(-o)写入字符
  • -t:设置watch的超时时间,如果超时时间内没有输入,则退出empty命令。默认值为0,表示不超时。

因此,大部分场景下,先通过-f启动命令,然后通过-w命令监视输出,并且写入结果。例如代码中的示例,如果要模拟ssh登录,可以这样:

ssh="ssh"                               # (/full/path/to/)ssh
target="localhost"                      # target host
login="luser"                           # username (Change it!)
password="TopSecret"                    # password (Change it!)

fifo_in="/tmp/empty.in"                 # input fifo
fifo_out="/tmp/empty.out"               # output

# -----------------------------------------------------------------------------
cmd="$ssh $login@$target"
tmp="/tmp/empty.tmp"                    # tempfile to store results

echo "Starting empty"
empty -f -L $tmp -i $fifo_in -o $fifo_out $cmd
if [ $? = 0 ]; then
        if [ -w $fifo_in -a -r $fifo_out ]; then
                echo "Sending Password"
                empty -w -v -i $fifo_out -o $fifo_in -t 5 assword: "$passwordn"
                echo "Sending tests"
                empty -s -o $fifo_in "echo "-- EMPTY TEST BEGIN --"n"
                empty -s -o $fifo_in "uname -an"
                empty -s -o $fifo_in "uptimen"
                empty -s -o $fifo_in "who am in"
                empty -s -o $fifo_in "echo "-- EMPTY TEST END --"n"
                echo "Sending exit"
                empty -s -o $fifo_in 'exitn'
                echo "Check results:"
                sleep 1
                cat $tmp
                rm -f $tmp
        else
                echo "Error: Can't find I/O fifos!"
                return 1
        fi
else
        echo "Error: Can't start empty in daemon mode"
        return 1
fi

echo "Done"

这么一大堆脚本,其实就三行和empty使用有关,一个是empty -f -L $tmp -i $fifo_in -o $fifo_out $cmd,该命令执行ssh命令,并且指定输入、输出和日志文件。empty -w -v -i $fifo_out -o $fifo_in -t 5 assword: "$password\n"该命令通过监听ssh命令的输出,如果遇到“assword:”字样,则向ssh进程发送密码。注意,这里的-i和-o参数和之前相反,因为需要监听ssh命令的输出,向ssh命令的输入发送字符。最后就是empty -s -o $fifo_in "echo \"-- EMPTY TEST BEGIN --\"\n"这时ssh已经认证成功了,因此就直接向远程写入通过ssh执行的命令即可。

除了这种预先知道命令每一步的输出之外,还有一些场景是命令可能在有些场景下会进行交互,有些场景下不会。我们遇到的场景就是webx提供的autoconfig命令。该命令默认会在缺少配置项的时候进行交互,提示用户是否使用默认值,并且写入到antx.properties文件。

这时,就不能简单的通过每一步等待特定的字符,然后输出了。这里我们利用了empty的另一个特性,即自动维护管道文件,命令开始执行时,empty会创建对应的标准输入和标准输出两个管道文件,命令结束时删除,这样就不用用户来维护命令的输入输出,也不会产生垃圾文件。因此,我们可以通过循环的方式,监听是否有我们需要的关键字,在每次监听前,增加判断管道文件是否存在,以防止命令已经正常退出。大致的脚本如下:

empty -f -i input -o output -L $HOME/autoconfig.log autoconfig -u $HOME/antx.properties $f
sleep 1
while true;
do
    if [ -w input -a -r output ]; then
        empty -w -i output -o input -t 5 ".*Yes.*" "yesn" ".*[Quit.*" "quitn"
    else
        break;
    fi
done

while循环里面,首先需要判断的是input和output两个文件是否存在。对于input文件,通过-w检查文件是否存在的同时,检查文件是否可写;对于output文件,通过-r检查文件是否存在的同时,检查文件是否可读。如果两个文件都存在,那么运行empty命令,等待输出并且设置超时时间。

这样的实现有两个好处,首先是可以兼容命令正常运行的流程,即无须进行交互。其次是不需要知道命令当前的状态,我们在一个empty命令里面已经兼容了命令所有可能的输出对应的用户输入值。

静态编译expect以及大坑

expect是脚本处理有交互命令的利器,但是这货依赖tcl,并且默认没有安装。为了方便分发到测试服务器上,尝试了下静态编译。结果以失败告终。下面是整个编译的过程。

编译tcl静态库

由于expect依赖tcl,必须先编译一个静态版本的tcl库。tcl的源码可以从阿里云镜像下载:

wget http://mirrors.aliyun.com/gentoo/distfiles/tcl-core8.6.4-src.tar.gz

编译的方式和编译普通应用类似,唯一需要注意的是,我们只需要编译出静态库即可。

tar vxf tcl-core8.6.4-src.tar.gz 
cd tcl8.6.4/unix/
./configure --disable-shared --prefix=$HOME/source/target
make
make install

这里我们直接安装到指定的路径,并且关闭动态链接库的编译。这样在$HOME/source/target里面,就会tcl的静态库。

编译expect

首先还是从镜像下载expect源码。

wget http://mirrors.aliyun.com/gentoo/distfiles/expect5.45.tar.gz

然后运行configure的时候,需要指定tcl的路径,并且关闭动态链接。

tar vxf expect5.45.tar.gz 
./configure --with-tcl=$HOME/source/target --disable-shared
make

这里遇到了很多链接的问题。首先是

expect.c:(.text+0xbc):对‘tclStubsPtr’未定义的引用
exp_main_sub.c:(.text+0x6fb):对‘tclIntStubsPtr’未定义的引用
tclUnixThrd.c:(.text+0x31):对‘pthread_key_delete’未定义的引用

这些符号无法找到,无法进行链接。向上查找这些错误对应的编译命令,发现已经在链接最终的expect命令了。此时的编译命令为:

gcc 
     -pipe -O2 -fomit-frame-pointer -Wall  
     -Wl,--export-dynamic  
    -o expect exp_main_exp.o 
    -L/alidata1/6080/source/expect5.45 -lexpect5.45 
    -L/alidata1/6080/source/target/lib -ltcl8.6 
    -ldl  -lieee -lm 
    -Wl,-rpath,/alidata1/6080/source/target/lib 
    -Wl,-rpath,/alidata1/6080/source/target/lib/expect5.45

查阅了tcl的faq,前面遇到无法链接的符号在tclstub库中,同时还发现少了pthread库的一些符号。加上这些之后,编译命令变成:

gcc 
     -pipe -O2 -fomit-frame-pointer -Wall  
     -Wl,--export-dynamic  
    -o expect exp_main_exp.o 
    -L/alidata1/6080/source/expect5.45 -lexpect5.45 
    -L/alidata1/6080/source/target/lib -ltcl8.6 
    -ldl  -lieee -lm 
    -Wl,-rpath,/alidata1/6080/source/target/lib 
    -Wl,-rpath,/alidata1/6080/source/target/lib/expect5.45 -ltclstub8.6 -lpthread

此时tcl和一些基础库的链接失败消失,但是出现了

pty_termios.c:(.text+0x116):对‘openpty’未定义的引用

这个错误。同样需要去查找这个函数实现的库,最终定位到了libutil。

最终将编译命令修改成:

gcc 
     -pipe -O2 -fomit-frame-pointer -Wall  
     -Wl,--export-dynamic  
    -o expect exp_main_exp.o 
    -L/alidata1/6080/source/expect5.45 -lexpect5.45 
    -L/alidata1/6080/source/target/lib -ltcl8.6 
    -ldl  -lieee -lm 
    -Wl,-rpath,/alidata1/6080/source/target/lib 
    -Wl,-rpath,/alidata1/6080/source/target/lib/expect5.45 -ltclstub8.6 -lpthread -lutil

终于能够正常编译出静态编译的expect命令了。

就在以为完事大吉的时候,发现这个静态编译的expect命令,虽然已经静态链接了tcl库,但是在其他没有tcl的机器上运行的时候,会提示:

Tcl_Init failed: Can't find a usable init.tcl in the following directories

原来为了支持扩展,tcl会要求初始化的时候执行这个初始化脚本。查阅了tcl的文档,要编译一个完全不依赖机器上tcl库的程序比较麻烦。最终,找到了一个简单的命令empty来处理命令的交互。

R表格处理(dplyr)

R表格处理(dplyr)

源文件是一个excel表格,主要的需求是按照日期分组,然后再按照另一列分组,同时求和和计算均值。大致的内容类似:

日期,用户,收入
2015-12-01 8:00:00,u1,2
2015-12-01 8:00:00,u1,4
2015-12-01 10:00:00,u2,5
2015-12-02 9:00:00,u1,10
2015-12-02 10:00:00,u2,4

统计内容是每天用户的数量、收入总数和平均每个用户的收入。例如:

日期,用户数,总收入,平均收入
2015-12-01,2,11,5.5

R中应该有多种方式能够实现这种数据聚合。网上很多都推荐使用dplyr包来进行运算。

首先是安装,和R其他包安装方式相同:

install.packages("dplyr")

然后是引入dplyr包,并加载入源输入(元数据是从excel导出的csv数据)。

library("dplyr")
data <- read.csv('input.csv')

这时的data变量内容为:

> data
                 日期 用户 收入
1  2015-12-01 8:00:00   u1    2
2  2015-12-01 8:00:00   u1    4
3 2015-12-01 10:00:00   u2    5
4  2015-12-02 9:00:00   u1   10
5 2015-12-02 10:00:00   u2    4

由于输出是按照日统计的,所以将第一列中的时间转成日期,以方便后续的聚合。

data$日期 = as.Date(data$日期)

修改之后会变成:

> data
        日期 用户 收入
1 2015-12-01   u1    2
2 2015-12-01   u1    4
3 2015-12-01   u2    5
4 2015-12-02   u1   10
5 2015-12-02   u2    4

然后开始聚合,这里的聚合有两个未读,首先是日期,然后是用户。

d_u_group <- group_by(data, 日期, 用户)

通过summarise函数,可以计算聚合之后的值:

d_u <- summarise(d_u_group, 'c' = n(), sum(收入))

由于这时的聚合是两个纬度的,所以还不是最终的数据:

> d_u
Source: local data frame [4 x 4]
Groups: 日期 [?]

        日期   用户     c sum(收入)
      (date) (fctr) (int)     (int)
1 2015-12-01     u1     2         6
2 2015-12-01     u2     1         5
3 2015-12-02     u1     1        10
4 2015-12-02     u2     1         4

最后再按照日期做聚合:

d_group <- group_by(d_u, 日期)
d <- summarise(d_group, '用户数' = n(), '总收入' = sum(收入), '平均收入' = 总收入/用户数)

最终的数据结果为:

> d
Source: local data frame [2 x 4]

        日期 用户数 总收入 平均收入
      (date)  (int)  (int)    (dbl)
1 2015-12-01      2     11      5.5
2 2015-12-02      2     14      7.0

容器化dns服务

容器化dns服务

之前将一个小应用使用docker compose部署之后,遇到一个问题,修改域名解析的IP。之前在虚拟机上可以直接修改hosts文件,
在docker容器中修改就比较麻烦,修改主机hosts文件也没有效果。

为了解决这个问题,引入了dnsmasq作为dns服务器。由于主机上本身已经有docker环境,因此也打算把dnsmasq放到容器中去运行。
首先是选择容器,在docker hub上下载量最多的是 andyshinn/dnsmasq 镜像。这个镜像的最大优势就是简单,整个镜像只有6M多。
对于无法访问外网的docker主机来说,部署比较方便。当然,这个镜像最大的劣势也是简单,没有提供配置文件,也无法可视化配置,使用时需要对dnsmasq有一定的了解。本文后面会介绍到刚开始使用遇到的不方便的地方。

安装

由于真正部署这个镜像的服务器无法访问外网,因此不能直接拉去docker镜像,暂时在一台可以访问外网的机器上拉取,然后导出,最后在机器上重新加载的方式来进行安装。也就是

  1. docker pull andyshinn/dnsmasq
  2. docker save andyshinn/dnsmasq > dnsmasq.tar
  3. scp dnsmasq.tar admin@xxx:.
  4. docker load < dnsmasq.tar

这样镜像就算是安装完成了。

启动

启动镜像前,参考了该镜像文档中的启动命令:

docker run -p 53:53/tcp -p 53:53/udp --cap-add=NET_ADMIN andyshinn/dnsmasq -S /consul/10.17.0.2

同时,看了下这个镜像的Dockerfile:

FROM alpine:3.2
RUN apk -U add dnsmasq
EXPOSE 53 53/udp
ENTRYPOINT ["dnsmasq", "-k"]

可以发现,这个镜像真是简单至极。默认就是运行了dnsmasq -k,即强制让dnsmasq运行在前端。然后dnsmasq没有任何特殊的配置,都需要启动过的时候手工指定。

而示例中的启动命令,有这几个含义:

  • 将容器的53端口(TCP和UDP)映射到主机上,这是dns的标准端口
  • 增加NET_ADMIN参数,使得容器中的进程可以修改网络配置
  • 给dnsmasq传递参数,针对.consul后缀的域名,使用10.17.0.2这个上游dns服务器进行解析

这样的启动方式,当然是无法满足需求的。dnsmasq命令的参数,可以在这个文档中进行查询。如果仅仅是解析一个域名,可以通过–address参数指定,域名和IP格式和上面类似:

docker run -p 53:53/tcp -p 53:53/udp --cap-add=NET_ADMIN andyshinn/dnsmasq --address /api.test.com/127.0.0.1

这样容器启动之后,就可以解析api.test.com这个域名到127.0.0.1。

应用

启动容器还是比较方便,然后就是将这个dns服务提供给其他docker容器使用。docker在启动参数中支持通过–dns指定dns服务器地址(指定后,会修改启动容器的/etc/resolv.conf文件)。如果在docker-compose中使用,可以在yml文件中增加dns项。

这样,容器启动之后,就可以使用自己搭建的dns服务了。然而,并没有~

NAT模式下dns服务的失效

首先遇到的问题,是容器中的服务仍然无法解析域名。使用docker exec命令进入容器,ping了域名还是提示无法解析。排查dnsmasq的日志没有输出。

由于dnsmasq解析日志不太好排查,查询了手册之后发现,可以通过–log-facility参数指定日志输出,将容器启动参数改成:

docker run -p 53:53/tcp -p 53:53/udp --cap-add=NET_ADMIN andyshinn/dnsmasq --address /api.test.com/127.0.0.1 -q --log-facility=-

这样可以将dnsmasq的日志输出到标准错误,也就能够在当前命令行中查看到。(如果在docker run的时候使用了-d参数放到了后台执行,可以直接通过docker logs来查看输出)

再次到应用容器中ping域名,会发现dnsmasq实际上已经完成了域名解析,但是域名解析的来源IP都是docker0设备的IP。如果将应用容器的dns服务器地址,修改成dnsmasq容器的内网地址,则解析正常。因此判断这个解析错误,可能和两个容器都运行在NAT模式中有关。

将dns容器改成host模式:

docker run --net=host --cap-add=NET_ADMIN andyshinn/dnsmasq --address /api.test.com/127.0.0.1 -q --log-facility=-

在将应用容器的dns服务器地址修改成主机的IP,可以发现能够正常解析域名。

dns解析自动更新

按照上面方式启动dns容器,可以正常工作。但是如果要增加解析记录,需要重新启动这个容器。文档里面有提到dnsmasq可以通过向进程发送SIGHUP信号,重新加载文件:

When it receives a SIGHUP, dnsmasq clears its cache and then re-loads /etc/hosts and /etc/ethers and any file given by –dhcp-hostsfile, –dhcp-hostsdir, –dhcp-optsfile, –dhcp-optsdir, –addn-hosts or –hostsdir. The dhcp lease change script is called for all existing DHCP leases. If –no-poll is set SIGHUP also re-reads /etc/resolv.conf. SIGHUP does NOT re-read the configuration file.

也就是说,如果通过–addn-hosts或者–hostsdir参数指定的文件(夹),可以通过信号来触发dnsmasq重新加载。为了方便区分,最终采用了后者,既–hostsdir指定hosts文件夹。

首先,在主机上创建一个文件夹和一个文件,写入需要解析的域名:

mkdir hosts
echo '127.0.0.1 a.test.com' > hosts/common

然后启动容器的时候,设置挂载卷,将这个目录共享给容器,同时指定这个目录未hostsdir:

docker run --net=host --cap-add=NET_ADMIN -v /home/admin/hosts:/media andyshinn/dnsmasq --addn-hosts=/media/common -q --log-facility=-

这样,如果需要新增解析记录,需要两步:

  1. 在主机hosts目录中,新增一条记录:echo 'b.test.com' >> hosts/common
  2. 向dns容器发送信号,使其重新加载文件:docker kill -s HUP 2beb1d9f3554

这样虽然还需要手工操作,但是无须再重新运行dns容器。

spring boot profile试用

spring boot profile试用

项目中需要在不同环境切换,之前类似的需求,主要在于一些配置的不同,如数据库配置等。这样的场景,通过maven的profile,结合resource filter即可。但是这次需要针对不同的场景,使用不同的实现类,以此来对接外部不同的系统。

本文基本上参考了spring博客中的介绍,通过@profile注解,来区分不同的使用场景。

自定义场景注解

@Profile注解需要接受一个字符串,作为场景名。这样每个地方都需要记住这个字符串。Spring的@Profile注解支持定义在其他注解之上,以创建自定义场景注解。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Profile("cloud")
public @interface Cloud {
}

这样就创建了一个@Cloud注解,该注解可以标识bean使用于cloud这个场景。后续就不再需要使用@Profile("cloud")的方式。这样即可以简化代码,同时可以利用IDE的自动补全:)

配置类场景控制

有了上面定义的注解,可以直接在有@Configure注解的配置类上的增加,以控制该类创建的Bean,只在指定场景下激活。例如:

@Configuration
@Cloud
public class CloudWebFilterConfig {

    private static final String FROM = "aliyun";

    @Bean
    public Filter ssoFilter() {
        return new SSOFilter();
    }

    @Bean
    public FilterRegistrationBean ssoFilterRegistrationBean() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(ssoFilter());
        filterRegistrationBean.setName("ssoFilter");
        filterRegistrationBean.setUrlPatterns(Collections.singletonList("/*"));

        filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);

        return filterRegistrationBean;
    }
}

上面的配置类,创建了两个bean,其中一个是springboot使用的filter,使用我们针对云上单点登录的实现;另一个是这个filter的配置,配置了这个filter的路径和名称,还有顺序。这里的配置和直接在web.xml中配置filter类似。唯一不同的是,FilterRegistrationBean支持设置filter的优先级。

基于service实现的场景控制

除了上述通过配置类的方式控制之外,@Profile和自定义的(@Cloud)注解还可以直接应用的@Service@Component等注解创建的服务实现上。同样,带上场景注解之后,这些服务实现只会在场景激活之后才会实例化。

例如,我们可以定义一个用户查询接口:

public interface UserService {
    List<AegisUser> fuzzyQuery(String query);
}

在不同场景下,我们需要有不同的实现。

@Service
@Cloud
public class UserServiceAImpl implements UserService {
    public List<AegisUser> fuzzyQuery(String query) {
        ...
    }
}
@Service
@Cloud
public class UserServiceBImpl implements UserService {
    public List<AegisUser> fuzzyQuery(String query) {
        ...
    }
}

上面创建了两个UserService的实现,一个标记为内部使用,一个标记为云上使用。

这样,对于上层平台,只需要直接@Autowired注解注入即可,无须再通过@Qualifier注解指定具体的实现了。运行时注入,会通过场景指定具体的实现类。

场景切换配置

spring boot在启动时需要指定当前激活的场景,主要方式有两个:

  1. 命令行参数指定:启动时,直接在命令行参数中增加:spring.profiles.active=cloud参数
  2. 在properties文件中指定,在应用依赖的properties文件中增加spring.profiles.active=cloud等配置,即可切换场景

为了能够在打包的时候就确定启动场景,最终还是采用了后者,并且和maven的resource filter结合。即在properties文件中使用占位符,在maven的profile中通过filter,通过maven profile来编译时替换。

<profiles>
    <profile>
        <id>internal</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <build.profile.id>internal</build.profile.id>
        </properties>
    </profile>
    <profile>
        <id>cloud</id>
        <properties>
            <build.profile.id>cloud</build.profile.id>
        </properties>
    </profile>
</profiles>

<build>
        <filters>
            <filter>profiles/${build.profile.id}/config.properties</filter>
        </filters>

        <resources>
            <resource>
                <filtering>true</filtering>
                <directory>src/main/resources</directory>
            </resource>
        </resources>
        ...
</build>    

这样只要在maven编译时通过-P参数指定maven profile即可。

gentoo prefix重生(llvm/clang)

gentoo prefix重生(llvm/clang)

前天手贱,更新了mac上gentoo prefix的perl,然后发现git svn无法使用。更新perl其他组件的时候,遇到了一堆问题。
特别是subversion1.8,在gcc-apple下已经无法编译了。

gentoo prefix portage镜像

之前gentoo prefix的portage rsync服务器挂掉过,然后改了其他一台,但是速度都非常慢。看见阿里云有gentoo prefix portage
的镜像,尝试修改成了这个镜像,但是后面更新的软件,包括portage都会存在问题。

刚开始都准备放弃重新做gentoo prefix了,下载了新的bootstrap-prefix.sh,发现了两个地方:
1. 新的脚本中portage树从rsync.prefix.bitzolder.nl下载
2. 新的脚本已经直接开始使用llvm作为prefix的构建编译器了

因此,还是先将portage的同步地址改了。由于新的portage已经去掉了make.conf中的SYNC变量,取而代之的是多了一个repos.conf目录
来存放仓库配置。因此在$EPREFIX/etc/portage/repos.conf下创建gentoo_prefix.conf文件,内容是:

[DEFAULT]
main-repo = gentoo_prefix

[gentoo_prefix]
location = /Users/babydragon/Gentoo/usr/portage
sync-type = rsync
sync-uri = rsync://rsync.prefix.bitzolder.nl/gentoo-portage-prefix

更新llvm

刚开始用了阿里云的镜像,emerge了llvm之后没有问题,但是加上了clang的USE之后,怎么都emerge不了。切换成官方镜像之后,又遇到llvm3.5.2循环依赖。
搜索了下发现bootstrap-prefix脚本中是bootstrap先emerge了llvm 3.4。按照这个方式和漫长的等待,llvm 3.4 emerge完成之后,再升级到3.5,
所有依赖的cxx包都能够正常安装了。

然后就是更新系统了,因为之前是perl损坏,直接使用:

perl-cleaner --all

来更新所有perl的依赖,发现编译的时候还有问题,看上去没有使用新的clang。继续查看脚本,发现stage2的时候,脚本会向make.conf文件中追加:

CC=clang
CXX=clang++
BUILD_CC=clang
BUILD_CXX=clang++

这几个变量,强制将编译器改成clang/clang++。

改完之后,git, svn和其他Perl的依赖,都能够正常的emerge了,git svn终于可以继续使用了。由于使用了llvm新版本,以后emerge boost等的时候,应该不会再有问题了吧。

springboot和mybatis结合

springboot和mybatis结合

依赖和数据源配置

springboot依赖了spring4,需要依赖mybatis-spring,最新版本是1.2.2。
数据源相关的依赖:

<!-- datasource -->
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP-java6</artifactId>
    <version>${HikariCP.version}</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${mysql-connector-java.version}</version>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>${mybatis.version}</version>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
    <version>${mybatis-spring.version}</version>
</dependency>

前两个是数据源的依赖,包括HikariCP和mysql驱动。后面两个是mybatis依赖,包括mybatis本身和mybatis-spring模块。

有了这些依赖之后,就可以通过spring4的配置类,对mybatis数据源等进行配置。

@Configuration
@PropertySource("classpath:datasource.properties")
@MapperScan(basePackages="xxx.repository", sqlSessionFactoryRef = "sqlSessionFactory")
public class DatasourceConfig {
    @Autowired
    private Environment env;

    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setDriverClassName("com.mysql.jdbc.Driver");
        config.setAutoCommit(false);
        config.setJdbcUrl(env.getProperty("xxx.db.url"));
        config.setUsername(env.getProperty("xxx.db.username"));
        config.setPassword(env.getProperty("xxx.db.password"));

        return new HikariDataSource(config);
    }

    @Bean
    public DataSourceTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dataSource());
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(dataSource);
        sessionFactory.setTypeAliasesPackage("xxx.mybatis");
        return sessionFactory.getObject();
    }

}

首先引入配置文件,并且注入到env对象中。env类似System的properties对象,封装了配置文件中的key value。
然后通过MapperScan注解定义mapper接口包路径。这里同时定义了sqlSessionFactoryRef,是因为需要用到多数据源,
防止spring无法注入,后面会提到。

之后代码就可以开始定义输出的bean。一个是datasource,直接初始化一个Hikari的数据源,springboot提供了builder类,
但是查看源码和api之后,DataSourceBuilder无法配置autocommit属性。

再下面是事务管理,需要通过构造函数注入dataSource。最后一个是mybatis的sqlSessionFactory,主要也是注入一个数据源。

mapper(DAO)实现

dao实现和原先的ibatis差不多,但是mybatis可以通过注解的形式直接生成动态sql。既然springboot用了代码来取代xml,mybatis
中也同样去掉了xml。

插入

插入操作需要注意两个地方,一个是如何返回插入之后的主键(mysql),一个是如何使用数据类型的handler。
首先看代码:

@Insert("INSERT INTO aegis_cron_timer " +
            "(id, gmt_create, gmt_modified, name, expression, event_class_name, description, last_trigger_time, status, parameter) " +
            "VALUES (NULL, now(), now(), #{name:VARCHAR}, #{expression:VARCHAR}, " +
            "#{eventClassName:VARCHAR}, #{description:VARCHAR}, now(), #{status:VARCHAR}, " +
            "#{parameter,typeHandler=com.alibaba.aegis.seawater.cron.service.dao.mybatis.MapToJsonTypeHandler})")
@SelectKey(before = false, statement = "SELECT LAST_INSERT_ID()", keyProperty = "id", resultType = java.lang.Long.class)
public Long insertCronTimer(CronTimer cronTimer);

针对mysql,可以通过SelectKey这个注解,设置插入后主键的返回。由于mysql是自增主键,所以设置为插入后执行,定义返回的类型为long
(数据库中定义了bigint)。

另外,这里有个字段需要从map序列化成json字符串,作为varchar类型存放到数据库中。在插入的sql中,可以直接在变量后面定义typeHandler,
值是对应handler的完整类名。

更新

更新操作比较简单,直接使用Update注解即可。和插入类似,如果需要指定type handler,直接在字段后面增加参数即可。更新函数可以返回一个int值,
表示本次更新的行数。

查询

查询通过Select注解完成,mybatis可以直接通过字段名字和查询结果的java bean之间做自动关联。如果名字不匹配,有两种方式,一种是通过sql中
增加AS关键字转成java bean中的字段名,一种是通过@Result注解指定二者的映射关系。

@Select("SELECT name, expression, event_class_name AS eventClassName, description, status, parameter " +
            "FROM aegis_cron_timer " +
            "WHERE status = 'ENABLE'")
@Results({
        @Result(column = "parameter", jdbcType = JdbcType.VARCHAR, property = "parameter", typeHandler = MapToJsonTypeHandler.class)
})
public List<CronTimer> listAllAvailableCronTimer();

这里通过Result注解配置了type handler,特别注意Result注解必须在Results注解中,不然不会生效。

自定义type handler

前文已经提到了如何在插入、更新、查询语句中使用type handler,type handler实现也比较简单。mybatis自带的type handler都是通过extends
BaseTypeHandler来实现的,但例子中直接实现了TypeHandler接口:

@MappedTypes(Map.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class MapToJsonTypeHandler implements TypeHandler<Map<String, Object>> {

    @Override
    public void setParameter(PreparedStatement ps, int i, Map<String, Object> parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, JSON.toJSONString(parameter));
    }

    @Override
    public Map<String, Object> getResult(ResultSet rs, String columnName) throws SQLException {
        String value = rs.getString(columnName);
        return jsonToMap(value);
    }

    @Override
    public Map<String, Object> getResult(ResultSet rs, int columnIndex) throws SQLException {
        String value = rs.getString(columnIndex);
        return jsonToMap(value);
    }

    @Override
    public Map<String, Object> getResult(CallableStatement cs, int columnIndex) throws SQLException {
        String value = cs.getString(columnIndex);
        return jsonToMap(value);
    }

    private Map<String,Object> jsonToMap(String value) {
        if (StringUtils.isBlank(value)) {
            return Collections.emptyMap();
        } else {
            return JSON.parseObject(value, new TypeReference<Map<String, Object>>() {
            });
        }
    }
}

实现比较简单,序列化的时候直接通过fastjson将map对象转成json string,放到PreparedStatement中。反序列化的时候返回来转成Map即可。

多数据源实现

由于项目需要从老的数据库迁移到新的数据库,所以需要两个数据源,在设置多数据源的时候也踩了很多坑。

另一个数据源配置类:

@Configuration
@PropertySource("classpath:amon-datasource.properties")
@MapperScan(basePackages="com.alibaba.aegis.seawater.cron.migrate.repository",
        sqlSessionFactoryRef = "amonSqlSessionFactory", sqlSessionTemplateRef = "amonSqlSessionTemplate")
public class AmonDataSourceConfig {
    @Autowired
    private Environment env;

    @Bean(name = "amonDataSource")
    public DataSource amonDataSource() {
        HikariConfig config = new HikariConfig();
        config.setDriverClassName("com.mysql.jdbc.Driver");
        config.setAutoCommit(true);
        config.setJdbcUrl(env.getProperty("amon.db.url"));
        config.setUsername(env.getProperty("amon.db.username"));
        config.setPassword(env.getProperty("amon.db.password"));

        return new HikariDataSource(config);
    }

    @Bean(name = "amonTransactionManager")
    public DataSourceTransactionManager amonTransactionManager(@Qualifier("amonDataSource")DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    @Bean(name = "amonSqlSessionFactory")
    public SqlSessionFactory amonSqlSessionFactory(@Qualifier("amonDataSource")DataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(dataSource);
        return sessionFactory.getObject();
    }

    @Bean(name = "amonSqlSessionTemplate")
    public SqlSessionTemplate amonSqlSessionTemplate(@Qualifier("amonSqlSessionFactory")SqlSessionFactory sqlSessionFactory) throws Exception {
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

这里也定义了一个配置文件,需要注意的是不要和前面一个的key相同,不然会覆盖的。定义bean的时候需要设置下name,或者函数名字改了也行。
需要定义的bean和之前的一样,特别注意MapperScan注解需要修改sqlSessionFactoryRef或者sqlSessionTemplateRef。这里两个都改了但是启动的时候会
提示:

Cannot use both: sqlSessionTemplate and sqlSessionFactory together. sqlSessionFactory is ignored.

这边定义了bean之后,直接使用就没有问题了。唯一需要特别注意的是@Transactional注解,由于定义了两个transactionManager,
无法通过类型来注入事务管理器了,需要注解中特别指定。比如使用前面定义的数据源的事物管理器,需要改成:

@Transactional("transactionManager")

这样spring可以通过名字注入bean。

DAO测试

为了方便测试,对应测试类中,重新覆盖了dataSource,采用h2这种内存数据库,解决单元测试数据干扰。

@Configuration
@MapperScan(basePackages="com.alibaba.aegis.seawater.cron.repository")
public class TestDatasourceConfig extends DatasourceConfig {
    @Autowired
    private Environment env;

    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .setName("cron")
                .addScript("h2.sql")
                .build();
    }

}

这里直接通过springboot提供的EmbeddedDatabaseBuilder来创建一个h2的数据库,并添加初始化数据库schema的sql文件。
这里需要注意的是,如果这个sql文件直接叫schema.sql,之前mysql数据源在执行的时候也会去执行,因此这里没有使用默认的名字。

其他坑

在springboot注入properties文件中配置的时候,还遇到一个恶心的问题,除了PropertySource注解指定的properties文件之外,
spring还会默认带上jvm变量、系统环境变量。刚开始直接把数据库用户名字段的key写成了username,结果由于测试服务器上使用了sudo
命令,sudo在切换用户的同时设置了USERNAME这个环境变量标识原始执行用户,导致springboot一直在注入这个值,调试了很久。