关于admin

计算机研究僧一只,Ubuntu Linuxer,Java、Python、C# 爱好者。

@Configuration、@Bean 与重载

1. 问题描述

记录一下之前遇到一个问题:在不同的场景下需要用到两个配置不同的 Client 实例。

实现方法:通过 @Configuration 注解来生命配置类,然后用 @Bean("clientx") 标记的方法来返回实例,每个返回 bean 的方法都通过 @Value 注解获取具体参数。

但是即使申明了不同的 name,自动注入的时候还是传入了同一个值。

2. 代码实例

首先构造一个 Client 类:

  
  package com.avaloninc.domain;
  
  import lombok.Data;
  
  @Data
  public class Client {
    private String endPoint;
    private String accessKey;
    private String accessSecret;
    private String region;
  }

然后给定我们的配置类:

  
  package com.avaloninc.factory;
  
  import com.avaloninc.domain.Client;
  import lombok.extern.slf4j.Slf4j;
  import org.springframework.beans.factory.annotation.Value;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;
  
  @Configuration
  @Slf4j
  public class ClientConfiguration {
  
    @Bean("client1")
    public Client getClient(@Value("${endPoint.beijing}") String endPoint) {
      log.info("client1 with one parameter is called.");
      Client client = new Client();
      client.setEndPoint(endPoint);
      return client;
    }
  
    @Bean("client2")
    public Client getClient(@Value("${endPoint.shanghai}") String endPoint,
                            @Value("${region}") String region) {
      log.info("client2 with two parameters is called.");
      Client client = new Client();
      client.setEndPoint(endPoint);
      client.setRegion(region);
      return client;
    }
  }

然后单元测试:

  
  package com.avaloninc.factory;
  
  import com.avaloninc.domain.Client;
  import junit.framework.TestCase;
  import org.junit.Test;
  import org.junit.runner.RunWith;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.beans.factory.annotation.Qualifier;
  import org.springframework.boot.test.context.SpringBootTest;
  import org.springframework.test.context.junit4.SpringRunner;
  
  @RunWith(SpringRunner.class)
  @SpringBootTest
  public class BeanFactoryTest extends TestCase {
  
    @Autowired
    @Qualifier("client1")
    private Client client1;
  
    @Autowired
    @Qualifier("client2")
    private Client client2;
  
    @Test
    public void test() {
      assertEquals(client1, client2);
    }
  }

