在 Spring 托管的项目中引入 h2 database 作单元测试

为了让单元测试尽可能的脱离开发环境种种依赖, 最好的办法是在单元测试的时候引入内存数据库.

之前我们也曾使用过 hsqldb 来做单元测试, 但是在刚开始的时候, 还能满足简单的测试需求; 随着数据库结构和查询条件越来越复杂, 发现 hsqldb 的弊端体现的越来越明显, 最终不得不放弃它.

hsqldb 在使用中碰到的最大的几个问题是, 与 mysql 数据库的 sql 存在较大差异, 尤其体现在 自增IDLIMIT 的使用上. 使得我们为了单元测试而不得不去维护两套不同的 sql. 成本非常大, 也丧失了单元测试的优点.

直到我们引用了 h2:

相比 hsqldb 而言, h2 带来的最大改善, 就是几乎完全兼容以前 mysql sql, DDL 直接就能正常执行, 简直太方便了.

下面说一下简单的配置, 以方便大家作参考.

maven 配置文件 pom.xml 中引入相关的 dependency:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.3.173</version>
</dependency>
<dependency>
    <groupId>org.testng</groupId>
    <artifactId>testng</artifactId>
    <version>6.8.5</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-test</artifactId>
    <version>3.0.5.RELEASE</version>
</dependency>

下面为单元测试指定 datasource. 个人经验是, 为了方便开发环境和单元测试环境使用不同的 datasource, 我们将 spring 中关于 datasource 的配置单独放在一个文件中. 这样的好处是, 在 src/main/resources/src/test/resources/ 下各有一个同名的 datasource 配置文件, 而在执行 test 时, 会自动用 test resources 中的配置文件替换 main中的配置.

applicationContext-datasource.xml

<?xml version="1.0" encoding="UTF-8"?>

<beans xmlns="http://www.springframework.org/schema/beans"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:jdbc="http://www.springframework.org/schema/jdbc"
        xsi:schemaLocation="http://www.springframework.org/schema/beans
                http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
                http://www.springframework.org/schema/jdbc
                http://www.springframework.org/schema/jdbc/spring-jdbc.xsd">

  <jdbc:embedded-database id="dataSource" type="H2">
    <jdbc:script location="classpath:database/h2_schema.sql"/>
    <jdbc:script location="classpath:database/h2_test_data.sql"/>
  </jdbc:embedded-database>

</beans>

这里看到我们导入了两个 sql 文件, 分别用来创建表结构和导入测试数据. 基本的 sql 语法就不说, 记得在 sql 的第一行 标注 SET MODE MYSQL; 用来表示兼容 mysql 语义:

SET MODE MYSQL;

-- --------------------------------------------------------

--
-- Table structure for table us_app
--

CREATE TABLE IF NOT EXISTS us_app (
  id int(11) NOT NULL AUTO_INCREMENT,
  title varchar(31) NOT NULL,
  username varchar(255) NOT NULL,
  password varchar(255) NOT NULL,
  grant_id int(11) NOT NULL,
  create_user varchar(30) NOT NULL,
  update_user varchar(30) NOT NULL,
  create_time datetime NOT NULL,
  update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
);

之后在 applicationContext.xml import 这个文件:

applicationContext.xml

<?xml version="1.0" encoding="UTF-8"?>

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
                http://www.springframework.org/schema/context
                http://www.springframework.org/schema/context/spring-context-3.0.xsd">

    <context:property-placeholder location="classpath:service.db.properties, classpath:service.properties"/>

    <import resource="applicationContext-service.xml"/>
    <import resource="applicationContext-datasource.xml"/>
    <import resource="applicationContext-mybatis.xml"/>

    <!-- 指明需要进行annotation扫描的包 -->
    <context:component-scan base-package="com.vipshop.auth"/>

</beans>

之后的操作就没什么好说了, 最好创建一个单元测试的 BASE 类, 用来继承 TestNG 和加载 Spring 配置文件:

