关于admin

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

Spring与Cache

1. 前言

在做项目中往往遇到这样一些场景:从外部系统获取一些配置信息,它们被频繁读取但是却不经常修改,甚至是只读的数据。它们不要求非常强的实时性却有着非常高的访问频率。

如果每次都重新发起一次 HTTP 请求无疑是对系统资源的一种浪费。常见的做法是将这些外部数据以缓存的形式存储下来。

下面介绍一下 Spring 自带的 Cache 功能。

2. 几种基本注解

  • @Cacheable:开启缓存,指定命名空间参数(可以指定多个)。

  • @CacheEvict:指定缓存的清除,如果使用 allEntries = true 则会删除所有缓存。

  • @CachePut:首先运行目标方法然后将返回结果加入缓存。

  • @CacheConfig:在类的级别指定缓存的配置信息,比如缓存的名字或者 CacheManager

  • @Caching:由于一个方法上可以指定多个缓存,而 Java 不允许同一个注解在同一个地方出现多次,因此 @Caching 注解来组织。

3. 常见的缓存存储

Spring 虽然提供了缓存的框架,但是缓存的具体实现还分为很多种,常见的有 ConcurrentMapCacheGuavaCache 以及基于 Redis 的实现。

3.1 ConcurrentMapCache

ConcurrentMapCache 是一种简单的缓存实现,不能进行配置。那么 ConcurrentMapCache 缓存的生命周期是永远,除非使用 @CacheEvict 或者 @CachePut 否则缓存不会失效或者更新。

3.2 GuavaCache

这是基于 Guava 缓存的一种实现,我们可以像使用 Guava 中的 Cache 一样进行配置,比如缓存的失效时间、缓存的最大容量等等。

3.3 基于 Redis 的缓存

上文无论 ConcurrentMapCache 也好、GuavaCache 也好,都是基于本机的内存,无法在多个节点共享。基于 Redis 的缓存才能实现分布式缓存。

4. 代码示例

首先给出代码的依赖:

  <dependencies>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <dependency>
          <groupId>com.avalon-inc</groupId>
          <artifactId>web-api-common</artifactId>
          <version>1.0-SNAPSHOT</version>
      </dependency>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-cache</artifactId>
      </dependency>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
  </dependencies>

然后是 Redis 的配置,见 application.properties

  spring.redis.host=localhost
  spring.redis.port=6379

接着进入代码配置的正文,使用 Cache 的第一件事是开启对缓存的支持:

  package com.avaloninc.springcacheexample ;
  
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;
  import org.springframework.cache.annotation.EnableCaching;
  
  /**
   * @Author: wuzhiyu.
   * @Date: 2018-05-18 02:46:01.
   * @Description:
   */
  @SpringBootApplication
  @EnableCaching
  public class Main {
    public static void main(String[] args) {
      SpringApplication.run(Main.class);
    }
  }

然后我们执行最小化的设置,注册三个 CacheManager

  package com.avaloninc.springcacheexample;
  
  import com.google.common.cache.CacheBuilder;
  
  import org.springframework.beans.factory.annotation.Value;
  import org.springframework.cache.CacheManager;
  import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
  import org.springframework.cache.guava.GuavaCacheManager;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;
  import org.springframework.context.annotation.Primary;
  import org.springframework.data.redis.cache.RedisCacheManager;
  import org.springframework.data.redis.connection.RedisConnectionFactory;
  import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
  import org.springframework.data.redis.core.RedisTemplate;
  import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
  import org.springframework.data.redis.serializer.StringRedisSerializer;
  
  import java.util.concurrent.TimeUnit;
  
  /**
   * @Author: wuzhiyu.
   * @Date: 2018-06-12 01:44:20.
   * @Description:
   */
  @Configuration
  public class Config {
    @Bean("concurrentMapCacheManager")
    @Primary
    public CacheManager getDefaultCacheManager() {
      return new ConcurrentMapCacheManager();
    }
  
    @Bean("guavaCacheManager")
    public CacheManager getGuavaCacheManager() {
      GuavaCacheManager guavaCacheManager = new GuavaCacheManager();
      guavaCacheManager.setCacheBuilder(
          CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(5, TimeUnit.SECONDS));
      return guavaCacheManager;
    }
  
    @Bean
    public JedisConnectionFactory getRedisConnectionFactory(@Value("${spring.redis.host}") String host,
                                                            @Value("${spring.redis.port}") int port) {
      JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
      jedisConnectionFactory.setHostName(host);
      jedisConnectionFactory.setPort(port);
      return jedisConnectionFactory;
    }
  
    @Bean
    public RedisTemplate<String, Object> getRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
      RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
      redisTemplate.setConnectionFactory(redisConnectionFactory);
      redisTemplate.setKeySerializer(new StringRedisSerializer());
      redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
      return redisTemplate;
    }
  
    @Bean("redisCacheManager")
    public CacheManager getRedisCacheManager(RedisTemplate<String, Object> redisTemplate) {
      RedisCacheManager redisCacheManager = new RedisCacheManager(redisTemplate);
      redisCacheManager.setDefaultExpiration(3);
      return redisCacheManager;
    }
  }

注意上面的 new ConcurrentMapCacheManager(),实例的构造函数有两种签名版本:

  • public ConcurrentMapCacheManager() {}

  • public ConcurrentMapCacheManager(String... cacheNames) { setCacheNames(Arrays.asList(cacheNames));}

如果使用无参构造,那么允许动态构造缓存,缓存数量可变。如果使用有参版本那么只能以固定个数的命名空间来创建固定个数的缓存。GuavaCacheRedisCache 同上。

