ElasticSearch 多值类型的数据存储

1. 使用场景

一般来说,我们的离线计算和统计都是在 Hive 里面进行的,但是也有离线计算不适应的场景。

比如在做移动行为分析的时候我们想要知道一连串事件的转化率,这就需要建立一个漏斗。如果使用 Hive 来计算漏斗,那么有几个问题是需要我们考虑的:

  1. 漏斗的建立需要一个尝试和调整的过程,有的时候业务比较复杂,那么漏斗的调整次数也会比较多。每次用 Hive 来计算的话显然是比较慢的。

  2. 同一个漏斗多人查看的话每次都使用 Hive 计算吗?显然不,数据应该使用 MySQL 来缓存已经查询到的结果,那么查询就变成了二级查询:先查 MySQL,如果 MySQL 中没有数据再调用 Hive 计算,然后把计算结果写到 MySQL 中,这就变成了一个比较复杂的异步的过程。我一开始的方案是:首次建立漏斗的话立刻异步地调用 Hive 去计算漏斗的历史数据,然后每天用调度系统将漏斗的配置 Sqoop 导入到 Hive 中进行计算,然后 Sqoop 导出到 MySQL 中用来展示。

  3. 同时也会遇到某些天的数据突然出现问题,修复后需要重启计算任务,那么如何保持 Hive、MySQL 数据的一致性也是需要我们考虑的。

总之:使用 Hive + MySQL 的方式来做漏斗计算不仅慢,而且复杂!两周的冲刺应该是上不了线的!

2. 解决方案

然后老大就让我使用 Hive + ElasticSearch 来做。简单来说,先使用 Hive 把源数据进行处理,得到初步聚合的结果,然后将数据导入到 ElasticSearch 中。使用的时候 Java 客户端程序通过参数构成查询条件向 ElasticSearch 查询数据。

当然坑也踩了不少,首先在使用 ElasticSearch 之前没有做过预先的调研,一直以关系型数据库的思维在想着如何来计算数据和查询数据,然后就发现了 ElasticSearch:

  • 不支持子查询
  • 不支持 COUNT(DISTINCT)
  • 不支持 Join

按照官方文档的说法,他们暂时不支持子查询和 COUNT(DISTINCT),短期内也不想增加这个功能。至于 Join,官方文档的建议是在 Java 程序中进行 Join,也就是说分为两步操作,Java 客户端先获取 Join 的字段,再以查到的 Join 字段作为查询条件进行查询。如果数据量很大的话,两次查询光传输数据就要数十秒了!

当时最大的困惑是我要计算用户事件行为的漏斗转化率,包括 pv 和 uv。问题来了,一开始的计算方案是把每一个用户的每一次事件信息都存储下来,计算的时候就用事件名去过滤然后 COUNT 就能算 pv 了。但是无法对用户去重,uv 计算不了。

好在老大提醒我把每个用户发生的事件及其点击次数放在一个 Map 里面,事件名作为 key,点击次数作为 value,一个用户一行就行。计算 pv 的时候根据对应事件以及它们的点击次数来求 sum,比如 map_type.event_id 就是从一个名叫 map_type 的字段中以 event_id 作为事件名查询点击次数。而求 uv 也很简单,因为每一行就对应一个用户,所以 COUNT 行数就行了。

在做漏斗计算的时候,只需要用 MySQL 来保存漏斗的配置信息,每次在 Java 后端代码中根据漏斗各个步骤的事件名构建查询语句,得到 ElasticSearch 返回的结果简单处理一下即可返回给前端。本来也担心过查询的速度是不是够快,需不需要用 MySQL 缓存漏斗的查询结果。但是真实使用的时候 ElasticSearch 查询的速度完全打消了我的顾虑。

3. 几个值得记录的问题

3.1 Map<String, bigint>

在解决问题的过程中还是有几个点值得提一下,首先 Hive 没有提供原生的将多行记录转成 Map<String, bigint> 类型的方法,str_to_map 函数只能返回 Map<String, String> 类型。当然这里其实可以写一个 UDAF 来解决,但是之前没有写过 UDF 和 UDAF,所以为了节省时间我用了 Streaming 中的 TRANSFORM 来解决这个问题。代码如下:

#!/bin/env python

import sys

def str_to_map(map_str):
    pairs = map_str.split(',')
    ret   = []
    for pair in pairs:
        key, value = pair.split('=')
        ret.append("%s\003%d" % (key, int(value)))
    return '\002'.join(ret)

for line in sys.stdin:
    line     = line.strip()
    fields   = line.split()
    map_str3 = fields.pop()
    map_str2 = fields.pop()
    map_str1 = fields.pop()
    fields.append(str_to_map(map_str1))
    fields.append(str_to_map(map_str2))
    fields.append(str_to_map(map_str3))
    print "\001".join(fields)

在这里返回 Map 类型数据的时候,我采用了默认的分割字符来标记 Map。

这里更新一下,为了节省磁盘空间,公司要求尽量以 ORC 格式来存储数据,导致上述的 Python 脚本不能使用。所以修改了一下 Hive 中 UDF str_to_map,使得其能够返回 Map<String, bigint>。

