You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

26 KiB

答疑篇:代码篇思考题集锦(三)

你好,我是朱晔。

今天我们继续一起分析这门课第13~20讲的课后思考题。这些题目涉及了日志、文件IO、序列化、Java 8日期时间类、OOM、Java高级特性反射、注解和泛型和Spring框架的16道问题。

接下来,我们就一一具体分析吧。

13 | 日志:日志记录真没你想象的那么简单

**问题1**在讲“为什么我的日志会重复记录”的案例时我们把INFO级别的日志存放到_info.log中把WARN和ERROR级别的日志存放到_error.log中。如果现在要把INFO和WARN级别的日志存放到_info.log中把ERROR日志存放到_error.log中应该如何配置Logback呢

要实现这个配置有两种方式分别是直接使用EvaluatorFilter和自定义一个Filter。我们分别看一下。

第一种方式是直接使用logback自带的EvaluatorFilter

<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
    <evaluator class="ch.qos.logback.classic.boolex.GEventEvaluator">
        <expression>
            e.level.toInt() == WARN.toInt() || e.level.toInt() == INFO.toInt()
        </expression>
    </evaluator>
    <OnMismatch>DENY</OnMismatch>
    <OnMatch>NEUTRAL</OnMatch>
</filter>

第二种方式是自定义一个Filter实现解析配置中的“|”字符分割的多个Level

public class MultipleLevelsFilter extends Filter<ILoggingEvent> {

    @Getter
    @Setter
    private String levels;
    private List<Integer> levelList;

    @Override
    public FilterReply decide(ILoggingEvent event) {

        if (levelList == null && !StringUtils.isEmpty(levels)) {
            //把由|分割的多个Level转换为List<Integer>
            levelList = Arrays.asList(levels.split("\\|")).stream()
                    .map(item -> Level.valueOf(item))
                    .map(level -> level.toInt())
                    .collect(Collectors.toList());
        }
        //如果levelList包含当前日志的级别则接收否则拒绝
        if (levelList.contains(event.getLevel().toInt()))
            return FilterReply.ACCEPT;
        else
            return FilterReply.DENY;
    }
}

然后在配置文件中使用这个MultipleLevelsFilter就可以了完整的配置代码参考这里

<filter class="org.geekbang.time.commonmistakes.logging.duplicate.MultipleLevelsFilter">
    <levels>INFO|WARN</levels>
</filter>

**问题2**生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理,以避免单个文件太大,同时保留一定天数的历史日志,你知道如何配置吗?可以在官方文档找到答案。

参考配置如下使用SizeAndTimeBasedRollingPolicy来实现按照文件大小和历史文件保留天数进行文件分割和归档

<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <!--日志文件保留天数-->
    <MaxHistory>30</MaxHistory>
    <!--日志文件最大的大小-->
    <MaxFileSize>100MB</MaxFileSize>
    <!--日志整体最大
     可选的totalSizeCap属性控制所有归档文件的总大小。当超过总大小上限时将异步删除最旧的存档。
     totalSizeCap属性也需要设置maxHistory属性。此外“最大历史”限制总是首先应用“总大小上限”限制其次应用。
     -->
    <totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>

14 | 文件IO实现高效正确的文件读写并非易事

**问题1**Files.lines方法进行流式处理需要使用try-with-resources进行资源释放。那么使用Files类中其他返回Stream包装对象的方法进行流式处理比如newDirectoryStream方法返回DirectoryStreamlist、walk和find方法返回Stream也同样有资源释放问题吗

使用Files类中其他返回Stream包装对象的方法进行流式处理也同样会有资源释放问题。

因为这些接口都需要使用try-with-resources模式来释放。正如文中所说如果不显式释放那么可能因为底层资源没有及时关闭造成资源泄露。

**问题2**Java的File类和Files类提供的文件复制、重命名、删除等操作是原子性的吗

Java的File和Files类的文件复制、重命名、删除等操作都不是原子性的。原因是文件类操作基本都是调用操作系统本身的API一般来说这些文件API并不像数据库有事务机制也很难办到即使有也很可能有平台差异性。

比如File.renameTo方法的文档中提到

Many aspects of the behavior of this method are inherently platform-dependent: The rename operation might not be able to move a file from one filesystem to another, it might not be atomic, and it might not succeed if a file with the destination abstract pathname already exists. The return value should always be checked to make sure that the rename operation was successful.

又比如Files.copy方法的文档中提到

Copying a file is not an atomic operation. If an IOException is thrown, then it is possible that the target file is incomplete or some of its file attributes have not been copied from the source file. When the REPLACE_EXISTING option is specified and the target file exists, then the target file is replaced. The check for the existence of the file and the creation of the new file may not be atomic with respect to other file system activities.