接着我们编写使用缓存的具体代码:

  package com.avaloninc.springcacheexample ;
  
  import com.avaloninc.webapi.common.response.Response;
  import com.avaloninc.webapi.common.response.Responses;
  import lombok.extern.slf4j.Slf4j;
  import org.joda.time.DateTime;
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;
  import org.springframework.cache.annotation.CacheConfig;
  import org.springframework.cache.annotation.CacheEvict;
  import org.springframework.cache.annotation.CachePut;
  import org.springframework.cache.annotation.Cacheable;
  import org.springframework.cache.annotation.Caching;
  import org.springframework.cache.annotation.EnableCaching;
  import org.springframework.web.bind.annotation.DeleteMapping;
  import org.springframework.web.bind.annotation.GetMapping;
  import org.springframework.web.bind.annotation.PathVariable;
  import org.springframework.web.bind.annotation.PutMapping;
  import org.springframework.web.bind.annotation.RestController;
  
  /**
   * @Author: wuzhiyu.
   * @Date: 2018-05-18 02:46:01.
   * @Description:
   */
  @SpringBootApplication
  @EnableCaching
  @RestController
  @Slf4j
  @CacheConfig(cacheNames = "caches")
  public class Main {
    public static void main(String[] args) {
      SpringApplication.run(Main.class);
    }
  
    @GetMapping("/obj/{key}")
    @Caching(cacheable = {
        @Cacheable(cacheManager = "concurrentMapCacheManager"),
        @Cacheable(cacheManager = "guavaCacheManager"),
        @Cacheable(cacheManager = "redisCacheManager")
    })
    public Response<String> get(@PathVariable("key") String key) {
      String value = key.concat(" ").concat(DateTime.now().toString());
      log.info("Get the real value of " + key);
      return Responses.sucessResponse(value);
    }
  
    @DeleteMapping("/obj/{key}")
    @Caching(evict = {
        @CacheEvict(cacheManager = "concurrentMapCacheManager"),
        @CacheEvict(cacheManager = "guavaCacheManager"),
        @CacheEvict(cacheManager = "redisCacheManager")
    })
    public Response<String> del(@PathVariable("key") String key) {
      log.info("Evict the key " + key);
      return Responses.sucessResponse("success");
    }
  
    @PutMapping("/obj/{key}")
    @Caching(put = {
        @CachePut(cacheManager = "concurrentMapCacheManager"),
        @CachePut(cacheManager = "guavaCacheManager"),
        @CachePut(cacheManager = "redisCacheManager")
    })
    public Response<String> put(@PathVariable("key") String key) {
      String value = key.concat(" ").concat(DateTime.now().toString());
      log.info("Put the real value of " + key);
      return Responses.sucessResponse(value);
    }
  }

注意上文是为了演示 @Caching 才同时使用了三种缓存。实验显示多个缓存下 @Cacheable 只要有一个缓存没有失效那么就不会执行具体的方法体获取的新的值!如果其他缓存和 ConcurrentMapCache 一起使用而不调用 @CacheEvict 或者 @CachePut,那么其他缓存可能永远不会更新!

5. 总结

本文只是简单介绍了一下 SpringBoot 集成缓存以及几种常见的存储方式,具体的选择按需求场景确定即可。但是正确的缓存使用姿势或者更新策略可以结合现有的设计模式,灵活运用这几种注解实现选定的模式。

6. 参考资料

Spring 与配置获取方式

1. 前言

Spring 在配置文件方面的支持非常强大,本文不再赘述,有需求可以查看 Spring Boot 的官方文档。本文的叙述内容是如何在程序中使用 Spring Boot 配置文件中的参数值。常见的手法有三种:

  • @Value 注解

  • org.springframework.core.env.Environment 对象

  • @ConfigurationProperties 注解

2. @Value

先从 @Value 说起,通过配置完整名称的形式即可获取需要的值,如:

@Value("${myConfig.name}")
private String name;

但是需要注意的一点是 @Value 注入的时间一般在 Bean 构造完成之后。如果构造 Bean 的方法需要使用到配置文件里的参数,那么可以把这些参数作为构造函数或者 @Bean 注解修饰的方法的传入参数,并以 @Value 注解来指定注入的参数。如:

@Bean("client1")
public Client getClient(@Value("${endPoint.beijing}") String endPoint) {
    Client client = new Client();
    client.setEndPoint(endPoint);
    return client;
}

3. org.springframework.core.env.Environment

@Value 注解在获取少量配置的时候还是相当方便的,但是如果我们需要从配置文件中获取大量配置的时候往往需要定义大量的实例变量,就不如直接从 Environment 获取来的方便。Environment 对象可以直接通过 @Autowired 注解注入得到。从 Environment 获取配置的方式也相当简单,如:

String myConfigNameOfEnv = environment.getProperty("myConfig.name");

getProperty 方法还有可以指定默认值和参数类型的重载方法,此处不展开。

4. @ConfigurationProperties

通过 @ConfigurationProperties 注解获取配置有两种形式:

下面给出一个示例,对于如下配置:

myConfig:
  name: myConfig
  list:
    - a
    - b
yourConfig:
  name: yourConfig
  list:
    - c
    - d

如果我们想使用 myConfig 前缀下的所有配置,那么两种方案分别可以按照如下形式获取。

方案一:

@Configuration
@ConfigurationProperties(prefix = "myConfig")
@Data
public class MyConfig {
  private String       name;
  private List<String> list;
}

这里通过前缀和字段的名字来映射配置。

方案二:

@Configuration
@ConfigurationProperties(prefix = "")
@Data
public class ConfigAsMap {
  private Map<String, String> myConfig;
  private Map<String, String> yourConfig;
}

这里我们将所有所有配置映射为一个对象,每一个 namespace 下的配置以 Map 的形式来存储。使用的时候可以按照如下方式获取特定配置:

Map<String, String> myConfigMap     = configAsMap.getMyConfig();
String              nameOfConfigMap = myConfigMap.get("name");

这里还要记述一下 Environment 对象和 @ConfigurationProperties 映射为 Map 后两者在获取列表时的区别。

对于 Environemnt 对象,我们要获取 myConfig.list 对象时的方式如下:

List<String> myConfigListOfEnv = Lists.newArrayList(environment.getProperty("myConfig.list[0]"),
                                            environment.getProperty("myConfig.list[1]"));

而通过 @ConfigurationProperties 映射为 Map 后获取的方式则是:

List<String> listOfConfigMap = Lists.newArrayList(myConfigMap.get("list.0"),
                                                  myConfigMap.get("list.1"));

两者在处理列表的索引时不一致,使用时需要我们注意。

5. 跨 Module 引用配置

最近开始接手改造一个项目,改造过程中有这样一个问题:该项目原来所有的配置都采用集中管理的方式,每一个项目的配置都从配置服务器的接口获取然后初始化 Spring 的容器。该项目将从配置服务器获取配置的部分抽出为一个单独的 Maven Module。

但是这个配置集中管理的项目即将下线,于是配置本地化的改造就迎面而来了。

我的改造方案是以不同的 profile 的形式将配置固化到配置 Module 的配置文件中。在线上环境和测试环境通过指定不同的 profile 达到配置的切换。

对于运行时指定的 profile 能否作用到依赖的 Maven 模块并没有绝对把握,而改造的范围比较广,所以还是捏了一把汗的。于是写了一个小的 Demo 作为原型验证。

首先给出配置文件:

application.yaml

myConfig:
  name: myConfig
  list:
    - a
    - b