/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


import org.apache.hadoop.hive.ql.exec.Description;
import org.apache.hadoop.hive.ql.exec.UDFArgumentException;
import org.apache.hadoop.hive.ql.metadata.HiveException;
import org.apache.hadoop.hive.ql.udf.generic.GenericUDF;
import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector;
import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector.Category;
import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspectorConverters;
import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspectorConverters.Converter;
import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspectorFactory;
import org.apache.hadoop.hive.serde2.objectinspector.PrimitiveObjectInspector;
import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorFactory;
import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorUtils;
import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorUtils.PrimitiveGrouping;

import java.util.LinkedHashMap;

/**
 * GenericUDFStringToMap.
 *
 */
@Description(name = "str_to_map", value = "_FUNC_(text, delimiter1, delimiter2) - "
        + "Creates a map by parsing text ", extended = "Split text into key-value pairs"
        + " using two delimiters. The first delimiter seperates pairs, and the"
        + " second delimiter sperates key and value. If only one parameter is given, default"
        + " delimiters are used: ',' as delimiter1 and '=' as delimiter2.")
public class GenericUDFStrToMap extends GenericUDF {
    // Must be deterministic order map for consistent q-test output across Java versions - see HIVE-9161
    private final LinkedHashMap<Object, Long> ret = new LinkedHashMap<Object, Long>();
    private transient Converter soi_text, soi_de1 = null, soi_de2 = null;
    final static String default_de1 = ",";
    final static String default_de2 = ":";

    @Override
    public ObjectInspector initialize(ObjectInspector[] arguments) throws UDFArgumentException {

        for (int idx = 0; idx < Math.min(arguments.length, 3); ++idx) {
            if (arguments[idx].getCategory() != Category.PRIMITIVE
                    || PrimitiveObjectInspectorUtils.getPrimitiveGrouping(
                    ((PrimitiveObjectInspector) arguments[idx]).getPrimitiveCategory())
                    != PrimitiveGrouping.STRING_GROUP) {
                throw new UDFArgumentException("All argument should be string/character type");
            }
        }
        soi_text = ObjectInspectorConverters.getConverter(arguments[0],
                PrimitiveObjectInspectorFactory.javaStringObjectInspector);
        if (arguments.length > 1) {
            soi_de1 = ObjectInspectorConverters.getConverter(arguments[1],
                    PrimitiveObjectInspectorFactory.javaStringObjectInspector);
        }
        if (arguments.length > 2) {
            soi_de2 = ObjectInspectorConverters.getConverter(arguments[2],
                    PrimitiveObjectInspectorFactory.javaStringObjectInspector);
        }

        return ObjectInspectorFactory.getStandardMapObjectInspector(
                PrimitiveObjectInspectorFactory.javaStringObjectInspector,
                PrimitiveObjectInspectorFactory.javaLongObjectInspector);
    }

    @Override
    public Object evaluate(DeferredObject[] arguments) throws HiveException {
        ret.clear();
        String text = (String) soi_text.convert(arguments[0].get());
        String delimiter1 = (soi_de1 == null) ?
                default_de1 : (String) soi_de1.convert(arguments[1].get());
        String delimiter2 = (soi_de2 == null) ?
                default_de2 : (String) soi_de2.convert(arguments[2].get());

        String[] keyValuePairs = text.split(delimiter1);

        for (String keyValuePair : keyValuePairs) {
            String[] keyValue = keyValuePair.split(delimiter2, 2);
            if (keyValue.length < 2) {
                ret.put(keyValuePair, null);
            } else {
                ret.put(keyValue[0], Long.valueOf(keyValue[1]));
            }
        }

        return ret;
    }

    @Override
    public String getDisplayString(String[] children) {
        return "usage not know!";
    }
}

3.2 Index OR Type

在存储的时候还有一个问题需要解决:对于每一天的分区数据,是用 Index 还是 Type 来存储?这个问题的由来和 ElasticSearch 的存储方式以及数据的回溯有关。

ElasticSearch 在设计的时候一开始是对照关系型数据库的。一个 Index 对应一个数据库,Index 下的 Type 对应 Table。但这并不是强制的要求的,数据到底怎么存应该根据自己的使用情景来定,具体可以参照这个文章 index vs type

另外一方面,难免有些数据有错误或者指标口径被修改了,那么要重新计算和导入数据。那么在重新导入数据的时候就要先删除所有的分区数据,恰巧 ElasticSearch 是没有分区的概念的。如果把 Hive 一个表中所有分区数据存在一张表中,用 stat_date 字段来标记日期,那么在删除数据的时候 ElasticSearch 会先查出所有这一天的数据然后再删除,无疑是很慢的解决方法。

最终我采用的是一个分区一个 Index,每次重新导入数据前先把对应的 Index 删除。虽然 Index 更耗资源,但是这样使用更符合一般的工作流程。

Python Unicode 字节串转成中文问题

1. 序

我觉得 Python 2.7 最大的坑就是字符编码的问题,默认不支持中文。就算你加个中文呢注释都要在文件顶部指定:# -*- coding: utf-8 -*-