15 | 序列化:一来一回你还是原来的你吗?

**问题1**在讨论Redis序列化方式的时候我们自定义了RedisTemplate让Key使用String序列化、让Value使用JSON序列化从而使Redis获得的Value可以直接转换为需要的对象类型。那么使用RedisTemplate<String, Long>能否存取Value是Long的数据呢这其中有什么坑吗

使用RedisTemplate<String, Long>不一定能存取Value是Long的数据。在Integer区间内返回的是Integer超过这个区间返回Long。测试代码如下

@GetMapping("wrong2")
public void wrong2() {
    String key = "testCounter";
    //测试一下设置在Integer范围内的值
    countRedisTemplate.opsForValue().set(key, 1L);
    log.info("{} {}", countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
    Long l1 = getLongFromRedis(key);
    //测试一下设置超过Integer范围的值
    countRedisTemplate.opsForValue().set(key, Integer.MAX_VALUE + 1L);
    log.info("{} {}", countRedisTemplate.opsForValue().get(key), countRedisTemplate.opsForValue().get(key) instanceof Long);
    //使用getLongFromRedis转换后的值必定是Long
    Long l2 = getLongFromRedis(key);
    log.info("{} {}", l1, l2);
}

private Long getLongFromRedis(String key) {
    Object o = countRedisTemplate.opsForValue().get(key);
    if (o instanceof Integer) {
        return ((Integer) o).longValue();
    }
    if (o instanceof Long) {
        return (Long) o;
    }
    return null;
}

会得到如下输出:

1 false
2147483648 true
1 2147483648

可以看到值设置1的时候类型不是Long设置2147483648的时候是Long。也就是使用RedisTemplate<String, Long>不一定就代表获取的到的Value是Long。

所以这边我写了一个getLongFromRedis方法来做转换避免出错判断当值是Integer的时候转换为Long。

**问题2**你可以看一下Jackson2ObjectMapperBuilder类源码的实现注意configure方法分析一下其除了关闭FAIL_ON_UNKNOWN_PROPERTIES外还做了什么吗

除了关闭FAIL_ON_UNKNOWN_PROPERTIES外Jackson2ObjectMapperBuilder类源码还主要做了以下两方面的事儿。

第一设置Jackson的一些默认值比如

  • MapperFeature.DEFAULT_VIEW_INCLUSION设置为禁用
  • DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES设置为禁用。

第二自动注册classpath中存在的一些jackson模块比如

  • jackson-datatype-jdk8支持JDK8的一些类型比如Optional
  • jackson-datatype-jsr310 支持JDK8的日期时间一些类型。
  • jackson-datatype-joda支持Joda-Time类型。
  • jackson-module-kotlin支持Kotlin。

16 | 用好Java 8的日期时间类少踩一些“老三样”的坑

**问题1**在这一讲中我多次强调了Date是一个时间戳是UTC时间、没有时区概念。那为什么调用其toString方法会输出类似CST之类的时区字样呢

关于这个问题参考toString中的相关源码你可以看到会获取当前时区取不到则显示GMT进行格式化

public String toString() {
    BaseCalendar.Date date = normalize();
    ...
    TimeZone zi = date.getZone();
    if (zi != null) {
        sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US)); // zzz
    } else {
        sb.append("GMT");
    }
    sb.append(' ').append(date.getYear());  // yyyy
    return sb.toString();
}

private final BaseCalendar.Date normalize() {
    if (cdate == null) {
        BaseCalendar cal = getCalendarSystem(fastTime);
        cdate = (BaseCalendar.Date) cal.getCalendarDate(fastTime,
                                                        TimeZone.getDefaultRef());
        return cdate;
    }
    // Normalize cdate with the TimeZone in cdate first. This is
    // required for the compatible behavior.
    if (!cdate.isNormalized()) {
        cdate = normalize(cdate);
    }
    // If the default TimeZone has changed, then recalculate the
    // fields with the new TimeZone.
    TimeZone tz = TimeZone.getDefaultRef();
    if (tz != cdate.getZone()) {
        cdate.setZone(tz);
        CalendarSystem cal = getCalendarSystem(cdate);
        cal.getCalendarDate(fastTime, cdate);
    }
    return cdate;
}

其实说白了这里显示的时区仅仅用于呈现并不代表Date类内置了时区信息。

**问题2**日期时间数据始终要保存到数据库中MySQL中有两种数据类型datetime和timestamp可以用来保存日期时间。你能说说它们的区别吗它们是否包含时区信息呢