/**
 * @author: dan.shan
 * @since: 2013-08-14 22:47
 */
@ContextConfiguration(locations = { "classpath:/spring/applicationContext.xml" })
public abstract class SpringContextTestParent extends AbstractTestNGSpringContextTests {

}

而真正的单元测试 class 继承该类:

/**
 * @author: dan.shan
 * @since: 2013-08-14 22:52
 */
public class ProfileRecoDaoTest extends SpringContextTestParent {

    @Autowired
    private ProfileRecoDao profileRecoDao;

    @Test
    public void testFindOne() {
        int userId = 1;
        ProfileReco result = profileRecoDao.findOne(userId);

        assertEquals("A", result.getType());
    }

    @Test
    public void testFindOneNotExsit() {
        ProfileReco result = profileRecoDao.findOne(Integer.MAX_VALUE);
        assertNull(result);
    }
}

后面不用说了, 执行就是了.

通过 spring 的 EL 表达式解决不同环境的部署参数配置问题

在我们一个真实项目中, 用到了 QA/DEV/TEST/PRODUCT 四套部署环境. 前三套类似, 只是在 PRODUCT 环境中, 公司的运维规范是将配置信息写到 linux 系统的环境变量中, 而这个配置信息的值是不能公开给我们的开发人员的. 这就需要我们在项目的部署问题上支持多种环境的配置方式.

我们需要满足下面几点需求:

  1. 交付给运维人员的是一个直接可以部署的 war 包. 运维只根据约定的参数直接修改系统的环境变量即可完成部署工作.
  2. 对于 QA 和开发人员, 同样是交付给他们一个可部署的 war 包, 而这个 war 中的配置信息是写在配置文件中. 不需配置环境变量.
  3. 对于不同环境的打包, 不应有任何代码的修改, 直接通过一个参数打成对应不同环境的 war.
  4. 运维今后可能会有对系统环境变量中的值作加密的需求, 也就是说, 这套部署逻辑应当支持对取值的算法定制.

通过我的前面的一篇文章, 介绍了如何通过 maven 将项目根据不同环境要求进行打包. 参考Maven 根据不同的 profile 对不同的构建环境进行配置.

下面我们在基于那篇文章作进一步的修改, 以满足我们新增的需求.

首先来聊聊 Spring 的 EL 表达式 SpEL

6.4 Expression support for defining bean definitions

SpEL expressions can be used with XML or annotation based configuration metadata for defining BeanDefinitions. In both cases the syntax to define the expression is of the form #{ <expression string> }.

6.4.1 XML based configuration

A property or constructor-arg value can be set using expressions as shown below

<bean id="numberGuess" class="org.spring.samples.NumberGuess">
  <property name="randomNumber" value="#{ T(java.lang.Math).random() * 100.0 }"/>
</bean>

The variable ‘systemProperties’ is predefined, so you can use it in your expressions as shown below. Note that you do not have to prefix the predefined variable with the ‘#’ symbol in this context.

<bean id="taxCalculator" class="org.spring.samples.TaxCalculator">
  <property name="defaultLocale" value="#{ systemProperties['user.region'] }"/>
</bean>

You can also refer to other bean properties by name, for example.

<bean id="numberGuess" class="org.spring.samples.NumberGuess">
  <property name="randomNumber" value="#{ T(java.lang.Math).random() * 100.0 }"/>
</bean>

<bean id="shapeGuess" class="org.spring.samples.ShapeGuess">
  <property name="initialShapeSeed" value="#{ numberGuess.randomNumber }"/>
</bean>

通过这几个例子, 我们知道了 EL 中不但可以直接使用参数, 还可以直接调用 java 的方法. 我们下面就开始利用这些特性来解决前面的问题.

已数据库配置为例, 我们需要创建一个 Class, 用于读取系统环境变量:

package com.vipshop.passport.core.common;

/**
 * 读取系统环境变量
 * @author dan.shan
 * @since 2013-5-30 18:34:02
 */
public class SystemUtil {
    