说真的,我从来搞不懂也记不住这些个编码解码巴拉巴拉。。。

但是前两天还真的遇到一个问题了。

2. 需求

有一份数据要入库,数据来源是从一个 API 获取 json 数据,解析成行之后写到文件里
到 HDFS 目录就好了。

Python 解析 json 非常方便。但是偏偏开发的这个接口的哥们儿返回的都是 \u7f16\u7801\u771f\u8ba8\u538c 这样的结果,查了一下原来是 unicode 中文编码以 ascii 码的方式来解析的结果。

一开始还以为是字符集的问题,查了半天才找到了两个解决方案:

3. 解决方案

a. str.decode

print '''\u7f16\u7801\u771f\u8ba8\u538c'''.decode('unicode_escape')
#输出:编码真讨厌

b. codecs.open

with codecs.open(file_name, 'w', 'utf-8') as f:
    f.write('\n'.join([ '\001'.join(row) for row in lines ]))

4.参考文章

一种 Hive 表存储格式的转换的方式

1. 序

实习的公司考虑到 ORC 格式拥有更高的压缩比和传输效率,所以要求我们建表的时候都采用 ORC 的格式。

好死不死,有个项目要把 Hive 里算出来的数通过 Sqoop 导出到 MySQL 里去。而 Sqoop 对于 ORC 格式的数据导出是有问题的,所以我面临的问题就是把 ORC 分区的数据转换成 TEXT 格式的。

2. 面临的实际状况

  1. 都是外部表
  2. 分区的字段是 yearmonthday
  3. 挂分区时指定 location '2016/03/03'(否则分区目录就会是 /year=2016/month=03/day=03 这样的形式)
  4. 已经有若干天的分区数据

3. 解决方案

最直观的的解决方案就是重新建表,指定 TEXT 作为存储格式,然后将原来的数据直接 insert 进去。

这种方法的缺陷是:虽然可以使用相同的表结构,但是由于名字不能重复,那么之前的计算脚本和调度任务都要进行相应的修改。考虑到一共有四张表,工作量还是挺大的。如果不想改变脚本和任务,那么就要在导出数据之后更改原表的存储方式并重新将数据导回去,还要花费一定的时间。

综合来说,最好要达到两个目标:
1. 不改变表的名字,避免大面积的修改
2. 尽量少地导数据,节省时间

4. 小伎俩

通过了一定时间的思考,我用了一点小伎俩来达成这个任务。

  1. 首先创建一张临时表,建为 TEXT 存储方式的外部表,然后指向原表相同的 HDFS 文件目录。
  2. 为临时表挂上分区,指定 location 确保分区的路径和原表一一对应。
  3. 使用 insert overwrite <TABLE> select ... 以及动态分区的技术,将 ORC 格式的原表读出来,以 TEXT 的格式存回原路径。
  4. drop 原表,按照 TEXT 方式建立新表。
  5. 为新表挂上分区,删掉临时表。

5. 动态分区

a. 开启设置

--允许使用动态分区可通过set hive.exec.dynamic.partition;查看
set hive.exec.dynamic.partition=true;
--当需要设置所有列为dynamic时需要这样设置
set hive.exec.dynamic.partition.mode=nonstrict;
--如果分区总数超过这个数量会报错
set hive.exec.max.dynamic.partitions=1000;
--单个MR Job允许创建分区的最大数量
set hive.exec.max.dynamic.partitions.pernode=1000;

b. 使用方式

假设表的结构是:

CREATE EXTERNAL TABLE temp_table(
    field1    string  comment 'field1',
    field2    string  comment 'field2'
)
COMMENT '临时表'
ROW FORMAT SERDE
  'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'
STORED AS INPUTFORMAT
  'org.apache.hadoop.mapred.TextInputFormat'
OUTPUTFORMAT
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

那么调用的方式如下:

insert overwrite table temp_table_new partition(year,month,day)
select field1, field2, year, month, day
from temp_table;

注意:如果父分区是动态分区,那么子分区不能是静态分区

c. 挂分区的脚本

由于动态分区在创建分区的时候产生的路径形如: year=2016/month=03/day=05,和内部常用的 2016/03/03 的形式不一样,所以我写了一个 Python 脚本先挂分区指定 location

简单来说还是构建 SQL 字符串,然后使用 subprocess 模块来调用 hive -e 'cmd' 来执行。

#!/bin/env python
# -*- coding: utf-8 -*-

from datetime import date, timedelta


def date_from_str(date_str, splitor='-'):
    year, month, day = map(int, date_str.split(splitor))
    return date(year=year, month=month, day=day)

def date_interval(start_date, end_date):
    days = (end_date - start_date).days + 1
    return  [(start_date + timedelta(var_date)) for var_date in range(0, days)]

def add_partitions_cmd(datebase, table, start_date, end_date):
    CMD_STR = "use {};\n".format(datebase)
    for var_date in date_interval(start_date, end_date):
        year, month, day = var_date.isoformat().split('-')
        CMD_STR += "alter table {} add if not exists partition(year='{}', month='{}', day='{}') location '{}/{}/{}';\n" \
                    .format(table, year, month, day, year, month, day)
    return CMD_STR