datetime和timestamp的区别主要体现在占用空间、表示的时间范围和时区三个方面。

  • 占用空间datetime占用8字节timestamp占用4字节。
  • 表示的时间范围datetime表示的范围是从“1000-01-01 00:00:00.000000”到“9999-12-31 23:59:59.999999”timestamp表示的范围是从“1970-01-01 00:00:01.000000”到“2038-01-19 03:14:07.999999”。
  • 时区timestamp保存的时候根据当前时区转换为UTC查询的时候再根据当前时区从UTC转回来而datetime就是一个死的字符串时间仅仅对MySQL本身而言表示。

需要注意的是我们说datetime不包含时区是固定的时间表示仅仅是指MySQL本身。使用timestamp需要考虑Java进程的时区和MySQL连接的时区。而使用datetime类型则只需要考虑Java进程的时区因为MySQL datetime没有时区信息了JDBC时间戳转换成MySQL datetime会根据MySQL的serverTimezone做一次转换

如果你的项目有国际化需求,我推荐使用时间戳,并且要确保你的应用服务器和数据库服务器设置了正确的匹配当地时区的时区配置。

其实,即便你的项目没有国际化需求,至少是应用服务器和数据库服务器设置一致的时区,也是需要的。

17 | 别以为“自动挡”就不可能出现OOM

**问题1**Spring的ConcurrentReferenceHashMap针对Key和Value支持软引用和弱引用两种方式。你觉得哪种方式更适合做缓存呢

答:软引用和弱引用的区别在于:若一个对象是弱引用可达,无论当前内存是否充足它都会被回收,而软引用可达的对象在内存不充足时才会被回收。因此,软引用要比弱引用“强”一些。

那么,使用弱引用作为缓存就会让缓存的生命周期过短,所以软引用更适合作为缓存。

**问题2**当我们需要动态执行一些表达式时可以使用Groovy动态语言实现new出一个GroovyShell类然后调用evaluate方法动态执行脚本。这种方式的问题是会重复产生大量的类增加Metaspace区的GC负担有可能会引起OOM。你知道如何避免这个问题吗

调用evaluate方法动态执行脚本会产生大量的类要避免可能因此导致的OOM问题我们可以把脚本包装为一个函数先调用parse函数来得到Script对象然后缓存起来以后直接使用invokeMethod方法调用这个函数即可

private Object rightGroovy(String script, String method, Object... args) {
    Script scriptObject;
 
    if (SCRIPT_CACHE.containsKey(script)) {
        //如果脚本已经生成过Script则直接使用
        scriptObject = SCRIPT_CACHE.get(script);
    } else {
        //否则把脚本解析为Script
        scriptObject = shell.parse(script);
        SCRIPT_CACHE.put(script, scriptObject);
    }
    return scriptObject.invokeMethod(method, args);
}

我在源码中提供了一个测试程序,你可以直接去看一下。

18 | 当反射、注解和泛型遇到OOP时会有哪些坑

**问题1**泛型类型擦除后会生成一个bridge方法这个方法同时又是synthetic方法。除了泛型类型擦除你知道还有什么情况编译器会生成synthetic方法吗

Synthetic方法是编译器自动生成的方法在源码中不出现。除了文中提到的泛型类型擦除外Synthetic方法还可能出现的一个比较常见的场景是内部类和顶层类需要相互访问对方的private字段或方法的时候。

编译后的内部类和普通类没有区别遵循private字段或方法对外部类不可见的原则但语法上内部类和顶层类的私有字段需要可以相互访问。为了解决这个矛盾编译器就只能生成桥接方法也就是Synthetic方法来把private成员转换为package级别的访问限制。

比如如下代码InnerClassApplication类的test方法需要访问内部类MyInnerClass类的私有字段name而内部类MyInnerClass类的test方法需要访问外部类InnerClassApplication类的私有字段gender。

public class InnerClassApplication {

    private String gender = "male";
    public static void main(String[] args) throws Exception {
        InnerClassApplication application = new InnerClassApplication();
        application.test();
    }

    private void test(){
        MyInnerClass myInnerClass = new MyInnerClass();
        System.out.println(myInnerClass.name);
        myInnerClass.test();
    }

    class MyInnerClass {
        private String name = "zhuye";
        void test(){
            System.out.println(gender);
        }
    }
}

编译器会为InnerClassApplication和MyInnerClass都生成桥接方法。

如下图所示InnerClassApplication的test方法其实调用的是内部类的access$000静态方法

这个access$000方法是Synthetic方法

而Synthetic方法的实现转接调用了内部类的name字段