yourConfig:
  name: yourConfig
  list:
    - c
    - d

application-test.yaml

hisConfig:
  name: hisConfig-test
  list:
    - g
    - h

application-prod.yaml

hisConfig:
  name: hisConfig-prod
  list:
    - e
    - f

两种通过 @ConfigurationProperties 获取配置的方式:

package com.avaloninc.springconfigexample.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
@ConfigurationProperties(prefix = "myConfig")
@Data
public class MyConfig {
  private String       name;
  private List<String> list;
}

以及:

package com.avaloninc.springconfigexample.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
@ConfigurationProperties(prefix = "")
@Data
public class ConfigAsMap {
  private Map<String, String> myConfig;
  private Map<String, String> yourConfig;
  private Map<String, String> hisConfig;
}

先在当前 Module 测试一下:

package com.avaloninc.springconfigexample;

import com.avaloninc.springconfigexample.config.ConfigAsMap;
import com.avaloninc.springconfigexample.config.MyConfig;
import junit.framework.TestCase;
import org.assertj.core.util.Lists;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.env.Environment;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;
import java.util.Map;

@SpringBootTest
@RunWith(SpringRunner.class)
@ActiveProfiles(value = "test")
public class MainTest extends TestCase {

  @Value("${myConfig.name}")
  private String name;
  @Autowired
  private Environment environment;
  @Autowired
  private ConfigAsMap configAsMap;
  @Autowired
  private MyConfig    myConfig;

  @Test
  public void test() {
    System.out.println("this.name = " + this.name);

    String myConfigNameOfEnv = environment.getProperty("myConfig.name");
    List<String> myConfigListOfEnv = Lists.newArrayList(environment.getProperty("myConfig.list[0]"),
                                                        environment.getProperty("myConfig.list[1]"));
    System.out.println("myConfigNameOfEnv = " + myConfigNameOfEnv);
    System.out.println("myConfigListOfEnv = " + myConfigListOfEnv);

    String       myConfigName = myConfig.getName();
    List<String> myConfigList = myConfig.getList();

    Map<String, String> myConfigMap     = configAsMap.getMyConfig();
    String              nameOfConfigMap = myConfigMap.get("name");
    List<String> listOfConfigMap = Lists.newArrayList(myConfigMap.get("list.0"),
                                                      myConfigMap.get("list.1"));

    System.out.println("myConfig = " + myConfig);
    System.out.println("configAsMap = " + configAsMap);

    assertEquals(this.name, myConfigNameOfEnv);
    assertEquals(myConfigNameOfEnv, myConfigName);
    assertEquals(myConfigName, nameOfConfigMap);
    assertEquals(myConfigListOfEnv, myConfigList);
    assertEquals(myConfigList, listOfConfigMap);
  }
}

然后我们在另一个 Module 中引用这里的配置,下面是单元测试:

package com.avaloninc.springtestexample;

import com.avaloninc.springconfigexample.config.ConfigAsMap;
import com.avaloninc.springconfigexample.config.MyConfig;
import junit.framework.TestCase;
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

@SpringBootTest
@RunWith(SpringRunner.class)
@ActiveProfiles("test")
@Import({ConfigAsMap.class, MyConfig.class})
public class MainTest extends TestCase {

  @Autowired
  private ConfigAsMap configAsMap;
  @Autowired
  private MyConfig  myConfig;

  @Test
  public void test() {
    System.out.println("configAsMap = " + configAsMap);
    assertEquals(configAsMap.getHisConfig().get("name"), "hisConfig-test");
    System.out.println("myConfig = " + myConfig);
  }
}

注意:跨模块引用配置的时候记得使用 @Import 注解,否则启动的时候会报错:org.springframework.beans.factory.UnsatisfiedDependencyException。

HDFS API 后记关于 FileSystem 缓存

HDFS API 这篇文章中,简单介绍了一下通过 Hadoop client 的 API 读写 HDFS 的方法。但是在实际使用过程中也发现了一个 FileSystem 的问题。

1. 问题描述

在程序运行中时不时地会抛出 IOException,内容提示 filesystem closed。一开始也很疑惑,因为不确定 org.apache.hadoop.fs.FileSystem 实例的线程安全性,所以每次调用的时候都通过 FileSystem.get(conf) 方法来获取一个新的对象。然后查阅了资料发现 FileSystem.get 方法会得到的并不是一个全新的对象,而是一个缓存过的对象。

源码如下:

  /** Returns the FileSystem for this URI's scheme and authority.  The scheme
   * of the URI determines a configuration property name,
   * <tt>fs.<i>scheme</i>.class</tt> whose value names the FileSystem class.
   * The entire URI is passed to the FileSystem instance's initialize method.
   */
  public static FileSystem get(URI uri, Configuration conf) throws IOException {
    String scheme = uri.getScheme();
    String authority = uri.getAuthority();

    if (scheme == null && authority == null) {     // use default FS
      return get(conf);
    }

    if (scheme != null && authority == null) {     // no authority
      URI defaultUri = getDefaultUri(conf);
      if (scheme.equals(defaultUri.getScheme())    // if scheme matches default
          && defaultUri.getAuthority() != null) {  // & default has authority
        return get(defaultUri, conf);              // return default
      }
    }
    
    String disableCacheName = String.format("fs.%s.impl.disable.cache", scheme);
    if (conf.getBoolean(disableCacheName, false)) {
      return createFileSystem(uri, conf);
    }

    return CACHE.get(uri, conf);
  }

可以看到 FileSyste.get 方法的确是取缓存的。

2. 解决方案

为了保障线程安全性,我们可以每次调用的时候都创建一个新的实例,FileSystem.newInstance 方法可以满足我们的需求。

package com.avaloninc.hdfsapi.service.impl;

import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;

import com.avaloninc.hdfsapi.service.HdfsService;
import org.apache.commons.io.IOUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @Author: wuzhiyu.
 * @Date: 2018-03-19 14:39.
 * @Description:
 */
@Service
public class HdfsServiceImpl implements HdfsService {
    @Autowired
    private Configuration config;