    /**
     * 读取系统变量
     * @author dan.shan
     * @since 2013-5-30 18:34:08 
     **/
    private static String getSystemValue(String key){
        return System.getenv(key);
    }
    
    public static String getDBUrl(){
        String host = getSystemValue("VIP_DB_HOST");
        String database = getSystemValue("VIP_DB_DATABASE");
        if(SuperString.isBlank(host) || SuperString.isBlank(database)){
            return null;
        }
        
        return "jdbc:mysql://" + host + "/" + database + "?useUnicode=true&amp;characterEncoding=utf8";
    }
    
    public static String getUserName(){
        String userName = getSystemValue("VIP_DB_USERNAME");
        return SuperString.isBlank(userName) ? null : userName;
    }
    
    public static String getPassword(){
        String password = getSystemValue("VIP_DB_PASSWORD");
        return SuperString.isBlank(password) ? null : password;
    }
    
}

java 中定义了 getDBUrl(), getUserName(), getPassword() 来获取数据库链接信息. 我们在 spring 中对 datasource 的配置稍作修改:

<!-- MySQL -->
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close"> 
    <property name="driverClass" value="${db.driver}" />
    <property name="jdbcUrl" value="#{'${db.url}' == '' ? T(com.vipshop.passport.core.common.SystemUtil).getDBUrl() : '${db.url}'}" />
    <property name="user" value="#{'${db.user}' == '' ? T(com.vipshop.passport.core.common.SystemUtil).getUserName() : '${db.user}'}" />
    <property name="password" value="#{'${db.password}' == '' ? T(com.vipshop.passport.core.common.SystemUtil).getPassword() : '${db.password}'}" />
    <!-- 省略c3p0配置 -->
</bean>

可以看到, xml 中尝试了去读取 ${db.driver}, ${db.url}, ${db.user}, ${db.password}. 当这些参数不为空的时候, 就会调用刚刚我们定义的 class 的方法取获取环境变量. 而至于这四个变量从哪里来的, 可以参考文首提到的那篇关于 maven filter 的文章.

下面我们针对不同的环境, 来写不同的 filter:

# filter-product.properties
db.default.driver=com.mysql.jdbc.Driver
db.url=
db.user=
db.password=

# filter-dev.properties
db.default.driver=com.mysql.jdbc.Driver
db.url=jdbc:mysql://vipshop.db.master:3306/passport?useUnicode=true&amp;characterEncoding=utf8
db.user=passport
db.password=passport

可见, 我们将生产环境的配置项全部留空, 这样打包的时候 maven, 会根据配置文件的内容, 将空字符串写到spring的配置文件中:

<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close"> 
    <property name="jdbcUrl" value="#{'' == '' ? T(com.vipshop.passport.core.common.SystemUtil).getFDSDBUrl() : ''" />
</bean>

这样就会 spring 就会从系统环境变量去加载配置信息.

关于 Redis 中的 Expire 使用时的一些注意事项

在公司处理一个 php 的项目, 要用 java 重新实现底层的逻辑功能. 碰到这样一个很蛋疼的情况:

以前的数据库设计非常混乱, User表中, 关于注册时间有如下两个字段, 表示重复的功能:

reg_date int(8) 示例”20130101”
add_time char(8) 示例”2013-01-01 23:59:59”

同样在user表中, 存在着各种奇怪的时间格式

birthday char(10) 示例”20130101”
web_time char(20)
congeal_time char(10)
last_time char(20)
stat_time char(20)
order_consume_time int(11)
final_mail_time int(11)
subscribe_time int(10)

更奇怪的是, 同样是add_time, 表示添加时间, 在不同的表里有完全不同的时间格式, 长度也不同:

User表:

add_time char(20) 示例”2013-01-01 23:59:59”

mark_record表:

add_time int(11), 1970-01-01至今的秒数

user_size表:

create_time int(10), 1970-01-01至今的秒数