反过来内部类的test方法也是通过外部类InnerClassApplication类的桥接方法access$100调用到其私有字段

**问题2**关于注解继承问题你觉得Spring的常用注解@Service、@Controller是否支持继承呢

Spring的常用注解@Service、@Controller不支持继承。这些注解只支持放到具体的非接口非抽象顶层类上来让它们成为Bean如果支持继承会非常不灵活而且容易出错。

19 | Spring框架IoC和AOP是扩展的核心

**问题1**除了通过@Autowired注入Bean外还可以使用@Inject或@Resource来注入Bean。你知道这三种方式的区别是什么吗

答:我们先说一下使用@Autowired、@Inject和@Resource这三种注解注入Bean的方式

  • @Autowired是Spring的注解优先按照类型注入。当无法确定具体注入类型的时候可以通过@Qualifier注解指定Bean名称。
  • @Inject是JSR330规范的实现也是根据类型进行自动装配的这一点和@Autowired类似。如果需要按名称进行装配则需要配合使用@Named。@Autowired和@Inject的区别在于前者可以使用required=false允许注入null后者允许注入一个Provider实现延迟注入。
  • @ResourceJSR250规范的实现如果不指定name优先根据名称进行匹配然后才是类型如果指定name则仅根据名称匹配。

**问题2**当Bean产生循环依赖时比如BeanA的构造方法依赖BeanB作为成员需要注入BeanB也依赖BeanA你觉得会出现什么问题呢又有哪些解决方式呢

Bean产生循环依赖主要包括两种情况一种是注入属性或字段涉及循环依赖另一种是构造方法注入涉及循环依赖。接下来我分别和你讲一讲。

第一种注入属性或字段涉及循环依赖比如TestA和TestB相互依赖

@Component
public class TestA {
    @Autowired
    @Getter
    private TestB testB;
}

@Component
public class TestB {
    @Autowired
    @Getter
    private TestA testA;
}

针对这个问题Spring内部通过三个Map的方式解决了这个问题不会出错。基本原理是因为循环依赖所以实例的初始化无法一次到位需要分步进行

  1. 创建A仅仅实例化不注入依赖
  2. 创建B仅仅实例化不注入依赖
  3. 为B注入A此时B已健全
  4. 为A注入B此时A也健全

网上有很多相关的分析,我找了一篇比较详细的,可供你参考。

第二种构造方法注入涉及循环依赖。遇到这种情况的话程序无法启动比如TestC和TestD的相互依赖

@Component
public class TestC {
    @Getter
    private TestD testD;

    @Autowired
    public TestC(TestD testD) {
        this.testD = testD;
    }
}

@Component
public class TestD {
    @Getter
    private TestC testC;

    @Autowired
    public TestD(TestC testC) {
        this.testC = testC;
    }
}

这种循环依赖的主要解决方式有2种

  • 改为属性或字段注入;
  • 使用@Lazy延迟注入。比如如下代码
@Component
public class TestC {
    @Getter
    private TestD testD;

    @Autowired
    public TestC(@Lazy TestD testD) {
        this.testD = testD;
    }
}

其实,这种@Lazy方式注入的就不是实际的类型了而是代理类获取的时候通过代理去拿值实例化。所以它可以解决循环依赖无法实例化的问题。

20 | Spring框架框架帮我们做了很多工作也带来了复杂度

**问题1**除了Spring框架这两讲涉及的execution、within、@within、@annotation 四个指示器外Spring AOP 还支持 this、target、args、@target、@args。你能说说后面五种指示器的作用吗

答:关于这些指示器的作用,你可以参考官方文档,文档里已经写的很清晰。

总结一下,按照使用场景,建议使用下面这些指示器:

  • 针对方法签名使用execution
  • 针对类型匹配使用within匹配类型、this匹配代理类实例、target匹配代理背后的目标类实例、args匹配参数
  • 针对注解匹配,使用@annotation使用指定注解标注的方法、@target使用指定注解标注的类、@args使用指定注解标注的类作为某个方法的参数

你可能会问,@within怎么没有呢

其实对于Spring默认的基于动态代理或CGLIB的AOP因为切点只能是方法使用@within和@target指示器并无区别但需要注意如果切换到AspectJ那么使用@within和@target这两个指示器的行为就会有所区别了@within会切入更多的成员的访问比如静态构造方法、字段访问一般而言使用@target指示器即可。