    @Override
    public String read(String path) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            Path hdfsPath = new Path(path);
            List<String> lines = IOUtils.readLines(fileSystem.open(hdfsPath));
            return Joiner.on("\n").join(lines);
        }
    }

    @Override
    public void write(String path, InputStream inputStream) throws IOException {
        FileSystem         fileSystem   = null;
        FSDataOutputStream outputStream = null;
        try {
            Path hdfsPath = new Path(path);
            fileSystem = FileSystem.newInstance(config);
            outputStream = fileSystem.create(hdfsPath, true);

            byte[] bytes    = new byte[1024];
            int    numBytes = 0;
            while ((numBytes = inputStream.read(bytes)) > 0) {
                outputStream.write(bytes, 0, numBytes);
            }
        } finally {
            IOUtils.closeQuietly(inputStream);
            IOUtils.closeQuietly(outputStream);
            IOUtils.closeQuietly(fileSystem);
        }
    }

    @Override
    public boolean rename(String src, String dest) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            Path srcPath = new Path(src);
            Path destPath = new Path(dest);

            if (!fileSystem.exists(srcPath)) {
                throw new IOException("Path " + src + " do not exists.");
            }

            if (!fileSystem.exists(destPath.getParent())) {
                fileSystem.mkdirs(destPath.getParent());
            }

            return fileSystem.rename(srcPath, destPath);
        }
    }

    @Override
    public boolean delete(String path) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            return fileSystem.delete(new Path(path), true);
        }
    }

    @Override
    public List<String> ls(String path) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            Path hdfsPath = new Path(path);
            if (!fileSystem.exists(hdfsPath)) {
                throw new IllegalArgumentException(
                    "Path " + path + " do not exist or is not a dir.");
            }

            if (fileSystem.isDirectory(hdfsPath)) {
                return Arrays.stream(fileSystem.listStatus(hdfsPath))
                    .map(FileStatus::getPath)
                    .map(Path::getName)
                    .collect(Collectors.toList());
            } else {
                FileStatus status = fileSystem.getFileStatus(hdfsPath);
                return ImmutableList.of(status.getPath().getName());
            }
        }
    }
}

3. 参考资料

Spring多数据源使用

1. 前言

平时开发的时候偶尔会遇到多数据库读写的情况(非分库分表),本文会给出一个简单的配置和使用两个数据库的示例。

2. 依赖与属性配置

首先给出 pom.xml 中引入的依赖:

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>1.3.1</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>LATEST</version>
</dependency>

然后我们手动创建两个数据库。在尝试使用多数据源的时候发现的一个问题是 spring.datasource.schema 不生效了,意味着不能使用 Spring 启动时自动创建库表的特性,所以只能手动创建数据库:

mysql root@localhost:(none)> DROP DATABASE IF EXISTS `prime`;
                          -> CREATE DATABASE `prime`;
                          -> use prime;
                          ->
                          -> DROP TABLE IF EXISTS person;
                          -> CREATE TABLE `person` (
                          ->   `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
                          ->   `name` varchar(50) NOT NULL DEFAULT '' COMMENT '
                          -> 名字',
                          ->   `age` int(11) NOT NULL COMMENT '年龄',
                          ->   `gender` int(11) NOT NULL COMMENT '性别',
                          ->   PRIMARY KEY (`id`)
                          -> ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
                          ->
You're about to run a destructive command.
Do you want to proceed? (y/n): y
Your call!
Query OK, 0 rows affected
Time: 0.006s

Query OK, 1 row affected
Time: 0.012s

You are now connected to database "prime" as user "root"
Time: 0.002s

Query OK, 0 rows affected
Time: 0.007s

Query OK, 0 rows affected
Time: 0.021s
mysql root@localhost:(none)> DROP DATABASE IF EXISTS `secondary`;
                          -> CREATE DATABASE `secondary`;
                          -> use secondary;
                          ->
                          -> DROP TABLE IF EXISTS person;
                          -> CREATE TABLE `person` (
                          ->   `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
                          ->   `name` varchar(50) NOT NULL DEFAULT '' COMMENT '
                          -> 名字',
                          ->   `age` int(11) NOT NULL COMMENT '年龄',
                          ->   `gender` int(11) NOT NULL COMMENT '性别',
                          ->   PRIMARY KEY (`id`)
                          -> ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
You're about to run a destructive command.
Do you want to proceed? (y/n): y
Your call!
Query OK, 0 rows affected
Time: 0.004s

Query OK, 1 row affected
Time: 0.001s

You are now connected to database "secondary" as user "root"
Time: 0.002s

Query OK, 0 rows affected
Time: 0.001s

Query OK, 0 rows affected
Time: 0.028s

下面是 application.properties,注明了两个数据库的基本信息:

spring.datasource.primary.url=jdbc:mysql://127.0.0.1:3306/prime?charset=utf8
spring.datasource.primary.username=root
spring.datasource.primary.password=root
spring.datasource.primary.driver-class-name=com.mysql.jdbc.Driver

spring.datasource.secondary.url=jdbc:mysql://127.0.0.1:3306/secondary?charset=utf8
spring.datasource.secondary.username=root
spring.datasource.secondary.password=root
spring.datasource.secondary.driver-class-name=com.mysql.jdbc.Driver

3. 配置数据源

针对两个数据库我们要分别创建对应的 DataSourcePlatformTransactionManagerSqlSessionFactory

第一个 DataSource 我们使用 Spring 中的 DataSourceBuilder 来构建,并用 @Primary 注解来标记:

package com.avaloninc.springmultidatasourceexample.conf;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
@MapperScan(basePackages = "com.avaloninc.springmultidatasourceexample.mapper.prime", sqlSessionFactoryRef = "primeSsf")
public class DataSourceConfigOne {

  @Bean(name = "primaryDs")
  @ConfigurationProperties(prefix = "spring.datasource.primary")
  @Primary
  public DataSource primaryDs() {
    return DataSourceBuilder.create().build();
  }


  @Bean(name = "primaryTxm")
  @Primary
  public PlatformTransactionManager primaryTxm(@Qualifier("primaryDs") DataSource dataSource) {
    return new DataSourceTransactionManager(dataSource);
  }

  @Bean(name = "primeSsf")
  @Primary
  public SqlSessionFactory primeSsf(@Qualifier("primaryDs") DataSource dataSource) throws Exception {
    SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
    sqlSessionFactoryBean.setDataSource(dataSource);
    return sqlSessionFactoryBean.getObject();
  }
}

第二个数据源我们使用 druid 作为连接池,除了基本参数外其他都采用默认配置:

package com.avaloninc.springmultidatasourceexample.conf;

import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
@MapperScan(basePackages = "com.avaloninc.springmultidatasourceexample.mapper.secondary", sqlSessionFactoryRef = "secondarySsf")
public class DataSourceConfigTwo {

  @Bean(name = "secondaryDs")
  @ConfigurationProperties(prefix = "spring.datasource.secondary")
  public DataSource secondaryDs() {
    return DruidDataSourceBuilder.create().build();
  }

  @Bean(name = "secondaryTxm")
  public PlatformTransactionManager secondaryTxm(@Qualifier("secondaryDs") DataSource dataSource) {
    return new DataSourceTransactionManager(dataSource);
  }


  @Bean(name = "secondarySsf")
  public SqlSessionFactory secondarySsf(@Qualifier("secondaryDs") DataSource dataSource) throws Exception {
    SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
    sqlSessionFactoryBean.setDataSource(dataSource);
    return sqlSessionFactoryBean.getObject();
  }
}

注意,在配置两个不同的数据源时都各自加了 @MapperSacn 注解,并各自给定了 basePackagessqlSessionFactoryRef。不同数据源的 Mapper 在不同的 package 里面定义,并使用不同的 SqlSessionFactory 来创建。

下面是 Mapper 接口的定义:

package com.avaloninc.springmultidatasourceexample.mapper.prime;

import com.avaloninc.springmultidatasourceexample.domain.Person;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.type.EnumOrdinalTypeHandler;

@Mapper
public interface PersonMapper {
  /**
   * Insert int.
   *
   * @param person the person
   * @return the int
   */
  @Insert("insert into person (name, age, gender) values (#{p.name}, #{p.age}, #{p.gender, typeHandler=org.apache.ibatis.type.EnumOrdinalTypeHandler, javaType=com.avaloninc.springmultidatasourceexample.domain.Person$Gender})")
  @Options(useGeneratedKeys = true, keyProperty = "p.id")
  int insert(@Param("p") Person person);

  /**
   * Gets person by id.
   *
   * @param id the id
   * @return the person by id
   */
  @Select("select id, name, age, gender from person where id = #{id}")
  @Results(id = "person", value = {
      @Result(column = "gender", property = "gender", typeHandler = EnumOrdinalTypeHandler.class)
  })
  Person getPersonById(@Param("id") int id);
}

以及:

package com.avaloninc.springmultidatasourceexample.mapper.secondary;

import com.avaloninc.springmultidatasourceexample.domain.User;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.type.EnumOrdinalTypeHandler;

@Mapper
public interface UserMapper {
  /**
   * Insert int.
   *
   * @param user the user
   * @return the int
   */
  @Insert("insert into person (name, age, gender) values (#{p.name}, #{p.age}, #{p.gender, typeHandler=org.apache.ibatis.type.EnumOrdinalTypeHandler, javaType=com.avaloninc.springmultidatasourceexample.domain.User$Gender})")
  @Options(useGeneratedKeys = true, keyProperty = "p.id")
  int insert(@Param("p") User user);

  /**
   * Gets person by id.
   *
   * @param id the id
   * @return the person by id
   */
  @Select("select id, name, age, gender from person where id = #{id}")
  @Results(id = "person", value = {
      @Result(column = "gender", property = "gender", typeHandler = EnumOrdinalTypeHandler.class)
  })
  User getUserById(@Param("id") int id);
}

4. 简单的 Service 调用

下面是针对这两个 Mapper 的写的简单的读写接口:

package com.avaloninc.springmultidatasourceexample.service;


import com.avaloninc.springmultidatasourceexample.domain.Person;

public interface PersonService {
  /**
   * Insert int.
   *
   * @param person the person
   * @return the int
   */
  int insert(Person person);

  /**
   * Gets person by id.
   *
   * @param id the id
   * @return the person by id
   */
  Person getPersonById(int id);
}
package com.avaloninc.springmultidatasourceexample.service;

import com.avaloninc.springmultidatasourceexample.domain.Person;
import com.avaloninc.springmultidatasourceexample.mapper.prime.PersonMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class PersonServiceImpl implements PersonService {

  @Autowired
  private PersonMapper personMapper;

  @Override
  public int insert(Person person) {
    return this.personMapper.insert(person);
  }

  @Override
  public Person getPersonById(int id) {
    return this.personMapper.getPersonById(id);
  }
}

以及:

package com.avaloninc.springmultidatasourceexample.service;

import com.avaloninc.springmultidatasourceexample.domain.User;

public interface UserService {
  /**
   * Insert int.
   *
   * @param user the user
   * @return the int
   */
  int insert(User user);

  /**
   * Gets user by id.
   *
   * @param id the id
   * @return the user by id
   */
  User getUserById(int id);
}
package com.avaloninc.springmultidatasourceexample.service;

import com.avaloninc.springmultidatasourceexample.domain.User;
import com.avaloninc.springmultidatasourceexample.mapper.secondary.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl implements UserService {

  @Autowired
  private UserMapper userMapper;

  @Override
  public int insert(User user) {
    return this.userMapper.insert(user);
  }

  @Override
  public User getUserById(int id) {
    return this.userMapper.getUserById(id);
  }
}

5. 单元测试

单元测试一发入魂:

package com.avaloninc.springmultidatasourceexample;

import static org.junit.Assert.*;

import com.avaloninc.springmultidatasourceexample.domain.Person;
import com.avaloninc.springmultidatasourceexample.domain.User;
import com.avaloninc.springmultidatasourceexample.domain.User.Gender;
import com.avaloninc.springmultidatasourceexample.service.PersonService;
import com.avaloninc.springmultidatasourceexample.service.UserService;
import junit.framework.TestCase;
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;

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

  @Autowired
  private UserService userService;
  @Autowired
  private PersonService personService;

  @Test
  public void test() {
    User user = new User();
    user.setAge(28);
    user.setGender(Gender.MALE);
    user.setName("John");

    int count = this.userService.insert(user);
    assertEquals(1, count);

    User userById = this.userService.getUserById(user.getId());
    assertEquals(user, userById);

    Person person = new Person();
    person.setAge(27);
    person.setName("Doe");
    person.setGender(Person.Gender.MALE);

    int insertCount = this.personService.insert(person);
    assertEquals(1, insertCount);

    Person personById = this.personService.getPersonById(person.getId());
    assertEquals(person, personById);
  }
}

查看数据库确认一下:

mysql root@localhost:(none)> select * from prime.person;
+----+------+-----+--------+
| id | name | age | gender |
+----+------+-----+--------+
| 1  | Doe  | 27  | 0      |
+----+------+-----+--------+
1 row in set
Time: 0.006s
mysql root@localhost:(none)> select * from secondary.person;
+----+------+-----+--------+
| id | name | age | gender |
+----+------+-----+--------+
| 1  | John | 28  | 0      |
+----+------+-----+--------+
1 row in set
Time: 0.006s
mysql root@localhost:(none)>

以上!