单元测试完成运行我们得到日志:

  
  2018-03-18 00:35:36.611  INFO 90857 --- [           main] c.a.factory.ClientConfigurationTest      : Starting ClientConfigurationTest on MacBookPro.lan with PID 90857 (started by wuzhiyu in /Users/wuzhiyu/Projects/manuscripts/spring-bean-factory-overload)
  2018-03-18 00:35:36.612  INFO 90857 --- [           main] c.a.factory.ClientConfigurationTest      : No active profile set, falling back to default profiles: default
  2018-03-18 00:35:36.692  INFO 90857 --- [           main] o.s.w.c.s.GenericWebApplicationContext   : Refreshing org.springframework.web.context.support.GenericWebApplicationContext@561b6512: startup date [Sun Mar 18 00:35:36 CST 2018]; root of context hierarchy
  2018-03-18 00:35:37.520  INFO 90857 --- [           main] c.avaloninc.factory.ClientConfiguration  : client2 with two parameters is called.
  2018-03-18 00:35:37.523  INFO 90857 --- [           main] c.avaloninc.factory.ClientConfiguration  : client2 with two parameters is called.
  2018-03-18 00:35:38.011  INFO 90857 --- [           main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.web.context.support.GenericWebApplicationContext@561b6512: startup date [Sun Mar 18 00:35:36 CST 2018]; root of context hierarchy

可以看到两个参数的方法被调用了两次,所以实际上两个不同名字的 bean 却拥有完全相同的内容。

有同事建议我试试 @Resource 注解来注入 bean。他的理由是 @Autowired 一般是通过类型来匹配 bean。所以加了如下代码:

  
  
    @Bean(name = "client3")
    public Client getClient(@Value("${endPoint.hangzhou}") String endPoint,
                            @Value("${accessKey}") String accessKey,
                            @Value("${accessSecret}") String accessSecret) {
      log.info("client3 with three parameters is called.");
      Client client = new Client();
      client.setEndPoint(endPoint);
      client.setAccessKey(accessKey);
      client.setAccessSecret(accessSecret);
      return client;
    }

以及修改单元测试:

  
    @Resource(name = "client3")
    private Client client3;

结果依然类似:

  
  2018-03-18 00:45:07.757  INFO 90937 --- [           main] o.s.w.c.s.GenericWebApplicationContext   : Refreshing org.springframework.web.context.support.GenericWebApplicationContext@385e9564: startup date [Sun Mar 18 00:45:07 CST 2018]; root of context hierarchy
  2018-03-18 00:45:08.585  INFO 90937 --- [           main] c.avaloninc.factory.ClientConfiguration  : client3 with three parameters is called.
  2018-03-18 00:45:08.589  INFO 90937 --- [           main] c.avaloninc.factory.ClientConfiguration  : client3 with three parameters is called.
  2018-03-18 00:45:08.590  INFO 90937 --- [           main] c.avaloninc.factory.ClientConfiguration  : client3 with three parameters is called.
  2018-03-18 00:45:09.078  INFO 90937 --- [           main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.web.context.support.GenericWebApplicationContext@385e9564: startup date [Sun Mar 18 00:45:07 CST 2018]; root of context hierarchy

3. 转机

但是通过日志发现,两个场景每个 bean 在构造的时候都采用了最后一个方法。在这里做了一个假设:Spring 在构造 bean 的时候采用了反射的方式,而且可能因为某些原因对于重载函数只使用最后一个同名的函数。

假设之后小心求证一下,再次修改一下代码。首先增加新的构造方法:

  
    @Bean("client4")
    public Client getClientFour(@Value("${endPoint.shanghai}") String endPoint,
                                @Value("${region}") String region,
                                @Value("${accessKey}") String accessKey,
                                @Value("${accessSecret}") String accessSecret) {
      log.info("client4 with four parameters is called.");
      Client client = new Client();
      client.setEndPoint(endPoint);
      client.setRegion(region);
      client.setAccessKey(accessKey);
      client.setAccessSecret(accessSecret);
      return client;
    }

然后在单元测试中注入:

  
    @Resource(name = "client4")
    private Client client4;

通过日志我们发现新的构造方法构造的实例与之前的发生了区别:

  
  2018-03-18 00:57:52.552  INFO 91033 --- [           main] o.s.w.c.s.GenericWebApplicationContext   : Refreshing org.springframework.web.context.support.GenericWebApplicationContext@8c3b9d: startup date [Sun Mar 18 00:57:52 CST 2018]; root of context hierarchy
  2018-03-18 00:57:53.369  INFO 91033 --- [           main] c.avaloninc.factory.ClientConfiguration  : client3 with three parameters is called.
  2018-03-18 00:57:53.372  INFO 91033 --- [           main] c.avaloninc.factory.ClientConfiguration  : client3 with three parameters is called.
  2018-03-18 00:57:53.373  INFO 91033 --- [           main] c.avaloninc.factory.ClientConfiguration  : client3 with three parameters is called.
  2018-03-18 00:57:53.374  INFO 91033 --- [           main] c.avaloninc.factory.ClientConfiguration  : client4 with four parameters is called.
  2018-03-18 00:57:53.811  INFO 91033 --- [           main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.web.context.support.GenericWebApplicationContext@8c3b9d: startup date [Sun Mar 18 00:57:52 CST 2018]; root of context hierarchy

4. 结论

虽然没有阅读 Spring 的源码,但是大致可以想见 Spring 在构造实例时对于返回不同 name 的 bean 的重载方法处理的时候只用最后一个同名方法。也就是说只用了函数的名字而不是完整的函数签名。

因此,下次在配置类中返回相同类型不同名字的实例时还是避免使用相同的函数名!

5. 参考文章

Spring与枚举参数

1. 前言

使用枚举作为参数类型是非常常见的一种手段,但是枚举传参随着使用方式的不同,也存在的些微的差异。

简单来说分为枚举在作为请求的参数依据传递方式不同可以分为两种情况:

  • GET 请求、POST 表单传递
  • Json 数据格式

两者在做数据类型转换的时候是大不一样的。

2. 实例

首先定义一个枚举类:

  package com.avaloninc.domain;
  
  import com.fasterxml.jackson.annotation.JsonValue;
  
  public enum StatusEnum {
    /**
     * Valid status enum.
     */
    VALID(1, "有效"),
    /**
     * Invalid status enum.
     */
    INVALID(2, "无效");
  
    private final int    code;
    private final String value;
  
    StatusEnum(int code, String value) {
      this.code = code;
      this.value = value;
    }
  }
  

然后我们给出如下简单的 Controller:

  package com.avaloninc;
  
  import com.avaloninc.domain.StatusEnum;
  import lombok.Data;
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;
  import org.springframework.web.bind.annotation.GetMapping;
  import org.springframework.web.bind.annotation.PostMapping;
  import org.springframework.web.bind.annotation.RequestBody;
  import org.springframework.web.bind.annotation.RequestMapping;
  import org.springframework.web.bind.annotation.RestController;
  
  @SpringBootApplication
  @RestController
  @RequestMapping("api")
  public class Application {
    public static void main(String[] args) {
      SpringApplication.run(Application.class);
    }
  
    @Data
    public static class ListRequest {
      private StatusEnum status;
    }
  
    @Data
    public static class JsonRequest {
      private StatusEnum status;
    }
  
    @GetMapping
    public StatusEnum list(ListRequest listRequest) {
      return listRequest.getStatus();
    }
  
    @PostMapping
    public StatusEnum post(JsonRequest jsonRequest) {
      return jsonRequest.getStatus();
    }
  
    @PostMapping("json")
    public StatusEnum postJson(@RequestBody JsonRequest jsonRequest) {
      return jsonRequest.getStatus();
    }
  }

然后我们发起一个测试:

  # 1. GET
  curl -X GET 'http://localhost:8080/api?status=VALID'
  
  "VALID"
  
  # 2. POST Form
  curl -X POST  http://localhost:8080/api -F status=VALID
  
  "VALID"
  
  # 3. POST json
  curl -X POST http://localhost:8080/api/json 
    -d '{ "status": "VALID"}'
    
  "VALID"

一切正常!

3. 新的需求

现在添加一个需求:我们希望返回的不只是枚举的字面值,而是真正的中文含义比如有效、无效。

需求实现也很简单,给 StatusEnum 增加一个带有 @JsonValue 注解的方法即可。该注解指定了 Jackson 在序列化对象时使用的方法。

    @JsonValue
    public String toValue() {
      return this.value;
    }

测试结果:

  # 1. GET
  curl -X GET 'http://localhost:8080/api?status=VALID'
  
  "有效"
  
  # 2. POST Form
  curl -X POST  http://localhost:8080/api -F status=VALID
  
  "有效"
  
  # 3. POST json
  curl -X POST http://localhost:8080/api/json 
    -d '{ "status": "VALID"}'
    
  {
      "timestamp": 1520709795581,
      "status": 400,
      "error": "Bad Request",
      "exception": "org.springframework.http.converter.HttpMessageNotReadableException",
      "message": "JSON parse error: Can not deserialize value of type com.avaloninc.domain.StatusEnum from String \"VALID\": value not one of declared Enum instance names: [无效, 有效]; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Can not deserialize value of type com.avaloninc.domain.StatusEnum from String \"VALID\": value not one of declared Enum instance names: [无效, 有效]\n at [Source: java.io.PushbackInputStream@5ba1fa04; line: 2, column: 12] (through reference chain: com.avaloninc.Application$JsonRequest[\"status\"])",
      "path": "/api/json"
  }

原来以 Json 作为参数的请求出了错误。String \"VALID\": value not one of declared Enum instance names: [无效, 有效] 告诉我们 Json 反序列化为 StatusEnum 时只能用 [无效, 有效] 作为值。奇怪的是没有指定 @JsonCreator@JsonCreator 注解可以指定对象反序列化的方法)为什么没有采用默认的行为,即用枚举字面值反序列化,而采用我们序列化使用的字段呢?查看了 @JsonValue 注解的 javadoc 后有所发现:

  • NOTE: when use for Java <code>enum</code>s, one additional feature is
  • that value returned by annotated method is also considered to be the
  • value to deserialize from, not just JSON String to serialize as.
  • This is possible since set of Enum values is constant and it is possible
  • to define mapping, but can not be done in general for POJO types; as such,
  • this is not used for POJO deserialization.*
  • @see JsonCreator

坑爹啊!对于 @JsonValue 来说枚举是一种特殊的场景,定义了序列化后的值反过来也就被用来反序列化。

解决方法也不难,手动指定一个 @JsonCreator 即可:

    @JsonCreator
    public static StatusEnum fromValue(String str) {
      for (StatusEnum statusEnum : StatusEnum.values()) {
        if (statusEnum.name().equals(str)) {
          return statusEnum;
        }
      }
      throw new IllegalArgumentException("Illegal enum " + str);
    }

于是乎问题也解决了!

4. 进一步思考

进一步思考一下,为什么增加了 @JsonValue 注解后 GET 请求和 POST Form 请求没有变化呢?

答案是参数的绑定机制不同。

通过打断点我们可以发现:GET 请求和 POST Form 请求中的字符串到枚举的转化是通过 org.springframework.core.convert.support.StringToEnumConverterFactory 来实现的,其代码如下:

  /*
   * Copyright 2002-2017 the original author or authors.
   *
   * Licensed 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.
   */
  
  package org.springframework.core.convert.support;
  
  import org.springframework.core.convert.converter.Converter;
  import org.springframework.core.convert.converter.ConverterFactory;
  
  /**
   * Converts from a String to a {@link java.lang.Enum} by calling {@link Enum#valueOf(Class, String)}.
   *
   * @author Keith Donald
   * @author Stephane Nicoll
   * @since 3.0
   */
  @SuppressWarnings({"unchecked", "rawtypes"})
  final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {
  
      @Override
      public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
          return new StringToEnum(ConversionUtils.getEnumType(targetType));
      }
  
  
      private class StringToEnum<T extends Enum> implements Converter<String, T> {
  
          private final Class<T> enumType;
  
          public StringToEnum(Class<T> enumType) {
              this.enumType = enumType;
          }
  
          @Override
          public T convert(String source) {
              if (source.isEmpty()) {
                  // It's an empty enum identifier: reset the enum value to null.
                  return null;
              }
              return (T) Enum.valueOf(this.enumType, source.trim());
          }
      }
  }

可以发现的是该类实现了接口 ConverterFactory ,通过调用 Enum.valueOf(Class, String) 实现了这个功能。

向下追溯源码可以发现该方法实际上是从一个 Map<String, Enum> 的字典中获取了转换后的实际值,着这个 String 类型的 Key 的获取方式就是 Enum.name() 返回的结果,即枚举的字面值。源码如下:

      /**
       * Returns a map from simple name to enum constant.  This package-private
       * method is used internally by Enum to implement
       * {@code public static <T extends Enum<T>> T valueOf(Class<T>, String)}
       * efficiently.  Note that the map is returned by this method is
       * created lazily on first use.  Typically it won't ever get created.
       */
      Map<String, T> enumConstantDirectory() {
          if (enumConstantDirectory == null) {
              T[] universe = getEnumConstantsShared();
              if (universe == null)
                  throw new IllegalArgumentException(
                      getName() + " is not an enum type");
              Map<String, T> m = new HashMap<>(2 * universe.length);
              for (T constant : universe)
                  m.put(((Enum<?>)constant).name(), constant);
              enumConstantDirectory = m;
          }
          return enumConstantDirectory;
      }
      private volatile transient Map<String, T> enumConstantDirectory = null;

5. 参考文章

记录使用expect遇到的一个坑

记录使用 expect 遇到的一个坑


1. 问题描述

最近写了一个自动化的安装程序,由于要远程操作多台服务器进行安装,所以必然想到了使用 Shell 脚本来自动化。而设计到密码输入的(比如 sudo )则使用 expect 来进行交互,然后被一个小坑浪费了一个下午的时间。

先上代码:

#!/bin/bash
user=${1}
ip=${2}
passwd=${3}


expect -c "
spawn ssh ${user}@${ip} -t 'uname -a'
expect {
    assword: {
        send \"${passwd}\r\"
    }
}
expect eof
"

本质上就是连接到服务器执行命令而已,uname -a 但是脚本竟然报错了:zsh:1: command not found: uname -a(注:真实执行的命令可不是 uname -a 这么简单,这里做示例足够了)。

WTF?

2. 解决过程

想了半天,我一直以为是使用 ssh 登录过去环境变量不对导致的。花了半天时间研究了ssh连接远程主机执行脚本的环境变量问题 这篇文章。虽然没有解决问题,但是文章不错!

苦恼良久,我看到了 send \"${passwd}\r\" 这段代码感到很奇怪。这段 expect 代码是从一个前同事的脚本中扒下来的,整条 expect 命令是由双引号括起来的,字符串参数值替换应该没问题,为什么一定要用转义的双引号呢?

我猜这里可能有什么关键,所以果断把 'uname -a' 改成 \"uname -a\"

结果跑通了!

接下来我又发现 'uname -a' 改成 'uname' 后原来的代码也能跑。

测试了一下午加一个晚上总结出,总结出三种可以跑通的情况:

  1. 不在 expect 命令中而直接以 ssh <user>@<ip> <cmd> 的形式调用。
  2. 'uname -a' 改成 \"uname -a\"
  3. 'uname -a' 改成 'uname'

所以我猜测应该是在使用 expect 时命令体内部在表示 <cmd> 部分出了问题。当命令带 flag 时,单引号没有起到作用,需要使用转义的双引号。

其底层原因我也没时间去探索了,在此 mark 一下吧。

3. 结论

总结来说,expect 命令内部需要用双引号和单引号的还是尽量用转义的双引号吧!

关于 Kafka 的一点思考

1. Kafka 消费者可能遇到的问题

Kafka 消息队列在消费时可能遇到两个问题:

  1. 消息重复(Producer 设置 acksall 可能会有重复数据)
  2. 消息乱序(网络延迟)

但是这两者在处理的时候却必须综合考虑,如果消息的生产者按顺序生产消息,消费者在消费的时候也会期望消息的有序性,但是如果消息发生了重复或者乱序,消费者是无法区分的,也就无法针对两种情况分别进行处理。

因此我们必须通过某些手段来区分和解决这些问题。

2. 解决思路

针对的业务场景不同,我们能够使用的手段也不尽相同,总体来说:

  1. 唯一标示
  2. 状态机机制
  3. 幂等的方法

2.1 唯一标识

生产者在生产消息的时候为每一条消息增加一个递增的、唯一的标识。消费者在消费之前首先查询数据库该消息是否已经消费,如果已经消费了则直接忽略。该手段用来处理重复的数据简单直接。

在唯一标识生成的时候还可以附加生成规则,比如 唯一标识 = 批次号 + 版本序号。对消息顺序有严格要求的场景,可以根据同一批次对消息进行缓存,等同一批次内的版本序号都齐了再进行处理。

2.2 状态机机制1

状态机机制针对于有严格状态流转的业务场景,比如工作流程的处理、资源状态的转换等等。业务流程本身可以表示成一个状态机。那么在生产数据的时候可以把目标实体的当前状态和目标状态写入消息中,消费的过程中通过消息的当前状态和实体实际的当前状态进行对比,如果一致再进行处理。

如果不一致可以通知上游重发,也可以针对业务场景设置不同策略。

2.3 幂等的方法2

幂等性:

HTTP方法的幂等性是指一次和多次请求某一个资源应该具有同样的副作用。同一个请求,发送一次和发送N次效果是一样的。

幂等性所表达的概念关注的是数学层面的运算和数值,并没有提及到数值的安全性问题。所以 RESTful 设计中将幂等性和安全性是作为两个不同的指标来衡量,如 POSTPUTGETDELETE 操作:

重要方法 安全 幂等
GET
DELETE
PUT
POST

因此,幂等性是系统的接口对外一种承诺(而不是实现),承诺只要调用接口成功,外部多次调用对系统的影响是一致的。声明为幂等的接口会认为外部调用失败是常态,并且失败之后必然会有重试。

对于消息队列的消费者也是类似的。HTTP 方法中相对于 POST 方法,PUT 方法是幂等的。从资源状态转换的角度来考虑或者从容错的角度出发,在某些应用场景下将处理流程进行改造。

既然 POST 方法意味着创建资源的实例,那么我们可以将 POST 方法尽量改造成 PUT 方法。比如:如果消费者从消息队列中读取消息来进行资源的创建,我们可以给每条消息增加唯一标识,创建资源的时候如果该唯一标识不存在则直接创建资源,否则进行全量的更新(类似 MySQLon duplicate key update 子句或者 Replace 语法)。ElasticSearch 可以通过 _index_type_id 组合确定唯一一条文档,如果在写入数据的时候三者重复了,那么ElasticSearch 会用新的文档完全覆盖老的文档。这也是通过 KafkaElasticSearch 中导入数据时避免重复数据的一个技巧。

其实不仅幂等的方法满足幂等性,唯一标识的方案、状态机机制同样满足了幂等的要求。这三种方案可以进行任意地组合以应对复杂的业务场景。

参考文章

  1. 消息队列设计精要
  2. 分布式系统接口幂等性
  3. 幂等性 个人理解及应用

Insert Or Update 后续

1. 问题描述

使用 insert … on duplicate key update 语法实现 insertOrUpdate 之后出现了几个新问题,首先给出测试代码:

DROP database if EXISTS test;
CREATE database test;

DROP TABLE if EXISTS test.person;
CREATE table test.person (
    id int not NULL PRIMARY KEY auto_increment,
    name VARCHAR(100) not NULL DEFAULT '' UNIQUE COMMENT '名字',
    age int not NULL DEFAULT 0 COMMENT '年龄',
    gender VARCHAR(20) NOT NULL DEFAULT '' COMMENT '性别',
    addresses text NOT NULL COMMENT '地址'
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='person';

MyBatis 语句:

<insert id="insertOrUpdate" useGeneratedKeys="true" keyProperty="id">
    insert into test.person
    (id, name, age, gender, addresses)
    VALUES
    <foreach collection="list" item="person" separator=",">
        (id, #{person.name}, #{person.age}, #{person.gender},
        #{person.addresses, typeHandler=com.note4code.test.persistence.typeHandler.GenericMapHandler})
    </foreach>
    on duplicate key update
    age = VALUES(age),
    gender = VALUES(gender),
    addresses = VALUES(addresses)
</insert>

Java 代码:

package com.note4code.test.service;

import com.note4code.test.domain.Address;
import com.note4code.test.domain.Gender;
import com.note4code.test.domain.Person;
import com.note4code.test.domain.Province;
import junit.framework.TestCase;
import org.assertj.core.util.Lists;
import org.assertj.core.util.Maps;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.io.IOException;
import java.util.List;
import java.util.Map;

@RunWith(SpringRunner.class)
@SpringBootTest
public class PersonServiceTest extends TestCase{

  @Autowired
  private PersonService personService;

  private Person me;
  private Person you;
  private Person him;

  @Before
  public void initData() {
    Address address = new Address(Province.BEIJING, "北京", "学院路");
    Map<Province, Address> map = Maps.newHashMap(Province.BEIJING, address);

    this.me = new Person();
    this.me.setName("me");
    this.me.setAge(27);
    this.me.setGender(Gender.MALE);
    this.me.setAddresses(map);

    this.you = new Person();
    this.you.setName("you");
    this.you.setAge(25);
    this.you.setGender(Gender.FEMALE);
    this.you.setAddresses(map);

    this.him = new Person();
    this.him.setName("him");
    this.him.setAge(25);
    this.him.setGender(Gender.MALE);
    this.him.setAddresses(map);
  }

  @Test
  public void testForOnDuplicateKey() {
    personService.addPerson(me);
    int id = me.getId();

    me.setAge(28);
    List<Person> people = Lists.newArrayList(me, you, him);
    personService.addOrUpdate(people);
    assertTrue(id != me.getId());
  }
}

运行测试用例,得到的输出结果是:

people = [Person{id=2, name=’me’, age=28, gender=MALE, addresses={BEIJING=Address{province=BEIJING, city=’北京’, street=’学院路’}}}
, Person{id=0, name=’you’, age=25, gender=FEMALE, addresses={BEIJING=Address{province=BEIJING, city=’北京’, street=’学院路’}}}
, Person{id=0, name=’him’, age=25, gender=MALE, addresses={BEIJING=Address{province=BEIJING, city=’北京’, street=’学院路’}}}
]

另外,查询数据库可以得到:

mysql root@localhost:test> SELECT * from person;
+------+--------+-------+----------+--------------------------------------------------------------------+
|   id | name   |   age | gender   | addresses                                                          |
|------+--------+-------+----------+--------------------------------------------------------------------|
|    1 | me     |    28 | MALE     | {"BEIJING":{"province":"BEIJING","city":"北京","street":"学院路"}} |
|    2 | you    |    25 | FEMALE   | {"BEIJING":{"province":"BEIJING","city":"北京","street":"学院路"}} |
|    3 | him    |    25 | MALE     | {"BEIJING":{"province":"BEIJING","city":"北京","street":"学院路"}} |
+------+--------+-------+----------+--------------------------------------------------------------------+
3 rows in set
Time: 0.002s
mysql root@localhost:test> SELECT LAST_INSERT_ID();
+--------------------+
|   LAST_INSERT_ID() |
|--------------------|
|                 17 |
+--------------------+
1 row in set
Time: 0.001s

从上面的示例可以看出 3 个问题:

  1. 即使使用了 userGeneratedKeys = true 并指定了 keyProperty,只回写了第一行的主键。
  2. 回写的主键与数据库不一致。
  3. LAST_INSERT_ID() 的值发生了跳跃,按理来说应该是 3,但是变成了 17。

2. 疑问

看到这里其实很让人费解:

  1. 为什么只返回了一个主键?
  2. useGeneratedKeys 返回的主键不对那么到底是什么?
  3. 为什么 LAST_INSERT_ID() 发生了跳变?

首先从 userGeneratedKeys 说起:

useGeneratedKeys(仅对 insert 和 update 有用)这会令 MyBatis 使用 JDBC 的 getGeneratedKeys 方法来取出由数据库内部生成的主键(比如:像 MySQL 和 SQL Server 这样的关系数据库管理系统的自动递增字段),默认值:false。

引自 insert, update 和 delete

With older JDBC drivers for MySQL, you could always use a MySQL-specific method on theStatement interface, or issue the query SELECT LAST_INSERT_ID() after issuing an INSERT to a table that had an AUTO_INCREMENT key.

First, we demonstrate the use of the new JDBC 3.0 method getGeneratedKeys() which is now the preferred method to use if you need to retrieve AUTO_INCREMENT keys and have access to JDBC 3.0. The second example shows how you can retrieve the same value using a standard SELECT LAST_INSERT_ID() query. 

引自 Retrieving AUTO_INCREMENT Column Values through JDBC

也就是说 Mybatis 通过 useGeneratedKeys 返回的是 LAST_INSERT_ID()

接着说,那么为什么只回写了一个主键,并且还是错的呢?

If you insert multiple rows using a single INSERT statement, LAST_INSERT_ID() returns the value generated for the first inserted row only.

引自 LAST_INSERT_ID()LAST_INSERT_ID(expr)

按照上文的说法,批量插入只会返回插入的第一条数据的主键。第一次插入 me 这个对象之后 LAST_INSERT_ID() 返回 1。接着在插入 people 时首先是更新了 me 这行记录,而 LAST_INSERT_ID() 没有变。直到插入第二行 you 这个对象,此时 LAST_INSERT_ID() 返回 2,也就是批量插入后回写的主键值。这同时解释了为什么只回写了一个主键并且回写的主键与数据库没有对应上。

最后,关于 LAST_INSERT_ID() 的跳变,我也找到了一些参考资料:

  1. 官方文档:InnoDB AUTO_INCREMENT Lock Modes
  2. 其实这个说的更加简洁清楚:AUTO_INCREMENT 字段的GAP

Insert Or Update

1. MySQL 批量插入

有的时候会有这样的需求:

  1. 批量写入数据,但是数据可能存在主键或者唯一性索引的冲突写到一半就失败了。
  2. 每天批量更新表,新数据插入,旧数据更新。

于是我们不禁想到是否能实现一个 insertOrUpdate 方法呢?事实上,MySQL 给出了标准 SQL 的两种扩展来帮我们实现这种需求。

2. INSERT ... ON DUPLICATE KEY UPDATE

标准 SQL 的扩展,如果插入时遇到主键冲突或者唯一性约束不满足则会执行 update 操作,例如:

INSERT INTO table (a,b,c) VALUES (1,2,3)
  ON DUPLICATE KEY UPDATE c=c+1;

UPDATE table SET c=c+1 WHERE a=1;

支持多行插入:

INSERT INTO table (a,b,c) VALUES (1,2,3),(4,5,6)
  ON DUPLICATE KEY UPDATE c=VALUES(a)+VALUES(b);

ON DUPLICATE KEY UPDATE 子句中的 VALUES(a) 表示的是如果没有冲突将会插入 a 列的值,即 1 或者 4。

ON DUPLICATE KEY UPDATE 子句不适宜在有多个唯一性索引的表上使用。

参考:14.2.5.3 INSERT … ON DUPLICATE KEY UPDATE Syntax

3. Replace

Replace 是标准 SQL 的扩展,等价于 inserts/deletes and inserts

REPLACE [LOW_PRIORITY | DELAYED]
    [INTO] tbl_name
    [PARTITION (partition_name,...)]
    [(col_name,...)]
    {VALUES | VALUE} ({expr | DEFAULT},...),(...),...

REPLACE [LOW_PRIORITY | DELAYED]
    [INTO] tbl_name
    [PARTITION (partition_name,...)]
    SET col_name={expr | DEFAULT}, ...

REPLACE [LOW_PRIORITY | DELAYED]
    [INTO] tbl_name
    [PARTITION (partition_name,...)]
    [(col_name,...)]
    SELECT ...

所有列的值都从 Replace 语句中获取,无法从现有的行中获取值,如果有的列没有指定值那么会给定默认值。如果在指定值的时候使用了 SET col_name = col_name + 1 语句,等价于 SET col_name = DEFAULT(col_name) + 1

Replace 语句的返回值表示影响的行数,影响行数 = 删除的行数 + 插入的行数。对于单行的 Replace 操作,如果返回 1 表示仅仅插入了新的一行,如果大于 1 则表示删除了至少一行并插入了一行。

参考:14.2.8 REPLACE Syntax

3. 异同分析

从文档的说明来看,至少有两点差异:

  1. INSERT … ON DUPLICATE KEY UPDATE 在插入时可以使用现有行的值, Replace 不支持。
    mysql root@localhost:test> create table tb_table (a int not NULL PRIMARY key auto_increment, b int NOT NULL DEFAULT 0, c int NOT NULL DEFAULT 0)
    Query OK, 0 rows affected
    Time: 0.042s
    mysql root@localhost:test> insert into tb_table (a, b, c) VALUES (1,2,3);
    Query OK, 1 row affected
    Time: 0.024s
    mysql root@localhost:test> INSERT INTO tb_table (a, b, c) values (1,5,6) on  DUPLICATE KEY UPDATE b = values(b) + b;
    Query OK, 2 rows affected
    Time: 0.025s
    mysql root@localhost:test> SELECT * from tb_table;
    +-----+-----+-----+
    |   a |   b |   c |
    |-----+-----+-----|
    |   1 |   7 |   3 |
    +-----+-----+-----+
    1 row in set
    Time: 0.001s
    
  2. INSERT … ON DUPLICATE KEY UPDATE 在不满足主键约束或者唯一性约束的时候是更新操作,所以主键不会变。而 Replacedeletes inserts 也就是说老的记录会被删除,如果不指定主键值那么可能会破坏已有的外键关联(比如主键是自增产生,插入的时候不指定主键值)。
    mysql root@localhost:test> create table tb_table (a int not NULL PRIMARY key auto_increment, b int NOT NULL DEFAULT 0 unique, c int NOT NULL DEFAULT 0)
    Query OK, 0 rows affected
    Time: 0.027s
    mysql root@localhost:test> show create table tb_table;
    +----------+----------------+
    | Table    | Create Table   |
    |----------+----------------|
    | tb_table | CREATE TABLE `tb_table` (
     `a` int(11) NOT NULL AUTO_INCREMENT,
     `b` int(11) NOT NULL DEFAULT '0',
     `c` int(11) NOT NULL DEFAULT '0',
     PRIMARY KEY (`a`),
     UNIQUE KEY `b` (`b`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8                |
    +----------+----------------+
    1 row in set
    Time: 0.020s
    mysql root@localhost:test> insert into tb_table (b,c) VALUES (2,3);
    Query OK, 1 row affected
    Time: 0.013s
    mysql root@localhost:test> SELECT * from tb_table;
    +-----+-----+-----+
    |   a |   b |   c |
    |-----+-----+-----|
    |   1 |   2 |   3 |
    +-----+-----+-----+
    1 row in set
    Time: 0.001s
    mysql root@localhost:test> replace into tb_table (b,c) values (2,4);
    Query OK, 2 rows affected
    Time: 0.023s
    mysql root@localhost:test> SELECT * from tb_table;
    +-----+-----+-----+
    |   a |   b |   c |
    |-----+-----+-----|
    |   2 |   2 |   4 |
    +-----+-----+-----+
    1 row in set
    Time: 0.001s
    mysql root@localhost:test> replace into tb_table (b,c) values (2,4),(3,5);
    Query OK, 3 rows affected
    Time: 0.007s
    mysql root@localhost:test> SELECT * from tb_table;
    +-----+-----+-----+
    |   a |   b |   c |
    |-----+-----+-----|
    |   3 |   2 |   4 |
    |   4 |   3 |   5 |
    +-----+-----+-----+
    2 rows in set
    Time: 0.001s
    mysql root@localhost:test> insert into tb_table (b,c) values (2,8) on  DUPLICATE KEY UPDATE c = VALUES(c);
    Query OK, 2 rows affected
    Time: 0.013s
    mysql root@localhost:test> SELECT * from tb_table;
    +-----+-----+-----+
    |   a |   b |   c |
    |-----+-----+-----|
    |   3 |   2 |   8 |
    |   4 |   3 |   5 |
    +-----+-----+-----+
    2 rows in set
    Time: 0.001s
    

Melkman算法

1. 问题描述

给定一组二维坐标的集合,求从这个集合中找出这些二维坐标的外围边界。经过查询,确定了这是一个二维凸包的问题,属于计算几何的范畴。

2. Melkman 算法

经过查询,发现二维凸包的解法有很多种,其中 Melkman 算法是其中比较好的解决方案。Melkman 算法在点排序后可以以 O(n) 的时间复杂度计算凸包。同时 Melkman 还是一种在线的算法,虽然我这里用不到这个特性。

Melkman 算法的大致思想是这样的:首先找出所有点集合中某一个维度上的最大或者最小值,然后按照顺时针或者逆时针的方向逐个遍历集合中剩余的点。要找出集合中最外围的点,有一个不变的特性必须要保持住:

假设 P_i,P_j,P_k 是凸包上逆时针方向上的连续 3 个点,那么它们必然保持一种左转的趋势,即:

    \[\overrightarrow{P_iP_j}\times\overrightarrow{P_jP_k}>0\]

如果用 (x_i,y_i),(x_j,y_j),(x_k,y_k) 来表示这三个点,亦即:

    \[\overrightarrow{P_iP_j}=(x_j-x_i,y_j-y_i)\]

    \[\overrightarrow{P_jP_k}=(x_k-x_j,y_k-y_j)\]

那么根据向量叉积公式必须满足:

    \[(x_j-x_i)<em>(y_k-y_j)-(x_k-x_j)</em>(y_j-y_i)>0\]

3. 算法描述

Melkman 算法使用双端队列来实现这个原理,假设双端队列为 D。所有队列操作可以用入队首、出队首、入队尾、出队尾来描述,实际操作过程可以描述为:

  1. 假设点集合的坐标可以表示为 (x,y),那么首先找出 x 值最小的点记为 P_0

  2. 然后选定一个方向,比如逆时针方向。然后计算所有剩余的每一个点 P_0P_x 形成的向量 \overrightarrow{P_0P_x}y 轴负方向的夹角。根据向量的点积公式计算出夹角之后根据夹角从小到大就行排序得到有序集合 S={P_0,P_1,P_2…P_{n-1}}

  3. 记某一时刻 D 的状态为:P_tP_{t-1}…P_0...P_{b-1}P_b ,对于 S 中的每一个点进行遍历:

    3.1 如果是 P_0 则首先将 P_0 入队尾,如果是 P_1 则入队尾,如果是 P_2 则入队首并且入队尾。

    3.2 假设遍历到当前节点 P_i
    ​ 3.2.1 如果 P_{b-1}P_bP_i 能保持左转特性则继续,否则 P_b 出队尾,如此往复直到 P_{b-m-1}P_{b-m}P_i 能够满足左转特性,P_i 入队尾。

    ​ 3.2.2 如果 P_iP_tP_{t-1} 能保持左转特性则继续,否则 P_t 出队首,如此往复直到 P_iP_{t-n}P_{t-n-1} 能够满足左转特性,P_i 入队首。

  4. 返回 D

4. 算法实现

首先给出数据结构,PointPolygon

Point.java

package com.xxx.dvp.rest.domain.model;

/**
 * User: wuzhiyu.
 * Date: 16/12/21.
 * Time: 15:18.
 */
public class Point {
  private double lng;
  private double lat;

  /* Constructors */

  /* Setters and Getters */
}

Polygon.java

package com.xxx.dvp.rest.domain.model;

import com.google.common.collect.Lists;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/**
 * The type Melkman polygon.
 */
public class Polygon {
  private List<Point> points;

  /* Constructors  */

  /* Setter and Getter */

  /**
   * Contains boolean.
   * 源代码来自 .NET Framework。多边形边界上的点不算多边形内
   *
   * @param point the melkman point
   * @return the boolean
   */
  public boolean contains(Point point) {
    int crossings = 0;
    for (int index = 0; index < getPoints().size() - 1; index++) {
      double slope = getPoints().get(index + 1).getSlope(
          getPoints().get(index));
      boolean cond1 = (getPoints().get(index).getLng() <= point.getLng())
          && (point.getLng() < getPoints().get(index + 1).getLng());
      boolean cond2 = (getPoints().get(index + 1).getLng() <= point.getLng())
          && (point.getLng() < getPoints().get(index).getLng());
      boolean above = ((point.getLng() - getPoints().get(index).getLng()) * slope
          + getPoints().get(index).getLat()) > point.getLat();
      if ((cond1 || cond2) && above) {
        crossings++;
      }
    }
    return (crossings % 2 != 0);
  }

  /**
   * On border boolean.
   *
   * @param point the point
   * @return the boolean
   */
  public boolean onBorder(Point point) {
    for (int index = 0; index < getPoints().size() - 1; index++) {
      double slope = getPoints().get(index + 1).getSlope(getPoints().get(index));
      double latOnLine = (point.getLng() - this.points.get(index).getLng()) * slope
          + this.points.get(index).getLat();
      if (latOnLine == point.getLat()) {
        return true;
      }
    }
    return false;
  }

  @Override
  public String toString() {
    return "Polygon{" + "points=" + points + '}';
  }
}

Melkman 算法:

package com.xxx.dvp.rest.domain.service;

import com.xxx.dvp.rest.domain.model.Boundary;
import com.xxx.dvp.rest.domain.model.Point;
import com.xxx.dvp.rest.domain.model.Polygon;
import com.xxx.dvp.rest.infra.persistence.conf.DataExampleConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.geojson.Feature;
import org.geojson.FeatureCollection;

import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Deque;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * User: wuzhiyu.
 * Date: 16/12/26.
 * Time: 10:45.
 */
public class PointService {

  public static void main(String[] argv) throws IOException {
    PointService pointService = new PointService();
    FeatureCollection collection = new FeatureCollection();
    String fileName = "cluster_%d.txt";

    for (int i = 0; i < 8; i++) {
      List<Point> points = DataExampleConfig.getPoints(String.format(fileName, i));
      Polygon polygon = pointService.getConvexHullWithMelkman(points);

      Boundary boundaryOfPolygon = GeoService.getBoundaryOfPolygon(polygon);
      Set<String> gridsOfPolygon = GridService.getGrids(boundaryOfPolygon);

      gridsOfPolygon = GridService.getGridsInPolygon(gridsOfPolygon, polygon);

      List<Point> edgeOfGrids = GridService.getEdgeOfGrids(gridsOfPolygon);

      Polygon newPolygon = new Polygon(edgeOfGrids);

      GridService.outputBoundaryGrids(boundaryOfPolygon);
      GridService.outputPoints(edgeOfGrids);

      Feature polygonFeature = GeoService.getPolygonFeature(newPolygon);

      collection.add(polygonFeature);

      List<Point> errorPoints = points.stream()
          .filter(point -> !polygon.contains(point))
          .filter(point -> !polygon.onBorder(point))
          .collect(Collectors.toList());
      System.out.println("errorPoints = " + errorPoints);
    }

    ObjectMapper objectMapper = new ObjectMapper();
    System.out.println(objectMapper.writeValueAsString(collection));
  }

  public Polygon getConvexHullWithMelkman(List<Point> pointList) {
    if (pointList.size() < 3) {
      return null;
    }

    sortByAngle(pointList);

    Deque<Point> deque = new ArrayDeque<>();

    pointList.forEach(point -> {
      switch (deque.size()) {
        case 0:
        case 1:
          deque.offerLast(point);
          return;
        case 2:
          deque.offerLast(point);
          deque.offerFirst(point);
          return;
        default:
          Point lastRightVertex = deque.pollLast();
          Point lastLeftVertex = deque.pollFirst();

          if (this.isLeftTurn(deque.peekLast(), lastRightVertex, point)
              && this.isLeftTurn(point, lastLeftVertex, deque.peekFirst())) {
            return;
          }

          while (!this.isLeftTurn(deque.peekLast(), lastRightVertex, point)) {
            lastRightVertex = deque.pollLast();
          }
          deque.offerLast(lastRightVertex);
          deque.offerLast(point);

          while (!this.isLeftTurn(point, lastLeftVertex, deque.peekFirst())) {
            lastLeftVertex = deque.pollFirst();
          }
          deque.offerFirst(lastLeftVertex);
          deque.offerFirst(point);
          return;
      }
    });

    return new Polygon(new ArrayList<>(deque));
  }

  private void sortByAngle(List<Point> pointList) {
    Point minLngPoint = pointList.stream()
        .min(Comparator.comparing(Point::getLng)).get();
    pointList.remove(minLngPoint);
    pointList.sort(new Comparator<Point>() {
      @Override
      public int compare(Point o1, Point o2) {
        return Double.compare(angleWithSouth(minLngPoint, o1),
            angleWithSouth(minLngPoint, o2));
      }
    });

    pointList.add(0, minLngPoint);
  }

  public boolean isLeftTurn(Point point1, Point point2, Point point3) {
    return crossProduct(point1, point2, point3) > 0;
  }

  public double crossProduct(Point point1, Point point2, Point point3) {
    double x1 = point2.getLng() - point1.getLng();
    double y1 = point2.getLat() - point1.getLat();
    double x2 = point3.getLng() - point2.getLng();
    double y2 = point3.getLat() - point2.getLat();
    return x1 * y2 - x2 * y1;
  }

  public double dotProduct(Point point1, Point point2, Point point3) {
    double x1 = point2.getLng() - point1.getLng();
    double y1 = point2.getLat() - point1.getLat();
    double x2 = point3.getLng() - point2.getLng();
    double y2 = point3.getLat() - point2.getLat();
    return x1 * x2 + y1 * y2;
  }

  public double angleWithSouth(Point point1, Point point2) {
    Point point = new Point(point1.getLng(), point1.getLat() + 1);
    return Math.acos(this.dotProduct(point, point1, point2)
        / (this.norm(point, point1) * this.norm(point1, point2)));
  }

  public double norm(Point point1, Point point2) {
    double deltaLat = point2.getLat() - point1.getLat();
    double deltaLng = point2.getLng() - point1.getLng();

    return Math.sqrt(deltaLat * deltaLat + deltaLng * deltaLng);
  }
}

上文的代码引用了一个依赖:

<!-- geojson -->
<dependency>
    <groupId>de.grundid.opendatalab</groupId>
    <artifactId>geojson-jackson</artifactId>
    <version>1.7</version>
</dependency>

输出的 json 结果可以在 geojson 做可视化展示。

5. 参考文献

  1. http://maxgoldste.in/melkman/
  2. http://w3.impa.br/~rdcastan/Cgeometry/
  3. 漫话二维凸包

TMUX 自定义配置

QQ20160703-0
TMUX 号称是文本处理的“三大神器”之一,不是没有理由的。最近工作上好多事情都要在服务器上处理,所以用 TMUX 特别频繁。

最近花了点时间自己配置了一下 TMUX,这里权当记录一下吧。

1. 自动加载配置文件

在使用 TMUX 的时候,如果要重新加载配置文件,需要 Ctrl + b 触发,然后输入 : + source-file + ~/.tmux.conf

因为在调试配置的时候需要反复尝试,所以把这个配置做成了快捷键 Ctrl + b + R

bind R source-file ~/.tmux.conf

2. 采用 Vi 模式的快捷键

开启之后在复制模式(copy-mode,Ctrl + b + ?)中可以使用 Vi 模式下的快捷键。

setw -g mode-keys vi

3. 鼠标支持

对于鼠标的支持表现在通过鼠标的点击切换 window、pane 以及通过拖曳更改 pane 的大小。

注意的是 TMUX 版本的不同设置方式也不同。我的 MacBook Pro 使用的是 2.1,而我经常登录的服务器上使用的是 1.6。查看 TMUX 版本的命令是:

tmux -V

如果是 2.1 的话,设置为:

set -g mouse on

如果是 1.6 的话则设置为:

set -g mouse-select-window on
set -g mouse-select-pane on
set -g mouse-resize-pane on

4. 切换 pane 的快捷键

默认的设置中,pane 的切换的快捷键是 Ctrl + b + 方向键,为了更便捷地进行切换,定义了新的快捷键 Alt + 方向键

# 切换为使用 Alt-方向键 切换同一 Window 中的pane
bind -n M-Left select-pane -L
bind -n M-Right select-pane -R
bind -n M-Up select-pane -U
bind -n M-Down select-pane -D

5. 自定义状态栏

TMUX 可以自定义状态栏的颜色和展示信息。首先给出我暂时使用的配置:

# 自动重新编号 window
set -g renumber-windows on

# 设置自动刷新的时间间隔
set -g status-interval 1
# 状态栏左对齐
set -g status-justify left
# 状态栏左侧宽度
set -g status-left-length 20
# 状态栏右侧宽度
set -g status-right-length 50

# 状态栏背景颜色
set -g status-bg '#333333'
# 状态栏前景颜色
set -g status-fg '#ffffff'
# 状态栏左侧显示 session 的名字
set -g status-left '#[bg=#00bb00] [#S] #[default] '
# 状态栏右侧显示时间
#set -g status-right '#[fg=white,bg=#55bb00] [#h] #[fg=white,bg=#009c00] %Y-%m-%d #[fg=white,bg=#007700] %H:%M:%S '
set -g status-right '#[fg=white,bg=#444444] [#h] #[fg=white,bg=#666666] %Y-%m-%d #[fg=white,bg=#888888] %H:%M:%S '

# 当前激活窗口在状态栏的展位格式
setw -g window-status-current-format '#[bg=#ff0000, fg=#ffffff, bold]*[#I] #W*'
# 未激活每个窗口占位的格式
setw -g window-status-format '#[bg=#0000ff, fg=#ffffff] [#I] #W '

有几个配置需要进行说明一下:

5.1 关于时间的配置

上文配置中,我采用了几个时间参数显示在了状态栏的右侧:

参数形式 参数含义
%Y 年:2016
%m 月:07
%d 日:03
%H 小时:02
%M 分钟:19
%S 秒:04

需要注意的是其实这个时间的刷新是有间隔的,我们可以通过设置参数:

set -g status-interval 1

使得状态栏的时间好像每一秒都在变化。

5.2 颜色配置

TMUX 的状态栏中可以按照 #[bg=colourxx,fg=#ffffff] 的形式定义颜色的前景和背景。颜色的类型指定如:blackredgreenyellowbluemagentacyanwhite 。也可以是 colour0 到 colour255。或者形如 #ffffff 的 RGB 格式。同时还可以针对字体进行设置:bright (or bold), dimunderscoreblinkreverse,hidden, or italics

#[default] 可以重置修改后的样式。

5.3 其他配置参数

参数名称 参数含义
#H 完整主机名
#h 不含域名的主机名
#F 窗口的标记
#S session 的名字
#I window 的序号
#W 窗口的名字

此外,我们还可以通过 #(shell_cmd) 的形式运行 shell 命令来增强显示。我们还可以通过 #{VARIABLE} 的形式使用 TMUX 中的各种变量。

  1. 条件变量,例如:#{?session_attached,attached,not attached}。如果 ? 后面的变量存在并且非 0 则返回第一个变量,否则返回第二个。
  2. 变量字符串截取 #{=N:VARIABLE} 的形式获取变量的前 N 个字符, #{=-N:VARIABLE} 获取变量的后 N 个字符。
  3. 如果变量是时间戳类型的,#{t:VARIABLE} 会把时间戳转换为时间字符串。
  4. #{d:VARIABLE} 返回变量的 dirname#{d:VARIABLE} 返回 basename

更多相关配置可见于:FORMATS

6. 我的配置

以下是我的配置,部分配置还是以 2.1 为准。

# 绑定 R 重载配置文件
bind R source-file ~/.tmux.conf

# 采用 vi 模式
setw -g mode-keys vi

# 基本设置
set -g default-terminal "screen-256color"

# 设置序号从1开始
set -g base-index 1
set -g pane-base-index 1

# 关闭状态栏窗口占位的自动命名
setw -g automatic-rename off
set-option -g allow-rename off
setw -g utf8 on
set -g status-utf8 on

# 设定状态栏的位置
set -g status-position bottom

# 状态栏配置 {
    # 自动重新编号 window
    set -g renumber-windows on

    # 设置自动刷新的时间间隔
    set -g status-interval 1
    # 状态栏左对齐
    set -g status-justify left
    # 状态栏左侧宽度
    set -g status-left-length 20
    # 状态栏右侧宽度
    set -g status-right-length 50

    # 状态栏背景颜色
    set -g status-bg '#333333'
    # 状态栏前景颜色
    set -g status-fg '#ffffff'
    # 状态栏左侧显示 session 的名字
    set -g status-left '#[bg=#00bb00] [#S] #[default] '
    # 状态栏右侧显示时间
    #set -g status-right '#[fg=white,bg=#55bb00] [#h] #[fg=white,bg=#009c00] %Y-%m-%d #[fg=white,bg=#007700] %H:%M:%S '
    set -g status-right '#[fg=white,bg=#444444] [#h] #[fg=white,bg=#666666] %Y-%m-%d #[fg=white,bg=#888888] %H:%M:%S '

    # 当前激活窗口在状态栏的展位格式
    setw -g window-status-current-format '#[bg=#ff0000, fg=#ffffff, bold]*[#I] #W*'
    # 未激活每个窗口占位的格式
    setw -g window-status-format '#[bg=#0000ff, fg=#ffffff] [#I] #W '
# }

# tmux 控制相关的配置 {
    # 允许鼠标选取 Window 、Pane 以及 Pane 的大小改变,2.1 时使用如下设置
    set -g mouse on
    # 同上,1.6 时如下设置
    #set -g mouse-select-window on
    #set -g mouse-select-pane on
    #set -g mouse-resize-pane on

    # 切换为使用 Alt-方向键 切换同一 Window 中的pane
    bind -n M-Left select-pane -L
    bind -n M-Right select-pane -R
    bind -n M-Up select-pane -U
    bind -n M-Down select-pane -D
# }

每次学一个这样的高频度软件,都觉得开源世界真尼玛高深莫测,每个软件都有如此复杂的可定制性!还都是免费的!

7. 参考资料

Hive 向 ElasticSearch 导出数据

一般来说,Hive 里的表经过初步的处理之后就可以向 ElasticSearch 中导入数据了。

ElasticSearch 提供了和 Hive 的整合方案 elasticsearch-hadoop.jar,简单来说我们可以按照如下步骤进行操作。

  1. 如果已存在同名 Index 则先删除旧的 Index。
  2. 创建 Index,设置好 mappings。
  3. 创建导入数据用的映射表。
  4. 导入数据
  5. 删除临时表。

1. 删除已存在的 Index

因为我在使用的时候将每天的分区数据映射到一个 Index 中,所以为了回溯数据,每次导数开始之前先删除旧有的 Index。这个操作使用 ElasticSearch 提供的 RESTful API 就行:

curl -XDELETE "http://${IP_ADDRESS}:9200/${PATITION_NAME}"

2. 创建 Index,设置 mappings

其实这一步可以省略,在导入数据的时候 ElasticSearch 会自动创建对应的 mappings,但是提前创建 mappings 的好处是可以做自定义的设置。

比如下面的 "index": "not_analyzed" 属性禁止 ElasticSearch 对字符串的分析。如果不加上这个设置那么在匹配字符串的时候只能用 matchPhrase 而不能用 term,而且在 group by 某个字段的时候这个字段可能只返回字符串值的一部分,比如“北京”只返回一个“北”等等。

总之,如果不需要对文本做检索,所有字符串类型的属性最好都加上 "index": "not_analyzed"

curl -XPUT "http://${IP_ADDRESS}:9200/${PATITION_NAME}" -d '
{
    "mappings": {
        "data":{
            "properties": {
                "id": {
                    "type": "long"
                },
                "stat_date": {
                    "format": "strict_date_optional_time||epoch_millis",
                    "type": "date"
                },
                "some_str_field": {
                    "type": "string",
                    "index":  "not_analyzed"
                },
                "some_num_field": {
                    "type": "long"
                }
            },
            "_all": {
              "enabled": false
            }
        }
    }
}'

3. 创建导入数据用的映射表

创建一个外部表,并用 es.nodes 指定几个连接用的节点,es.resource指定映射的 Index 和 Type。给个例子:

add jar elasticsearch-hadoop-hive-2.2.0.jar;
drop table if exists ${TEMP_TABLE};
CREATE EXTERNAL TABLE ${TEMP_TABLE}(
    id bigint comment 'id',
    stat_date string comment '日期',
    some_str_field string comment '字符串类型',
    some_num_field bigint comment '数值类型'
)
STORED BY 'org.elasticsearch.hadoop.hive.EsStorageHandler'
TBLPROPERTIES(
'es.nodes' = '${NODE_IP_ADDR_1}:9200,${NODE_IP_ADDR_2}:9200,${NODE_IP_ADDR_3}:9200',
'es.resource' = '${PATITION_NAME}/${TYPE}'
);

4. 导入数据

SET hive.mapred.reduce.tasks.speculative.execution = false;
SET mapreduce.map.speculative = false;
SET mapreduce.reduce.speculative = false;
insert overwrite table ${TEMP_TABLE}
select  ${COLUMNS}
from    ${DBNAME}.${FILENAME}
where   concat(year,month,day)='$V_TS';

5. 删除临时表

导数用的临时表在导入数据结束后就可以删除了。

drop table ${TEMP_TABLE};

6. 此处有坑

在使用 Hive 向 ElasticSearch 中导出数据的时候,有一个大坑非常关键:并发!

这种导入数据的方法会启动 mapper 来执行,但是如果 mapper 数过高,ElasticSearch 过载(Overload)或者多出冗余的数据。据同事分析,原因是 mapper 会批量地将数据插入 ElasticSearch 中,而当 mapper 失败的时候会被重启,已经导入的数据又会重新插入 ElasticSearch 导致数据重复。

其中的一个手段就是适当减少并发的 mapper 的数量。通过合并文件的手段可以降低 mapper 的数量。

SET mapred.max.split.size=1000000000;
SET mapred.min.split.size=1000000000;
set mapred.min.split.size.per.node=1000000000;
set mapred.min.split.size.per.rack=1000000000;
set hive.input.format=org.apache.hadoop.hive.ql.io.CombineHiveInputFormat;

另外,还可以通过设置 ElasticSearch 的 _id 字段避免重复。比如为每一行生成一个 uuid,把 uuid 作为 mappings 中的 _id。ElasticSearch 对于 uuid 相同的记录会覆盖旧的记录,这样相同 uuid 的记录就不会重复了。

add jar elasticsearch-hadoop-hive-2.2.0.jar;
drop table if exists ${TEMP_TABLE};
CREATE EXTERNAL TABLE ${TEMP_TABLE}(
    id bigint comment 'id',
    stat_date string comment '日期',
    some_str_field string comment '字符串类型',
    some_num_field bigint comment '数值类型',
    uuid string COMMENT '为了导入ES而准备的id'
)
STORED BY 'org.elasticsearch.hadoop.hive.EsStorageHandler'
TBLPROPERTIES(
'es.nodes' = '${NODE_IP_ADDR_1}:9200,${NODE_IP_ADDR_2}:9200,${NODE_IP_ADDR_3}:9200',
'es.resource' = '${PATITION_NAME}/${TYPE}',
'es.mapping.id' = 'uuid'
);

最近更新:最近公司架构部门搞了一套通过 Kafka 导入数据的方案,先把数据写入到 Kafka,然后 Kafka 写入到 ElasticSearch。但是 Kafka 保证不会丢数,还是可能有重复数据项,所以也加了 uuid 作为 _id 来去重。这个套方案运行的很好,可惜同时导入的时候并发导入任务数也并不能太高,有些回溯数据的任务也只能一个个跑。

参考资料

ElasticSearch 与 Hive 的整合

Elasticsearch Query DSL

1. 序

Hive 无疑是数据相关工作最常用的的工具,利用 SQL 就能够完成许多统计和分析的任务,学习成本低,开发效率高。

但是 Hive 的缺点也很明显:运行慢!所以 Hive 一般用来做离线计算,每天由调度系统调用脚本完成一系列的计算任务,产出数据然后交给其他应用展现。但总有一些情景是 Hive 满足不了的,比如对数据的实时查询,比如对数据的快速分析。

这个时候就可以使用我们今天要介绍的工具:Elasticsearch。

2. 简介

ElasticSearch 是一种基于 Apache Lucene 的开源项目,主要面向的是数据的实时检索、分析以及全文搜索等。

Elasticsearch 的几个特性非常棒:
1. RESTful API,使用 Json 交换数据
2. 跨 index 的查询
3. 速度,尤其是大数据量下聚合函数的速度非常快!

3. Query DSL

我们可以使用两种结构化语句: 结构化查询(Query DSL)和结构化过滤(Filter DSL)。 查询与过滤语句非常相似,但是它们由于使用目的不同而稍有差异。
一条过滤语句会询问每个文档的字段值是否包含着特定值,一条查询语句会计算每个文档与查询语句的相关性,会给出一个相关性评分 _score,并且 按照相关性对匹配到的文档进行排序。

3.1 term 过滤

精确匹配数字、日期、布尔值或 not_analyzed 的字符串。相当于等号 =

{ "term": { "age":    26           }}
{ "term": { "date":   "2014-09-01" }}
{ "term": { "public": true         }}
{ "term": { "tag":    "full_text"  }}

3.2 terms 过滤

精确匹配多个值。相当于 in

{
    "terms": {
        "tag": [ "search", "full_text", "nosql" ]
    }
}

3.3 range 过滤

按照指定范围查找一批数据,相当于 between

{
    "range": {
        "age": {
            "gte":  20,
            "lt":   30
        }
    }
}
字段 含义
gt >
gte >=
lt <
lte <=

3.4 existsmissing 过滤

针对已经查出一批数据,但是想区分是否存在某个字段。类似于 IS_NULL 条件。

{
    "exists": {
        "field": "title"
    }
}

3.5 bool 过滤

bool 过滤可以用来合并多个过滤条件查询结果的布尔逻辑:

过滤子句 含义
must 多个查询条件完全匹配,相当于 and
must_not 多个查询条件的相反匹配,相当于 not
should 至少有一个查询条件匹配,相当于 or

复合实例:

{
    "bool": {
        "must": { "term": { "folder": "inbox" }},
        "must_not": { "term": { "tag": "spam" }},
        "should": [
            { "term": { "starred": true }},
            { "term": { "unread":  true }}
        ]
    }
}

3.6 match_all 查询

使用 match_all 可以查询到所有文档,事没有查询条件下的默认语句。

{
    "query": {
        "match_all": {}
    }
}

此查询常用于合并过滤条件。比如说你需要检索所有的邮箱,所有的文档相关性都是相同的,所以得到的 _score 为 1。

3.7 match 查询

某个字段中包含特定内容

{
    "query": {
        "match": {
            "字段名": "特定内容"
        }
    }
}

如果用 match 指定了一个确切值,在遇到数字,日期,布尔值或者 not_analyzed 的字符串时,它将为你搜索你给定的值:

{ "match": { "age":    26           }}
{ "match": { "date":   "2014-09-01" }}
{ "match": { "public": true         }}
{ "match": { "tag":    "full_text"  }}

3.8 multi_match 查询

multi_match 查询允许你在 match 查询的基础上同时搜索多个字段:

{
    "multi_match": {
        "query":    "full text search",
        "fields":   [ "title", "body" ]
    }
}

3.9 bool 查询

bool 查询与 bool 过滤相似,用于合并多个查询子句。不同的是,bool 过滤可以直接给出是否匹配成功,而 bool 查询要计算每一个查询子句的 _score (相关性分值)。

查询子句 含义
must 查询指定文档一定要被包含。
must_not 查询指定文档一定不要被包含。
should 查询指定文档,有则可以为文档相关性加分。

如果 bool 查询下没有 must 子句,那至少应该有一个 should 子句。但是 如果有 must 子句,那么没有 should 子句也可以进行查询。

4. 查询与过滤条件的合并

search API 中只能包含 query 语句,所以我们需要用 filtered 来同时包含 queryfilter 子句,外面再包含一层 query

{
    "query": {
        "filtered": {
            "query":  { "match": { "email": "business opportunity" }},
            "filter": { "term":  { "folder": "inbox" }}
        }
    }
}

5. 验证查询

5.1 验证

validate API 可以验证查询语句是否合法:

curl -XGET 'http://dp.diditaxi.com.cn:80/es/<INDEX>/<TYPE>/_validate/query?pretty=true' -d '{
    QUERY
}'

返回结果:

#合法
{
  "valid" : true,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "failed" : 0
  }
}

#非法
{ 
    "valid" :         false, 
    "_shards" : {    
        "total" :       1,    
        "successful" :  1,    
        "failed" :      0  
    }
 }

5.2 理解错误信息

想知道语句非法的具体错误信息,需要加上 explain 参数:

curl -XGET 'http://dp.diditaxi.com.cn:80/es/<INDEX>/<TYPE>/_validate/query?explain&pretty=true' -d '{
    QUERY
}'