**问题2**Spring 的 Environment 中的 PropertySources 属性可以包含多个 PropertySource越往前优先级越高。那我们能否利用这个特点实现配置文件中属性值的自动赋值呢比如我们可以定义 %%MYSQL.URL%%、%%MYSQL.USERNAME%% 和 %%MYSQL.PASSWORD%%,分别代表数据库连接字符串、用户名和密码。在配置数据源时,我们只要设置其值为占位符,框架就可以自动根据当前应用程序名 application.name统一把占位符替换为真实的数据库信息。这样生产的数据库信息就不需要放在配置文件中了会更安全。

我们利用PropertySource具有优先级的特点实现配置文件中属性值的自动赋值。主要逻辑是遍历现在的属性值找出能匹配到占位符的属性并把这些属性的值替换为实际的数据库信息然后再把这些替换后的属性值构成新的PropertiesPropertySource加入PropertySources的第一个。这样我们这个PropertiesPropertySource中的值就可以生效了。

主要源码如下:

public static void main(String[] args) {
    Utils.loadPropertySource(CommonMistakesApplication.class, "db.properties");
    new SpringApplicationBuilder()
            .sources(CommonMistakesApplication.class)
            .initializers(context -> initDbUrl(context.getEnvironment()))
            .run(args);
}
private static final String MYSQL_URL_PLACEHOLDER = "%%MYSQL.URL%%";
private static final String MYSQL_USERNAME_PLACEHOLDER = "%%MYSQL.USERNAME%%";
private static final String MYSQL_PASSWORD_PLACEHOLDER = "%%MYSQL.PASSWORD%%";
private static void initDbUrl(ConfigurableEnvironment env) {

    String dataSourceUrl = env.getProperty("spring.datasource.url");
    String username = env.getProperty("spring.datasource.username");
    String password = env.getProperty("spring.datasource.password");

    if (dataSourceUrl != null && !dataSourceUrl.contains(MYSQL_URL_PLACEHOLDER))
        throw new IllegalArgumentException("请使用占位符" + MYSQL_URL_PLACEHOLDER + "来替换数据库URL配置");
    if (username != null && !username.contains(MYSQL_USERNAME_PLACEHOLDER))
        throw new IllegalArgumentException("请使用占位符" + MYSQL_USERNAME_PLACEHOLDER + "来替换数据库账号配置!");
    if (password != null && !password.contains(MYSQL_PASSWORD_PLACEHOLDER))
        throw new IllegalArgumentException("请使用占位符" + MYSQL_PASSWORD_PLACEHOLDER + "来替换数据库密码配置!");

    //这里我把值写死了,实际应用中可以从外部服务来获取
    Map<String, String> property = new HashMap<>();
    property.put(MYSQL_URL_PLACEHOLDER, "jdbc:mysql://localhost:6657/common_mistakes?characterEncoding=UTF-8&useSSL=false");
    property.put(MYSQL_USERNAME_PLACEHOLDER, "root");
    property.put(MYSQL_PASSWORD_PLACEHOLDER, "kIo9u7Oi0eg");
    //保存修改后的配置属性
    Properties modifiedProps = new Properties();
    //遍历现在的属性值,找出能匹配到占位符的属性,并把这些属性的值替换为实际的数据库信息
    StreamSupport.stream(env.getPropertySources().spliterator(), false)
            .filter(ps -> ps instanceof EnumerablePropertySource)
            .map(ps -> ((EnumerablePropertySource) ps).getPropertyNames())
            .flatMap(Arrays::stream)
            .forEach(propKey -> {
                String propValue = env.getProperty(propKey);
                property.entrySet().forEach(item -> {
                    //如果原先配置的属性值包含我们定义的占位符
                    if (propValue.contains(item.getKey())) {
                        //那么就把实际的配置信息加入modifiedProps
                        modifiedProps.put(propKey, propValue.replaceAll(item.getKey(), item.getValue()));
                    }
                });
            });

    if (!modifiedProps.isEmpty()) {
        log.info("modifiedProps: {}", modifiedProps);
        env.getPropertySources().addFirst(new PropertiesPropertySource("mysql", modifiedProps));
    }
}

我在GitHub上第20讲对应的源码中更新了我的实现你可以点击这里查看。有一些同学会问这么做的意义到底在于什么为何不直接使用类似Apollo这样的配置框架呢

其实我们的目的就是不希望让开发人员手动配置数据库信息希望程序启动的时候自动替换占位符实现自动配置从CMDB直接拿着应用程序ID来换取对应的数据库信息。你可能会问了一个应用程序ID对应多个数据库怎么办其实一般对于微服务系统来说一个应用就应该对应一个数据库。这样一来除了程序其他人都不会接触到生产的数据库信息会更安全。

以上就是咱们这门课的第13~20讲的思考题答案了。

关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。