Spring 与 JdbcTemplate

1. 前言

Spring 除了 Mybatis 外同样支持 JPA 和 JdbcTemplate 等的数据映射框架。这里简单给一个关于 JdbcTemplate 的示例。

JdbcTemplate 的功能当然不如 MyBatis 来的强大,但是如果偏爱 Java 代码手动做对象映射的可以试一试,下面不多废话,直接上代码。

2. 实例配置

首先给出需要引入的依赖:

  <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
  </dependency>
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
  </dependency>

然后自定义数据源 dataSourcejdbcTemplate

  package com.avaloninc.springjdbctemplateexample.conf;
  
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;
  import org.springframework.context.annotation.PropertySource;
  import org.springframework.core.env.Environment;
  import org.springframework.jdbc.core.JdbcTemplate;
  import org.springframework.jdbc.datasource.DataSourceTransactionManager;
  import org.springframework.jdbc.datasource.DriverManagerDataSource;
  import org.springframework.transaction.PlatformTransactionManager;
  import org.springframework.transaction.annotation.EnableTransactionManagement;
  
  import javax.sql.DataSource;
  
  @Configuration
  @EnableTransactionManagement
  @PropertySource(value = "classpath:application.properties")
  public class AppConfig {
  
    @Autowired
    private Environment env;
  
    @Bean(name = "dataSource")
    public DataSource dataSource() {
      DriverManagerDataSource dataSource = new DriverManagerDataSource();
      String                  url        = env.getProperty("jdbc.url");
      String                  userName   = env.getProperty("jdbc.username");
      String                  password   = env.getProperty("jdbc.password");
      dataSource.setUrl(url);
      dataSource.setUsername(userName);
      dataSource.setPassword(password);
      return dataSource;
    }
  
    @Bean
    public PlatformTransactionManager dataSourceTransactionManager() {
      return new DataSourceTransactionManager(dataSource());
    }
  
    @Bean
    public JdbcTemplate jdbcTemplate() {
      JdbcTemplate jdbcTemplate = new JdbcTemplate();
      jdbcTemplate.setDataSource(dataSource());
      return jdbcTemplate;
    }
  }

这段代码中,我们通过注入 Environment 对象来实现从配置文件中获取数据库的连接信息。

3. 实例代码

先给出模型类:

  package com.avaloninc.springjdbctemplateexample.domain;
  
  import lombok.Data;
  
  @Data
  public class Person {
    private int    id;
    private String name;
    private int    age;
    private Gender gender;
  
    public enum Gender {
      MALE,
      FEMALE;
    }
  }
  

然后给定两个简单的读写接口的实现:

  package com.avaloninc.springjdbctemplateexample.service;
  
  import com.avaloninc.springjdbctemplateexample.domain.Person;
  
  public interface PersonService {
    /**
     * Insert int.
     *
     * @param person the person
     * @return the int
     */
    int insert(Person person);
  
    /**
     * Gets person by id.
     *
     * @param id the id
     * @return the person by id
     */
    Person getPersonById(int id);
  }
  
  package com.avaloninc.springjdbctemplateexample.service;
  
  import com.avaloninc.springjdbctemplateexample.domain.Person;
  import com.avaloninc.springjdbctemplateexample.domain.Person.Gender;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.jdbc.core.JdbcTemplate;
  import org.springframework.jdbc.core.RowMapper;
  import org.springframework.jdbc.support.GeneratedKeyHolder;
  import org.springframework.stereotype.Service;
  
  import java.sql.PreparedStatement;
  import java.sql.ResultSet;
  import java.sql.SQLException;
  import java.sql.Statement;
  
  @Service
  public class PersonServiceImpl implements PersonService {
  
    @Autowired
    private JdbcTemplate jdbcTemplate;
  
    @Override
    public int insert(Person person) {
      GeneratedKeyHolder holder = new GeneratedKeyHolder();
      int rowCount = this.jdbcTemplate.update(
          connection -> {
            PreparedStatement statement = connection.prepareStatement(
                "insert into person (name, age, gender) values (?, ?,?)",
                Statement.RETURN_GENERATED_KEYS);
            statement.setString(1, person.getName());
            statement.setInt(2, person.getAge());
            statement.setInt(3, person.getGender().ordinal());
            return statement;
          },
          holder);
      person.setId(holder.getKey().intValue());
      return rowCount;
    }
  
    @Override
    public Person getPersonById(int id) {
      return this.jdbcTemplate.queryForObject("select id, name, age, gender from person where id = ?",
                                              new Object[] {id}, new RowMapper<Person>() {
            @Override
            public Person mapRow(ResultSet rs, int rowNum) throws SQLException {
              Person person = new Person();
              person.setId(rs.getInt("id"));
              person.setName(rs.getString("name"));
              person.setAge(rs.getInt("age"));
              person.setGender(Gender.values()[rs.getInt("gender")]);
              return person;
            }
          });
    }
  }
  

完全手动实现了对象的映射,对于复杂类型的数据反序列化,不必借助 MyBatis 的 TypeHandler,直接代码实现即可。

4. 单元测试

单元测试走一发,确认有效:

  package com.avaloninc.springjdbctemplateexample.service;
  
  import com.avaloninc.springjdbctemplateexample.domain.Person;
  import com.avaloninc.springjdbctemplateexample.domain.Person.Gender;
  import junit.framework.TestCase;
  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;
  
  @SpringBootTest
  @RunWith(SpringRunner.class)
  public class PersonServiceImplTest extends TestCase {
  
    @Autowired
    private PersonService personService;
  
    @Test
    public void test() {
      Person person = new Person();
      person.setName("John");
      person.setAge(28);
      person.setGender(Gender.MALE);
  
      int count = personService.insert(person);
      assertTrue(count > 0);
      assertTrue(person.getId() > 0);
      Person personById = personService.getPersonById(person.getId());
      assertNotNull(personById);
      assertEquals(person, personById);
    }
  }

泛型 TypeHandler

在 Mybatis 自带的 TypeHandler 中就有通过泛型 TypeHandler 支持的类型,比如枚举的 TypeHandler 就分别有 EnumOrdinalTypeHandlerEnumTypeHandler 两种。分别使用枚举的 ordinal 以及 name 来存取枚举。这两种 TypeHandler 都通过继承 BaseTypeHandler<E> 实现的。

考虑如下的一个场景:我希望对一系列某个接口的子类实例进行处理,如果针对每一个子类实现一个 TypeHandler 会非常麻烦,而我们在序列化和反序列化子类实例的时候都使用接口方法的返回值,那么我们就可以定义一个泛型 TypeHandler。下面给出一个枚举结合泛型 TypeHandler 的例子。