if __name__ == '__main__':
    import sys

    if len(sys.argv) != 5:
        print 'Usage: python add_partitions.py <DATABASE> <TABLE> <START_DATE> <END_DATE>'
        sys.exit(255)

    datebase   = sys.argv[1]
    table      = sys.argv[2]
    start_date = date_from_str(sys.argv[3])
    end_date   = date_from_str(sys.argv[4])

    ADD_PARTITIONS = add_partitions_cmd(datebase, table, start_date, end_date)
    CMD = 'hive -e "{}"'.format(ADD_PARTITIONS)
    print CMD

    from subprocess import Popen, PIPE
    output, param = Popen(['hive', '-e', ADD_PARTITIONS], stdout=PIPE).communicate()
    print output
    print param

吐槽一下 subprocess

以前使用 Python 执行 Shell 的命令的时候常用 subprocess.getstatusoutput(cmd)。因为同时可以获得输出和返回值。这次一开始也是这么用的,结果老是报错。一查才知道尼玛这个函数没有了!

Python 使用 MySQL 数据库

#!/bin/env python
# -*- coding: utf-8 -*-

import MySQLdb

def printRow(res):
    for prop in res:
        print prop,
    print

conn = MySQLdb.connect(host="localhost",user="root",passwd="root",db="employees",port=3306)
cur = conn.cursor()

# fetchone()
# 获取一条结果
print '='*5 + 'fetchone' + '='*5
rows = cur.execute("select count(*) from employees")
result = cur.fetchone()
print "rows:%d, result:%d" % (rows,result[0])

# fetchmany()
# 获取多条结果
print '='*5 + 'fetchmany' + '='*5
rows = cur.execute("select * from employees limit 10")
print "rows:%d" % rows
result = cur.fetchmany(5)
print "first 5:"
for res in result:
    printRow(res)
print "last 5:"
result = cur.fetchmany(5)
for res in result:
    for prop in res:
        print prop,
    print

# fetchall()
# 获取所有结果
print '='*5 + 'fetchall' + '='*5
rows = cur.execute("select * from employees limit 10")
print "rows:%d" % rows
result = cur.fetchall()
print "all 10:"
for res in result:
    printRow(res)

# scroll()
# 控制游标的移动
print '='*5 + 'scroll' + '='*5
rows = cur.execute("select * from employees limit 10")
print "rows:%d" % rows
print "first row:"
first_row = cur.fetchone()
printRow(first_row)
# absolute 从第 0 条位置向下移动 2 条
print "third row:"
cur.scroll(2,mode='absolute')
third_row = cur.fetchone()
printRow(third_row)
# relative 从当前移动 3 条
print "seventh row:"
cur.scroll(3,mode='relative')
seventh_row = cur.fetchone()
printRow(seventh_row)

# executemany()
# 用多个参数执行同一条语句,参数是所有支持迭代的对象
print '='*5 + 'executemany' + '='*5
genders = ['F','M']
rows = cur.executemany('select * from employees where gender=%s limit 1', genders)
result = cur.fetchall()
print rows
print result
# 执行结果表明每一条语句执行过后游标 cursor 都会改变。
# 因此不适合多参数的查询,而适合多参数的插入

# conn.commit()
# 用来执行事务
print '='*5 + 'commit' + '='*5
try:
    cur.execute("insert into employees values(0,date(now()),'Jack','Sparrow','M',date(now()))")
    cur.execute("insert into employees values(1,date(now()),'Jack','Sparrow','M',date(now()))")
    raise MySQLdb.Error
    conn.commit()
except MySQLdb.Error,e:
    # cur.execute("delete from employees where emp_no in (0,1)")
    print "Error Message: %s" % str(e.args)

cur.close()
conn.close()

SVN迁移到Git:保持提交记录

1.前言

最近组里要把之前在 SVN 上管理的项目迁移到 SVN 上去,所以花了一个晚上研究了一下 SVN 向 Git 迁移的方法。这里首先给出一种能保存提交记录的方案。

2.实施准备

再实施迁移之前,有几件事情是必须做的:

  1. 获取 SVN 路径权限
  2. 将本机的公钥提交到 Git 服务器上
  3. 在 Git 服务器上建立仓库

3.实施过程

实施的过程分为以下几个步骤:

  1. 将 SVN checkout 到本地
  2. 获取用户名列表
  3. 用 git svn clone 创建 Git 仓库
  4. 导出 .gitignore 文件
  5. 调整分支和标签
  6. 推送到远程

a. 将 SVN checkout 到本地

svn checkout --username USER_NAME --password PASSWORD https://svn.xxx/dir_name  dir_name

b. 获取用户名列表

通过如下命令获得用户列表:

svn log --xml | grep -P "^<author" | sort -u | perl -pe 's/<author>(.*?)<\/author>/$1 = /'

然后按照如下格式放入一个文件中(比如 user.txt):