看到这种数据库, 直接崩溃了. 没办法, 历史遗留问题, 一定要处理的, 我这里的解决方案是, 在 hibernate 的映射 VO 定义中, 对一些恶心的字段进行封装

@Entity
@org.hibernate.annotations.Entity(dynamicInsert = true, dynamicUpdate = true)
@Table(name="user")
public class User {

    /** 
     * 真正的注册时间.
     * 修改这个时间的set方法, 给另两个时间进行赋值
     */
    @Transient
    private Date createTime = null;
    
    /** 另一个欠干的创建时间 yyyy-MM-dd HH-mm-ss*/
    @Column(name="add_time", length=20, nullable=true, updatable=false)
    private String createTimeFuckString = null;
    
    /**
     * 另一个前干的创建时间创建时间 yyyyMMdd, 不知道有个P用, 
     * 原有的数据库里保存了两个用于描述注册时间的不同格式字段, 不敢删, 只能原样保留.
     * 同样崩溃的地方还有: birthday的格式为String的yyyyMMdd
     */
    @Column(name="reg_date", length=8, nullable=true)
    private Integer createTimeFuckInt = null;
    
    public User() {
        super();
    }

    /** @return the createTime */
    public Date getCreateTime() {
        return createTime;
    }
    
    /** @param createTime the createTime to set */
    public void setCreateTime(Date createTime) {
        this.createTime = createTime;
        if (createTime == null) {
            this.createTimeFuckString = null;
            this.createTimeFuckInt = null;
        } else {
            String createTimeStr = SuperDate.formatDateTime(createTime, "yyyyMMdd");
            this.createTimeFuckInt = Integer.parseInt(createTimeStr);
            this.createTimeFuckString = new SuperDate(createTime).getDateTimeString();
        }
    }

    /** @return the createTimeFuckString */
    public String getCreateTimeFuckString() {
        return createTimeFuckString;
    }

    /** @param createTimeFuckString the createTimeFuck to set */
    private void setCreateTimeFuckString(String createTimeFuckString) {
        this.createTimeFuckString = createTimeFuckString;
        if (createTimeFuckString == null) {
            this.createTime = null;
            this.createTimeFuckString = null;
        } else {
            SuperDate date = new SuperDate(createTimeFuckString);
            this.createTime = date.getDate();
            this.createTimeFuckInt = Integer.parseInt(
                    SuperDate.formatDateTime(this.createTime, "yyyyMMdd"));
        }
    }

    /** @return the createTimeFuckInt */
    public Integer getCreateTimeFuckInt() {
        return createTimeFuckInt;
    }

    /** @param createTimeFuckInt the createTimeFuckInt to set */
    private void setCreateTimeFuckInt(Integer createTimeFuckInt) {
        this.createTimeFuckInt = createTimeFuckInt;
        if (createTimeFuckInt == null) {
            this.createTime = null;
            this.createTimeFuckString = null;
        } else {
            String createTimeFuckIntStr = String.valueOf(createTimeFuckInt);
            if (createTimeFuckIntStr.length() != 8) {
                return;
            }
            
            SuperDate date = new SuperDate(
                    createTimeFuckIntStr.substring(0, 4),
                    createTimeFuckIntStr.substring(4, 6),
                    createTimeFuckIntStr.substring(6));
            this.createTime = date.getDate();
            this.createTimeFuckString = date.getDateTimeString();
        }
    }
}

代码只列出关于时间的操作. 创建一个 Date 类型的 createTime, 作为唯一的 public set 操作. createTimeFuckInt, createTimeFuckString 这两个字段分别对应着映射到数据库的列, 但将 set 方法设置为 private的.

这里注意一个细节, 在定义 createTime 时, 要添加 @Transient, 来申明这个树形在数据库中不做映射操作.

    /** 
     * 真正的注册时间.
     * 修改这个时间的set方法, 给另两个时间进行赋值
     */
    @Transient
    private Date createTime = null;
过滤 hibernate 映射中的某些指定属性