首先定义一个接口:

  
  package com.avaloninc.generictypehandler.domain;
  
  public interface Translatable {
    int getCode();
  
    String getNameCn();
  }

然后定义一个实现该接口的枚举:

  
  package com.avaloninc.generictypehandler.domain;
  
  public enum Gender implements Translatable {
  
    MALE(1, "男"),
    FEMALE(2, "女");
  
    private String nameCn;
    private int code;
  
    Gender(int code, String nameCn) {
      this.code = code;
      this.nameCn = nameCn;
    }
  
    @Override
    public int getCode() {
      return this.code;
    }
  
    @Override
    public String getNameCn() {
      return this.nameCn;
    }
  }

然后我们准备用自定义的 code 来进行存取,而不是枚举的 ordinal (注意 ordinal 和枚举常量的定义顺序是一致的,默认从 0 开始)。

下面给出实体类:

  
  package com.avaloninc.generictypehandler.domain;
  
  import lombok.Data;
  
  @Data
  public class Person {
      private int    id;
      private String name;
      private int    age;
      private Gender gender;
  }

定义泛型 TypeHandler,注意我们这里使用了指定了泛型的上界:枚举并且实现了接口。

  
  package com.avaloninc.generictypehandler.typehandler;
  
  
  import com.avaloninc.generictypehandler.domain.Translatable;
  import org.apache.ibatis.type.BaseTypeHandler;
  import org.apache.ibatis.type.JdbcType;
  
  import java.sql.CallableStatement;
  import java.sql.PreparedStatement;
  import java.sql.ResultSet;
  import java.sql.SQLException;
  import java.util.Arrays;
  import java.util.Objects;
  import java.util.Optional;
  
  public class GenericTranslatableEnumHandler<T extends Enum<T> & Translatable> extends BaseTypeHandler<T> {
  
    private Class<T> type;
  
    public GenericTranslatableEnumHandler(Class<T> type) {
      if (Objects.isNull(type)) {
        throw new IllegalArgumentException("type should not be null.");
      }
      this.type = type;
    }
  
    @Override
    public void setNonNullParameter(PreparedStatement preparedStatement, int i, T t, JdbcType
        jdbcType) throws SQLException {
      preparedStatement.setInt(i, t.getCode());
    }
  
    @Override
    public T getNullableResult(ResultSet resultSet, String s) throws SQLException {
      int code = resultSet.getInt(s);
      Optional<T> first = Arrays.stream(type.getEnumConstants())
          .filter(ele -> ele.getCode() == code).findFirst();
      if (first.isPresent()) {
        return first.get();
      } else {
        throw new SQLException("Illegal argument " + code + " for " + type.getCanonicalName());
      }
    }
  
    @Override
    public T getNullableResult(ResultSet resultSet, int i) throws SQLException {
      int code = resultSet.getInt(i);
      Optional<T> first = Arrays.stream(type.getEnumConstants())
          .filter(ele -> ele.getCode() == code).findFirst();
      if (first.isPresent()) {
        return first.get();
      } else {
        throw new SQLException("Illegal argument " + code + " for " + type.getCanonicalName());
      }
    }
  
    @Override
    public T getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
      int code = callableStatement.getInt(i);
      Optional<T> first = Arrays.stream(type.getEnumConstants())
          .filter(ele -> ele.getCode() == code).findFirst();
      if (first.isPresent()) {
        return first.get();
      } else {
        throw new SQLException("Illegal argument " + code + " for " + type.getCanonicalName());
      }
    }
  }
  

mybatis-config.xml 中指定类型和 TypeHandler 的关联:

  
      <typeHandlers>
          <typeHandler handler="com.avaloninc.generictypehandler.typehandler.GenericTranslatableEnumHandler"
                       javaType="com.avaloninc.generictypehandler.domain.Gender"/>
      </typeHandlers>

最后是 Mapper 接口:

  
  package com.avaloninc.generictypehandler.mapper;
  
  import com.avaloninc.generictypehandler.domain.Person;
  import org.apache.ibatis.annotations.Insert;
  import org.apache.ibatis.annotations.Mapper;
  import org.apache.ibatis.annotations.Options;
  import org.apache.ibatis.annotations.Param;
  import org.apache.ibatis.annotations.Select;
  
  @Mapper
  public interface PersonMapper {
      /**
       * Insert int.
       *
       * @param person the person
       * @return the int
       */
      @Insert("insert into person (name, age, gender) values (#{p.name}, #{p.age}, #{p.gender})")
      @Options(useGeneratedKeys = true, keyProperty = "p.id")
      int insert(@Param("p") Person person);
  
      /**
       * Get person.
       *
       * @param id the id
       * @return the person
       */
      @Select("select id, name, age, gender from person where id = #{id}")
      Person get(@Param("id") int id);
  }

HDFS API

1. 前言

最近在做项目的时候遇到了这样一个需求:WEB 端 API 接受用户上传的文件以供后端服务定时执行任务时使用这些文件或者程序。

一般来说部署的 API 和后端服务都是多点部署的,所以文件的存储必须在多个节点都能访问。简单来说就是需要一个分布式的文件存储服务。

可供选择的方式也有很多,比如:Redis、HDFS 甚至可以把文件直接存储到数据库里。但是考虑到上传的文件可能是 jar 包或者其他大文件以及管理的便利性,最终采用了 HDFS 作为我们的分布式存储方案。

2. 本地环境搭建

在本地环境搭建方面,基本采用了参考文献 1 里的实施方法,主要还是用 brew 来安装,但是为了兼容线上 2.6.0 的版本,在做本地安装的时候还是使用了 2.6.0 的版本。

首先打开远程登录的配置:

  
  sudo systemsetup -setremotelogin on

然后生成公钥和私钥(略),接着将公钥加入到 authrized_keys 里面:

  cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys

接着是安装 hadoop,如果直接使用 brew install hadoop 来安装的话默认会安装上 3.0.0,为了安装指定的 2.6.0 还需要一点小技巧。

首先如果 brew 安装的其实是一系列 ruby 脚本,brew install <package> 会转换成对应软件包的安装脚本进行。到这里我们就可以想到在 brew 的提交历史中找到 2.6.0 的安装脚本即可,其对应的 Github 地址是:hadoop.rb

因此安装的时候只要输入:

  brew install https://raw.githubusercontent.com/Homebrew/homebrew-core/ed89a8d0422f29c9bb87e2ea11b3a3f550493294/Formula/hadoop.rb