schacon = Scott Chacon <schacon@geemail.com>
selse = Someo Nelse <selse@geemail.com>

c. 用 git svn clone 创建 Git 仓库

git svn clone --no-metadata -A users.txt https://svn.xxx/dir_name  dir_name

d. 导出 .gitignore 文件

git svn show-ignore > .gitignore
git add .gitignore
git commit -m 'Convert svn:ignore properties to .gitignore.'

e. 调整分支和标签

首先要移动标签,把它们从奇怪的远程分支变成实际的标签,然后把剩下的分支移动到本地。

cp -Rf .git/refs/remotes/tags/* .git/refs/tags/
rm -Rf .git/refs/remotes/tags

接下来,把 refs/remotes 下面剩下的索引变成本地分支:

cp -Rf .git/refs/remotes/* .git/refs/heads/
rm -Rf .git/refs/remotes

f. 推送到远程

git remote add origin git@my-git-server:myrepository.git
git push origin --all

4. Python 脚本

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import sys
import commands

if len(sys.argv) < 3:
    print 'Usage: python %s USERNAME PASSWORD' % sys.argv[0]
    exit(255)

user_name = sys.argv[1]
user_pwd  = sys.argv[2]

svn_repos = [
        svn_path,
        ......
        ]

git_repos = [
        git_path,
        ......
        ]

users = {}

def init():
    run_cmd('.', 'mkdir git')
    run_cmd('.', 'mkdir svn')

def run_cmd(dir_name, cmd):
    cmd_str = 'cd %s && %s' % (dir_name, cmd)
    print cmd_str
    status,output = commands.getstatusoutput(cmd_str)
    print 'status: %d' % status
    print 'output: \n%s' % output
    print '=========================================================\n'

    return status,output

def clone_svn_to_local(svn_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    run_cmd('svn', "svn checkout --username %s --password %s %s %s" % (user_name, user_pwd, svn_addr, repo_dir_name))
    _,output = run_cmd('svn/' + repo_dir_name, "svn log --xml | grep -P '^<author' | sort -u | perl -pe 's/<author>(.*?)<\/author>/$1 = /'")
    lines = output.split("\n")
    for line in lines:
        name = line.split('=')[0].strip()
        users[name] = ('%s = %s <%s@didichuxing.com>' % (name, name, name))

def write_users():
    with open('git/users.txt', 'w+') as f:
        for name in users.values():
            f.write('%s\n' % name)

def git_svn_clone(svn_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    run_cmd('git', 'git svn clone --no-metadata -A users.txt %s %s' % (svn_addr, repo_dir_name))
    run_cmd('git/' + repo_dir_name, 'git svn show-ignore > .gitignore')
    run_cmd('git/' + repo_dir_name, 'git add .gitignore')
    run_cmd('git/' + repo_dir_name, "git commit -m 'Convert svn:ignore properties to .gitignore.'")
    run_cmd('git/' + repo_dir_name, 'cp -Rf .git/refs/remotes/tags/* .git/refs/tags/')
    run_cmd('git/' + repo_dir_name, 'rm -Rf .git/refs/remotes/tags')
    run_cmd('git/' + repo_dir_name, 'cp -Rf .git/refs/remotes/* .git/refs/heads/')
    run_cmd('git/' + repo_dir_name, 'rm -Rf .git/refs/remotes')

def diff_git_and_svn_repos(svn_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    git_repo_path = 'git/' + repo_dir_name
    svn_repo_path = 'svn/' + repo_dir_name
    _,output = run_cmd('.', 'diff -r %s %s' % (git_repo_path, svn_repo_path))
    expected = 'Only in git/%s: .git\nOnly in git/%s: .gitignore\nOnly in svn/%s: .svn' % (repo_dir_name, repo_dir_name, repo_dir_name)
    if expected != output:
        raise Exception('迁移后内容不一致:\n%s' % output)

def push_to_remote(svn_addr, git_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    run_cmd('git/' + repo_dir_name, 'git checkout -b svn_migrate_branch')
    run_cmd('git/' + repo_dir_name, 'git remote add origin %s' % git_addr)
    run_cmd('git/' + repo_dir_name, 'git push origin --all')

def get_repo_dir_name(svn_addr):
    return svn_addr.split('/')[-1]

def main():
    init()

    for svn_addr in svn_repos:
        clone_svn_to_local(svn_addr)

    write_users()

    repo_nums = len(git_repos)
    for i in range(0,repo_nums):
        git_svn_clone(svn_repos[i])
        diff_git_and_svn_repos(svn_repos[i])
        push_to_remote(svn_repos[i], git_repos[i])

if __name__ == '__main__':
    main()

4. 参考文献

5. 一点想法

其实大家都能看出来这个脚本大部分都是对 shell 命令的调用。为什么舍近求远呢?
因为本人一直觉得 shell 的内部命令,关于流程控制以及字符串处理等实在不好用。我更倾向于 Python 和 Ruby 这样强大的脚本语言。
其实我对 Python 用的也不是很多,在写脚本的过程中查阅了一些 Python 调用 shell 的资料,也和 Ruby 中类似的代码做个比较。
在上面的脚本中我使用了 commands 包的 getstatusoutput('cmd') 方法 。该函数返回了一个元组,通过序列解包可以同时获得返回值和标准输出流的输出。一般调用方法为:

(status, output) = commands.getstatusoutput('shell 命令')
print status, output

而 Ruby 中也有类似的方法,即 Kernel 模块中的 `cmd` 方法。但是该方法直接返回的只有标准输出,返回值还要靠 $? 来获取。

irb(main):014:0> `echo hello && exit 99`
=> "hello\n"
irb(main):015:0> $?.exitstatus
=> 99

在便利性上,Python 的序列解包使得多个函数返回值变成可能,减少了封装也提高了使用的便利性。

Spring MVC 中的 url 问题

这两天在学习 Spring MVC 的时候与到了一个关于 url 的问题让我很困惑,查找资料和同学讨论没找出一个结论。现在先这里 Mark 一下,等找出原因以后再补完这篇文章。

1. 问题描述

首先上一个表单:

<form:form commandName="book" action="book_update" method="post">
<fieldset>
  <legend>Add a book</legend>
  <form:hidden path="id"/>
    <p>
      <label for="category">Category</label>
      <form:select id="category" path="category.id" items="${categories}"
                   itemLabel="name" itemValue="id"></form:select>
    </p>
    <p>
      <label for="title">Title</label>
      <form:input id="title" path="title" />
    </p>
    <p>
      <label for="author">Author</label>
      <form:input id="author" path="author" />
    </p>
    <p>
      <label for="isbn">ISBN</label>
      <form:input id="isbn" path="isbn" />
    </p>
    <p id="buttons">
      <input id="reset" type="reset" tabindex="4" />
      <input id="submit" type="submit" tabindex="5" 
             value="Update Book" />
    </p>
</fieldset>
</form:form>

这个表单在我访问 http://127.0.0.1:8080/book-demo/book_edit/{id} 时会展示。简单来说,这个 url 的格式可以表示为:

http://hostname[:端口号]/context/action/{id}

在这里 hostname[:端口号] 即为 127.0.0.1:8080context 为 webapp 的名字 book-demo,而后面的 action 则使用了路径变量。

从上面的表单代码可以看到表单提交对应的 actionbook_update。这个表单提交的时候,我预期的 url 是:

http://127.0.0.1:8080/book-demo/book_update

但实际表单提交后访问的 action 为:

http://127.0.0.1:8080/book_demo/book_edit/book_update

也就是说程序把 /book_demo/book_edit 作为了 context,把路径变量作为了 action

2. 尝试的解决方案

显然,错误出在访问 action 的时候产生了错误的 url,所以针对这个错误原因我尝试了两种解决方案:

1) action=”/book_update”

我把表单的 action 改了一下,结果产生的 url 是:

http://127.0.0.1:8080/book_update

显然连 url 的 context 都丢了,action 中带了 / 之后有了一点根路径的意思。

2) action=”/book-demo/book_update”

结果正确,获得了我想要的 url 。

3. 思考

a. 关于 url 产生机制

这个问题不禁引人思考 url 路径产生的机制。

之前做过 ruby on rails 的开发,作为对比:tomcat 运行应用的时候都会带上这个应用的名称,也就构成了第一级的上下文。对比 ruby on rails 在运行应用的时候 url 不会带上应用名称。因此,这个表单在 ruby on rails 中使用不会有问题,但是在这里就会有!

从绝对路径与相对路径的角度来看:首先 action=”book_update” 的写法相当于使用相对路径,把 /book_demo/book_edit 当做了上一级路径。而 action=”/book_update” 以及 action=”/book-demo/update” 则属于绝对路径的范畴。

b. 关于 RESTful 的 url

上面的表单其实并不符合 RESTful 的风格(我觉得 rails 提倡的 RESTful 风格的 url 很是优雅,之前做 rails 的时候一直想写写来着)。比如更新一本书的信息,对应的 action 可以叫 update,路径可以映射为 /books/{id}/update。上文的表单是把书本的 id 信息作为一个隐藏域来传递,虽然可行但绝对不是一个良好的风格。这里附一下 RESTful 风格的路由:
Rails:

url HTTP 方法 对应的 action 操作说明
/books GET list 获取所有书籍的信息
/books/new GET new 返回新增书籍的页面
/books POST create 新增一本书籍
/books/{id} GET show 显示某特定id书本的详细信息
/books/{id}/edit GET edit 返回编辑书本信息的页面
/books/{id} PUT/PATCH update 更新书本信息
/books/{id} DELETE destroy 删除书本信息

Rails 的 HTTP 方法使用了一些小技巧来实现 PUT/PATCH 和 DELETE 方法。通过在表单内增加一个隐藏域来实现:

<input type="hidden" name="_method" value="put">

对应地,我们可以借鉴并稍作修改在 Spring MVC 中应用:

url HTTP 方法 对应的 action 操作说明
/books GET list 获取所有书籍的信息
/books/new GET new 返回新增书籍的页面
/books/create POST create 新增一本书籍
/books/{id} GET show 显示某特定id书本的详细信息
/books/{id}/edit GET edit 返回编辑书本信息的页面
/books/{id}/update POST update 更新书本信息
/books/{id}/destroy POST destroy 删除书本信息

Rails 事务

Rails 中的事务

Rails 的事务使用方法有两种:

第一种使用方法:直接使用 ActiveRecord::Base 中的事务,如:

ActiveRecord::Base.transaction do
  或许会产生异常的处理
end
  不产生异常时的处理
rescue => e
  产生异常时的处理

第二种,使用模型的事务。由于模型继承了 ActiveRecord::Base,所以模型也包含了事务:

模型.transaction do
  或许会产生异常的处理
end
  不产生异常时的处理
rescue => e
  产生异常时的处理

Rails 中多表事务

Rails 在使用事务时经常会涉及多张表。比如一个 Release 的实例会关联多个 WorkOrder 实例,那么在事务中先存储 Release,然后存储 WorkOrder 时触发异常(比如 redmine_id 的唯一性约束):

Release.transaction do
  release = Release.create
  release.work_orders.create!(redmine_id:1)
  release.work_orders.create!(redmine_id:1)
end

经实验,ReleaseWorkOrder 都会回滚。

Ruby Mechanize 使用小结

1. Mechanize 简介

The Mechanize library is used for automating interaction with websites. Mechanize automatically stores and sends cookies, follows redirects, and can follow links and submit forms. Form fields can be populated and submitted. Mechanize also keeps track of the sites that you have visited as a history.

简单来说:

Mechanize 是一个用来自动化和网站交互的类库。Mechanize 有着自动存储和发送 cookies、跟踪重定向和链接以及提交表单的功能。Mechanize 可以产生和提交表单的域。Mechanize 还能够记录你访问的历史记录。

2. Mechanize 简单使用

使用 Mechanize 需要 require 一下:

require 'mechanize'

2.1 Mechanize 获取页面

我们首先获取一个浏览器的代理:

@agent = Mechanize.new

我们可以对这个代理进行设置,伪装成各种主流的浏览器(此处不表)。然后通过 URL 获取指定页面:

@page = @agent.get('www.github.com')

2.2 表单操作

2.2.1 搜索框

@agent = Mechanize.new
@page = @agent.get('xxx.xxx.com')
form = @page.forms.first
form["q"] = "xxxxx"
result = form.submit

上述代码中使用 @page.forms.first 获取了第一个表单,在页面中包含多个表单时,可以使用 @page.form_with(action: "search") 这样的方法获取特定的表单。

2.2.2 登录表单

@agent = Mechanize.new
@page = @agent.get('xxx.xxx.com')
form = @page.form_with(action: "login")
form["username"] = "username"
form["password"] = "password"
result = form.submit

form 使用 hash 的形式进行参数的赋值,表单中的每个元素用 name 属性来获取。

2.3 获取页面中的元素

从页面中获取元素常见的方法是 @page.links 或者 @page.forms 这样的内建方法。但是要从页面中获取某个特定标签时,可以使用 XPath 来实现:

@page.parser.xpath("//div[name='content']/p[id='id']")

上述语句获取的是页面中一个 name 属性值为 contentdiv 标签下的 id 属性为 idp 标签。XPath 的具体使用方法请看参考文献。

但是偶尔也会碰上你想获取的元素既没有 id 也没有 name 属性的情况,这个时候只能靠类似下文的方法暴力匹配了:

link.children[0].children[2].children[1].text

2.4 POST 数据到指定 URI

Mechanize 不支持 Javascript,对于某些需要 Javascript 支持的表单,Mechanize 无法帮你获取到数据。但是我们可以直接发出 POST 请求。
一般来说,我们可以先使用 Firefox 或者 Chrome 浏览器查看执行 POST 请求时发送的数据中参数的名称、格式以及地址。然后将其作为模板,用我们实际使用的参数替换模板中的参数。以下就是一个例子:

post_url = "http://address/to/your/target/action"

date = Time.now.strftime('%Y-%m-%d')
release_params ={
  "issue[tracker_id]" => "9",
  "issue[subject]" => "[xx上线任务] #{date}",
  "issue[description]" => "#{description}",
  "issue[project_id]" => "46",
  "issue[assigned_to_id]" => "642",
  "issue[status_id]" => "1",
  "issue[priority_id]" => "13",
  "issue[custom_field_values][30]" => "642",
  "issue[custom_field_values][37]" => "xxxx"
}
@agent.post(post_url, release_params)

3. 参考文献

RSpec 测试——2. RSpec Expectations

RSpec::Expectations 允许你表达一个样例中对象的预期结果。

1. 内建匹配器

1.1 相等

expect(actual).to eq(expected)  # passes if actual == expected
expect(actual).to eql(expected) # passes if actual.eql?(expected)
expect(actual).not_to eql(not_expected) # passes if not(actual.eql?(expected))

注意:新的 expect 语法不再支持 == 匹配器。

1.2 身份标识

expect(actual).to be(expected)    # passes if actual.equal?(expected)
expect(actual).to equal(expected) # passes if actual.equal?(expected)

1.3 比较

expect(actual).to be >  expected
expect(actual).to be >= expected
expect(actual).to be <= expected
expect(actual).to be <  expected
expect(actual).to be_within(delta).of(expected)

1.4 正则表达式

expect(actual).to match(/expression/)

1.5 类型

expect(actual).to be_an_instance_of(expected) # passes if actual.class == expected
expect(actual).to be_a(expected)              # passes if actual.is_a?(expected)
expect(actual).to be_an(expected)             # an alias for be_a
expect(actual).to be_a_kind_of(expected)      # another alias

1.6 真假

expect(actual).to be_truthy   # passes if actual is truthy (not nil or false)
expect(actual).to be true     # passes if actual == true
expect(actual).to be_falsy    # passes if actual is falsy (nil or false)
expect(actual).to be false    # passes if actual == false
expect(actual).to be_nil      # passes if actual is nil
expect(actual).to_not be_nil  # passes if actual is not nil

1.7 预期错误

expect { ... }.to raise_error
expect { ... }.to raise_error(ErrorClass)
expect { ... }.to raise_error("message")
expect { ... }.to raise_error(ErrorClass, "message")

1.8 预期抛出

expect { ... }.to throw_symbol
expect { ... }.to throw_symbol(:symbol)
expect { ... }.to throw_symbol(:symbol, 'value')

1.9 断言匹配

expect(actual).to be_xxx         # passes if actual.xxx?
expect(actual).to have_xxx(:arg) # passes if actual.has_xxx?(:arg)

1.10 范围(Ruby >= 1.9)

expect(1..10).to cover(3)

1.11 集合

expect(actual).to include(expected)
expect(actual).to start_with(expected)
expect(actual).to end_with(expected)

expect(actual).to contain_exactly(individual, items)
# ...which is the same as:
expect(actual).to match_array(expected_array)

2 should 和 should_not 内建匹配器

2.1 相等

actual.should     eq(expected)  # passes if actual == expected
actual.should     == expected   # passes if actual == expected
actual.should_not eql(expected) # passes if actual.eql?(expected)

注意:我们推荐 eq 匹配器而不是 == 以避免 Ruby 的警告。

2.2 身份标识

actual.should     be(expected)    # passes if actual.equal?(expected)
actual.should_not equal(expected) # passes if actual.equal?(expected)

2.3 比较

actual.should be >  expected
actual.should be >= expected
actual.should be <= expected
actual.should be <  expected
actual.should be_within(delta).of(expected)

2.4 正则表达式

actual.should match(/expression/)
actual.should =~ /expression/

2.5 类型

actual.should     be_an_instance_of(expected)
actual.should_not be_a_kind_of(expected)

2.6 真假

actual.should be_true  # passes if actual is truthy (not nil or false)
actual.should be_false # passes if actual is falsy (nil or false)
actual.should be_nil   # passes if actual is nil

2.7 断言

actual.should     be_xxx         # passes if actual.xxx?
actual.should_not have_xxx(:arg) # passes if actual.has_xxx?(:arg)

2.8 范围(Ruby >= 1.9)

(1..10).should cover(3)

2.9 集合

actual.should include(expected)
actual.should start_with(expected)
actual.should end_with(expected)

RSpec 测试——1. RSpec Core

1. 基本结构

RSpec.describe Order do
  it "sums the prices of its line items" do
    #something...
  end
end

2. 紧凑结构

RSpec.describe Order do
  context "with no items" do
    it "behaves one way" do
      # ...
    end
  end

  context "with one item" do
    it "behaves another way" do
      # ...
    end
  end
end

3. 别名

对于顶层的 example group 来说应该用 describe 和 context,组群内可以使用 it、specify、example。
it、specify、example 是测试的基本单元,它们内部不能使用 before 语句块。before 语句块只能在 describe 和 context 内使用。

4. 共享的样例和上下文

RSpec.shared_examples "collections" do |collection_class|
  it "is empty when first created" do
    expect(collection_class.new).to be_empty
  end
end

RSpec.describe Array do
  include_examples "collections", Array
end

RSpec.describe Hash do
  include_examples "collections", Hash
end

在共享样例中几乎可以声明一切,before、after 以及 around 等钩子方法,let 声明以及紧凑结构等。
类似的用法还有 shared_context 以及 include_context。

5. 元数据

rspec-core 存储了一个包含所有样例和组群的元数据 hash。可以通过如下方法从样例中获取:

it "does something" do
  expect(example.metadata[:description]).to eq("does something")
end

或者:

RSpec.shared_examples "collections" do
  it "is empty when first created" do
    expect(described_class.new).to be_empty
  end
end

RSpec.describe Array do
  include_examples "collections"
end

RSpec.describe Hash do
  include_examples "collections"
end

6. RSpec 命令

获取帮助:rspec --help
以文档格式输出:rspec <spec_file> --format doc
以网页格式输出:rspec <spec_file> --format html
rspec 命令的参数储存到 .rspec 文件中来自动调用如:echo "--format doc" >> .rspec