在公司处理一个 php 的项目, 要用 java 重新实现底层的逻辑功能. 碰到这样一个很蛋疼的情况:

以前的数据库设计非常混乱, User表中, 关于注册时间有如下两个字段, 表示重复的功能:

reg_date int(8) 示例”20130101”
add_time char(8) 示例”2013-01-01 23:59:59”

同样在user表中, 存在着各种奇怪的时间格式

birthday char(10) 示例”20130101”
web_time char(20)
congeal_time char(10)
last_time char(20)
stat_time char(20)
order_consume_time int(11)
final_mail_time int(11)
subscribe_time int(10)

更奇怪的是, 同样是add_time, 表示添加时间, 在不同的表里有完全不同的时间格式, 长度也不同:

User表:

add_time char(20) 示例”2013-01-01 23:59:59”

mark_record表:

add_time int(11), 1970-01-01至今的秒数

user_size表:

create_time int(10), 1970-01-01至今的秒数

看到这种数据库, 直接崩溃了. 没办法, 历史遗留问题, 一定要处理的, 我这里的解决方案是, 在 hibernate 的映射 VO 定义中, 对一些恶心的字段进行封装

@Entity
@org.hibernate.annotations.Entity(dynamicInsert = true, dynamicUpdate = true)
@Table(name="user")
public class User {

    /** 
     * 真正的注册时间.
     * 修改这个时间的set方法, 给另两个时间进行赋值
     */
    @Transient
    private Date createTime = null;
    
    /** 另一个欠干的创建时间 yyyy-MM-dd HH-mm-ss*/
    @Column(name="add_time", length=20, nullable=true, updatable=false)
    private String createTimeFuckString = null;
    
    /**
     * 另一个前干的创建时间创建时间 yyyyMMdd, 不知道有个P用, 
     * 原有的数据库里保存了两个用于描述注册时间的不同格式字段, 不敢删, 只能原样保留.
     * 同样崩溃的地方还有: birthday的格式为String的yyyyMMdd
     */
    @Column(name="reg_date", length=8, nullable=true)
    private Integer createTimeFuckInt = null;
    
    public User() {
        super();
    }

    /** @return the createTime */
    public Date getCreateTime() {
        return createTime;
    }
    
    /** @param createTime the createTime to set */
    public void setCreateTime(Date createTime) {
        this.createTime = createTime;
        if (createTime == null) {
            this.createTimeFuckString = null;
            this.createTimeFuckInt = null;
        } else {
            String createTimeStr = SuperDate.formatDateTime(createTime, "yyyyMMdd");
            this.createTimeFuckInt = Integer.parseInt(createTimeStr);
            this.createTimeFuckString = new SuperDate(createTime).getDateTimeString();
        }
    }

    /** @return the createTimeFuckString */
    public String getCreateTimeFuckString() {
        return createTimeFuckString;
    }

    /** @param createTimeFuckString the createTimeFuck to set */
    private void setCreateTimeFuckString(String createTimeFuckString) {
        this.createTimeFuckString = createTimeFuckString;
        if (createTimeFuckString == null) {
            this.createTime = null;
            this.createTimeFuckString = null;
        } else {
            SuperDate date = new SuperDate(createTimeFuckString);
            this.createTime = date.getDate();
            this.createTimeFuckInt = Integer.parseInt(
                    SuperDate.formatDateTime(this.createTime, "yyyyMMdd"));
        }
    }

    /** @return the createTimeFuckInt */
    public Integer getCreateTimeFuckInt() {
        return createTimeFuckInt;
    }

    /** @param createTimeFuckInt the createTimeFuckInt to set */
    private void setCreateTimeFuckInt(Integer createTimeFuckInt) {
        this.createTimeFuckInt = createTimeFuckInt;
        if (createTimeFuckInt == null) {
            this.createTime = null;
            this.createTimeFuckString = null;
        } else {
            String createTimeFuckIntStr = String.valueOf(createTimeFuckInt);
            if (createTimeFuckIntStr.length() != 8) {
                return;
            }
            
            SuperDate date = new SuperDate(
                    createTimeFuckIntStr.substring(0, 4),
                    createTimeFuckIntStr.substring(4, 6),
                    createTimeFuckIntStr.substring(6));
            this.createTime = date.getDate();
            this.createTimeFuckString = date.getDateTimeString();
        }
    }
}