安装好之后,我们只需要使用 HDFS,因此只做 HDFS 相关的配置。

修改 /usr/local/Cellar/hadoop/2.6.0/libexec/etc/hadoop/core-site.xml 的内容:

  
   <configuration>
      <property>
          <name>hadoop.tmp.dir</name>
          <value>/usr/local/Cellar/hadoop/hdfs/tmp</value>
          <description>A base for other temporary directories.</description>
      </property>
      <property>
          <name>fs.default.name</name>
          <value>hdfs://localhost:8020</value>
      </property>
  </configuration>

然后初始化 namenode

  
  cd /usr/local/Cellar/hadoop/2.6.0/libexec/bin
  ./hdfs namenode -format

最后通过 /usr/local/Cellar/hadoop/2.6.0/sbin/start-dfs.sh 启动,而 /usr/local/Cellar/hadoop/2.6.0/sbin/stop-dfs.sh 负责关闭 HDFS。

3. API 使用实例

这里代码不做赘述,只是简单放一下基本读写的方法:

  package com.avaloninc.hdfsapi.service;
  
  import java.io.IOException;
  import java.io.InputStream;
  import java.util.List;
  
  public interface HdfsService {
      String read(String path) throws IOException;
  
      void write(String path, InputStream inputStream) throws IOException;
  
      boolean rename(String src, String dest) throws IOException;
  
      boolean delete(String path) throws IOException;
  
      List<String> ls(String path) throws IOException;
  }
  

接口实现类:

  package com.avaloninc.hdfsapi.service.impl;
  
  import com.google.common.base.Joiner;
  import com.google.common.collect.ImmutableList;
  
  import com.avaloninc.hdfsapi.service.HdfsService;
  import org.apache.commons.io.IOUtils;
  import org.apache.hadoop.conf.Configuration;
  import org.apache.hadoop.fs.FSDataOutputStream;
  import org.apache.hadoop.fs.FileStatus;
  import org.apache.hadoop.fs.FileSystem;
  import org.apache.hadoop.fs.Path;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.stereotype.Service;
  
  import java.io.IOException;
  import java.io.InputStream;
  import java.util.Arrays;
  import java.util.List;
  import java.util.stream.Collectors;
  
  @Service
  public class HdfsServiceImpl implements HdfsService {
      @Autowired
      private Configuration config;
  
      @Override
      public String read(String path) throws IOException {
          try (FileSystem fileSystem = FileSystem.get(config)) {
              Path hdfsPath = new Path(path);
              List<String> lines = IOUtils.readLines(fileSystem.open(hdfsPath));
              return Joiner.on("\n").join(lines);
          }
      }
  
      @Override
      public void write(String path, InputStream inputStream) throws IOException {
          FileSystem         fileSystem   = null;
          FSDataOutputStream outputStream = null;
          try {
              Path hdfsPath = new Path(path);
              fileSystem = FileSystem.get(config);
              outputStream = fileSystem.create(hdfsPath, true);
  
              byte[] bytes    = new byte[1024];
              int    numBytes = 0;
              while ((numBytes = inputStream.read(bytes)) > 0) {
                  outputStream.write(bytes, 0, numBytes);
              }
          } finally {
              IOUtils.closeQuietly(inputStream);
              IOUtils.closeQuietly(outputStream);
              IOUtils.closeQuietly(fileSystem);
          }
      }
  
      @Override
      public boolean rename(String src, String dest) throws IOException {
          try (FileSystem fileSystem = FileSystem.get(config)) {
              Path srcPath = new Path(src);
              Path destPath = new Path(dest);
  
              if (!fileSystem.exists(srcPath)) {
                  throw new IOException("Path " + src + " do not exists.");
              }
  
              if (!fileSystem.exists(destPath.getParent())) {
                  fileSystem.mkdirs(destPath.getParent());
              }
  
              return fileSystem.rename(srcPath, destPath);
          }
      }
  
      @Override
      public boolean delete(String path) throws IOException {
          try (FileSystem fileSystem = FileSystem.get(config)) {
              return fileSystem.delete(new Path(path), true);
          }
      }
  
      @Override
      public List<String> ls(String path) throws IOException {
          try (FileSystem fileSystem = FileSystem.get(config)) {
              Path hdfsPath = new Path(path);
              if (!fileSystem.exists(hdfsPath)) {
                  throw new IllegalArgumentException(
                      "Path " + path + " do not exist or is not a dir.");
              }
  
              if (fileSystem.isDirectory(hdfsPath)) {
                  return Arrays.stream(fileSystem.listStatus(hdfsPath))
                      .map(FileStatus::getPath)
                      .map(Path::getName)
                      .collect(Collectors.toList());
              } else {
                  FileStatus status = fileSystem.getFileStatus(hdfsPath);
                  return ImmutableList.of(status.getPath().getName());
              }
          }
      }
  }

单元测试:

  package com.avaloninc.hdfsapi.service;
  
  import junit.framework.TestCase;
  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.ByteArrayInputStream;
  import java.io.IOException;
  import java.io.InputStream;
  import java.util.List;
  
  @RunWith(SpringRunner.class)
  @SpringBootTest
  public class HdfsServiceTest extends TestCase {
  
      @Autowired
      private HdfsService hdfsService;
  
      @Test
      public void test() throws IOException {
          String      sql         = "select * \nfrom test.test_tb";
          String      path        = "/user/wuzhiyu";
          String      fileName    = "test.sql";
          String      srcPath     = path + "/" + fileName;
          InputStream inputStream = new ByteArrayInputStream(sql.getBytes());
          hdfsService.write(srcPath, inputStream);
  
          List<String> list = hdfsService.ls(path);
          assertTrue(list.contains(fileName));
          System.out.println("list = " + list);
  
          String content = hdfsService.read(srcPath);
          assertEquals(sql, content);
          System.out.println("content = " + content);
  
          String newFileName = "test_rename.sql";
          String newPath = path + "/" + newFileName;
          assertTrue(hdfsService.rename(srcPath, newPath));
  
          list = hdfsService.ls(path);
          assertTrue(list.contains(newFileName));
          System.out.println("list = " + list);
  
          assertTrue(hdfsService.delete(newPath));
      }
  }

引用 Hadoop 客户端:

  
          <dependency>
              <groupId>org.apache.hadoop</groupId>
              <artifactId>hadoop-client</artifactId>
              <version>2.6.0</version>
          </dependency>

4. 参考文献

@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 命令内部需要用双引号和单引号的还是尽量用转义的双引号吧!