代码只列出关于时间的操作. 创建一个 Date 类型的 createTime, 作为唯一的 public set 操作. createTimeFuckInt, createTimeFuckString 这两个字段分别对应着映射到数据库的列, 但将 set 方法设置为 private的.

这里注意一个细节, 在定义 createTime 时, 要添加 @Transient, 来申明这个树形在数据库中不做映射操作.

    /** 
     * 真正的注册时间.
     * 修改这个时间的set方法, 给另两个时间进行赋值
     */
    @Transient
    private Date createTime = null;
使用 jenkins 对 bitbucket 上的私人项目作持续集成

我有几个放置在 Bitbucket 上的私人项目, 以及一台放置在香港的 Linode vps, 想在这台 linode 上部署一个 jenkins 服务, 实现对 bitbucket 上的这些项目作持续集成. 下面来说流程, 基于目前最新的1.502版本.

至于怎么安装 jenkins 以及怎么配置 resin/nginx 等服务, 不在本文的讨论范围, 这里关注的重点在于配置 bitbucket 项目.

配置 Jenkins

首先, 解决安全问题, 既然 bitbucket 上的项目是私人的, jenkins 自然也不能开放公共权限. 我们要设置 jenkins.

通过左上角进入系统配置菜单: Jenkins > Manage Jenkins > Configure Global Security, 勾选 Enable security, 下面_Security Realm_支持了四种安全策略, 我们选择最简单的 Jenkins’s own user database, 但不要勾选下面的 Allow users to sign up.

在下面的 Authorization 中选择 Project-based Matrix Authorization Strategy, 以获得最灵活的权限控制功能. 配置完成后, 把jenkins重启, 这个时候会提示要求输入用户名和密码, 由于是第一次登录, 直接点击左上角 jenkins, 这里提示了注册功能, 注册唯一的管理员用户. 注册完成后就能登录了, 之后可以再次进入刚刚的 Configure Global Security 配置用户权限.

导入 Bitbucket 项目

配置好了 jenkins, 我们下面来创建指向 bitbucket 的项目. Jenkins 中点击 New Job, 设置 Job name 并选择 Build a free-style software project. 下面进入了项目的配置页面, 最主要的几个地方是:

  • Source Code Management

这里当然选择 Git. 但是要注意一点, jenkins 不支持 HTTPS 方式, 所以我们必须在 bitbucket 中找到项目的 SSH 地址, 而且同时我们也要在jenkins的服务器上生成 ssh key. 这里简单介绍步骤:

登录 linode 服务器, 执行下面的命令生成 ssh key:

$ ssh-keygen -t rsa

默认生成的key会保存在 ~/.ssh/id_rsa.pub, 我们把内容 cat 出来并复制.

$ cat ~/.ssh/id_rsa.pub

进入 bitbucket 的项目配置页面, 在 Deployment keys 点击 Add key, Label 随便写, Key 中粘贴前面复制出来的 id_rsa.pub 的内容.

保存后进入 bitbucket 的项目主页, 查看项目的 SSH 地址, 点击 HTTPS 下拉选择 SSH, 地址格式为 git@bitbucket.org:/.git, 把这个地址填到 jenkins 中项目配置页的 Git Repositories 里. _Branches to build_ 可以填 master, 不填则默认是最后 push 的 branch.

下面来配置顶起 build 的周期, 我选择的做法是勾选 Build TriggersPoll SCM , 填入 * * * * *, 这样让 jenkins 每分钟检查一次 bitbucket, 如果有新的修改, 则自动 build.

到此, 就完成了所有关于 jenkins 的配置, 点击左边的 Build Now试试看吧.