关于admin

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

使用 Shadowsocks-libev 构建网络代理

1. 序

作为一个程序员,总有着一些科学上网的需求。面对纷繁的服务提供商,面对时断时续的网络服务,总还是觉得老话说的有道理:自己动手丰衣足食!

2. 搭建过程

搭建代理服务器的常见手段是在一台境外服务器上启动 Shadowsocks 服务,然后在终端使用客户端连接过去。大致过程可以分为几个简单步骤:

  1. 购买境外服务器。

  2. 搭建 Shadowsocks 服务。

  3. 下载客户端连接。

2.1 购买服务器

在同事的推荐下,在 Vultr1 购买相关的服务。配置和价格如下:

项目 规格
CPU 1 vCore
RAM 1024 M
Storage 25 G SSD
Bandwidth 1000 GB
Location Silicon Valley
OS Ubuntu 18.04
Price $ 5 /month

但就价格来说每年对应人民币在 400 元左右价格偏贵,但是如果考虑到同时能搭建 WordPress 和 Shadowsocks 服务的话相对更容易接受一些。

另外一个好处是灵活,如果这个 IP 被封了还可以在其他地区的机房搭建服务。

2.2 搭建服务

搭建服务只需要按步骤执行如下命令(默认 root 用户)2

 apt-get update
 apt-get install -y shadowsocks-libev

然后编辑配置文件 /etc/shadowsocks-libev/config.json

 {
     "server":"0.0.0.0",
     "server_port":<port>,
     "local_port":1080,
     "password":"<password>",
     "timeout":60,
     "method":"aes-256-cfb"
 }

这里 <server port><password> 根据喜好设置即可。

然后我们启动服务:

 # 重新启动 shadowsocks-libev (以防按照旧的配置启动了服务)
 systemctl restart shadowsocks-libev
 
 # 设置随着操作系统自启动
 systemctl enable shadowsocks-libev.service
 
 # 查看服务状态
 systemctl status shadowsocks-libev.service

另外针对网络拥塞的情况我们还可以使用 BBR 来提升网速,具体的方法为3

 echo "net.core.default_qdisc=fq" >> /etc/sysctl.conf
 echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf
 sysctl -p

关于 BBR 的原理可参见参考资料4

2.3 下载客户端

Shadowsocks 的客户端可以从 Github 进行下载,具体页面为:

3. 参考资料

RestTemplate 与 Gzip Content-Encoding

1. 问题描述

最近做一个针对 Yarn Application 进行错误诊断的需求,需要从 Resource Manager 获取 Application 的运行信息,比如:

 GET /ws/v1/cluster/apps/application_1561545353229_936285 HTTP/1.1
 Host: yarn.xxx.com
 Accept: */*
 accept-encoding: gzip, deflate

这个接口使用 Postman 可以得到对应的结果:

 {
     "app": {
         "id": "application_1561545353229_936285",
         "user": "bp_growth",
         "name": "moses:278648",
         "queue": "root.bp_growth_dev",
         "state": "FINISHED",
         "finalStatus": "FAILED",
         "progress": 100,
         "trackingUI": "History",
         "trackingUrl": "http://yarn-rm02.tc.rack.xxx.com:8088/proxy/application_1561545353229_936285/",
         "diagnostics": "Task failed task_1561545353229_936285_m_000008\nJob failed as tasks failed. failedMaps:1 failedReduces:0\n",
         "clusterId": 1561545353229,
         "applicationType": "MAPREDUCE",
         "applicationTags": "",
         "startedTime": 1562740151124,
         "finishedTime": 1562740195218,
         "elapsedTime": 44094,
         "amContainerLogs": "http://data1117.tc.rack.xxx.com:8042/node/containerlogs/container_e93_1561545353229_936285_01_000001/bp_growth",
         "amHostHttpAddress": "data1117.tc.rack.xxx.com:8042",
         "allocatedMB": -1,
         "allocatedVCores": -1,
         "reservedMB": -1,
         "reservedVCores": -1,
         "runningContainers": -1,
         "memorySeconds": 1583688,
         "vcoreSeconds": 721,
         "preemptedResourceMB": 0,
         "preemptedResourceVCores": 0,
         "numNonAMContainerPreempted": 0,
         "numAMContainerPreempted": 0,
         "logAggregationStatus": "TIME_OUT"
     }
 }

但是使用 RestTemplate 去请求的时候却会得到异常:

 org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Illegal character ((CTRL-CHAR, code 31)): only regular white space (\r, \n, \t) is allowed between tokens; nested exception is com.fasterxml.jackson.core.JsonParseException: Illegal character ((CTRL-CHAR, code 31)): only regular white space (\r, \n, \t) is allowed between tokens
  at [Source: (PushbackInputStream); line: 1, column: 2]

原因是 Response 的结果使用了 Gzip 进行压缩。

2. 解决方案

在网上找到的最简单的解决方案来自:How to parse gzip encoded response with RestTemplate from Spring-Web

简单来说就是在构造 RestTemplate 的时候指定 ClientHttpRequestFactory。

Maven 项目引入一个依赖:

 <dependency>
   <groupId>org.apache.httpcomponents</groupId>
   <artifactId>httpclient</artifactId>
 </dependency>

由于我同时使用了 SpringBoot,所以不需要指定版本。

具体使用时如:

   @Test
   public void test() {
 
     HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(
         HttpClientBuilder.create().build());
     RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory);
 
     Map forObject = restTemplate.getForObject("http://yarn.xxx.com/ws/v1/cluster/apps/application_1561545353229_936285", Map.class);
     System.out.println("forObject = " + forObject);
   }

使用 Docker 构建工作环境

1. 前言

作为一个程序员,在家加班或者处理紧急需求是一种常态。为了应对这种情况(也为了更好地压榨程序员的剩余时间)大部分公司都会给员工配发笔记本电脑。

新员工入职第一件事情就是配置工作环境,这个工作至少耗费一天的时间。有经验的程序员一般会用一个 dotfile 项目来保存常用的配置文件,比如 .zshrc.vimrc.ssh/config 等。

本文更进一步,使用 Docker 来构建工作环境。

原因在于以下几个方面:

  • 任意电脑都可以快速初始化工作环境。本人不喜欢把公司电脑背回家,一来太重太麻烦,而来丢了坏了还要找你赔。

  • 隔离工作环境。把工作环境当做不同的 profile,以插件的形式加载到环境中,灵活而且干净。

2. 实施方案

为了更好地保障安全性,本人把工作环境的 Docker 分为两个部分:

  • Docker 镜像

  • dotfile (Git 仓库)

本质上是代码和配置分离的策略,镜像可以使用阿里云提供的服务来存储,而 Dockerfile 和 dotfile 则利用 Git 的私有库做版本控制和多机器同步。

2.1 Docker 镜像

以下是本人人生中第一个 Dockerfile:

 ARG UBUNTU_VERSION=18.04
 FROM ubuntu:${UBUNTU_VERSION}
 MAINTAINER wuzhiyu "wuzhiyu@avalon-inc.com"
 
 ###################### SYSTEM SETTINGS ######################
 
 ARG USER_NAME=<account name>
 ARG GROUP_NAME=sudo
 ARG PASSWORD=<password>
 ARG HOME=/home/${USER_NAME}
 ARG HOST_NAME=ubuntu-docker
 
 # 1. update chinese software repository
 RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak
 COPY 18.04.aliyun.sources.list /etc/apt/sources.list
 
 # 2. install necessary software
 ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update && apt-get install -y sudo man curl inetutils-ping git tmux vim autojump python-pip zsh oathtool expect && apt-get clean && pip install mycli virtualenv virtualenvwrapper
 RUN apt-get update && apt-get install -y locales && apt-get clean && locale-gen en_US.UTF-8
 
 # 3. (1)create a user account without password (2) grant sudo permission by adding account to sudo group (3) set password for the account
 
 RUN adduser --home ${HOME} --ingroup ${GROUP_NAME} --shell /bin/bash --disabled-password --gecos "" ${USER_NAME} && echo "${USER_NAME}:${PASSWORD}" | chpasswd
 RUN chown -R ${USER_NAME}:${GROUP_NAME} ${HOME}
 
 ###################### USER SETTINGS ######################
 
 # set default user and work dir
 USER ${USER_NAME}
 WORKDIR ${HOME}
 ENV USER ${USER_NAME}
 
 # install oh-my-zsh
 RUN export RUNZSH=yes && sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"
 
 # config-files -v when docker run
 
 # CMD
 CMD /usr/bin/zsh

这个 Dockerfile 干的事情非常简单,简单来说就是:

  1. 指定基础镜像。

  2. 更新 Ubuntu 的软件源。国情在此,用阿里云的替换。

  3. 使用非交互模式安装必要的软件。

  4. 创建用户账户并授予 sudo 的权限。

  5. 切换到用户账户和家目录。

  6. 安装 oh-my-zsh

  7. 指定默认进入 zsh

构建命令为:

 docker build -t workspace:latest ubuntu

其中 ubuntu 是保存 Dockerfile 和其他文件的上下文目录。

看的出来镜像本身是比较简单的,主要是准备必要的软件和运行账户,下面来介绍一下 dotfile。

2.2 dotfile

dotfile 项目包括不限于以下内容:

  • .zshrc

  • .vimrc

  • .ssh/config

  • 公钥私钥

  • 业务脚本

2.2.1 .zshrc

这个文件是 zshell 启动时默认加载的文件,一般也是我们所有环境配置的公共部分。我们可以通过一些命令来判断加载特定的 profile。

比如:

 # 为 true 表示为 Mac 环境
 [[ $(uname) = "Darwin" ]]
 
 # 为 true 表示为 Docker 环境
 [[ -f /.dockerenv ]]

如果为 docker 环境,我们可以 source 包含业务信息的脚本(比如自动进行两步校验的登录脚本、包含数据库连接串信息的脚本等等),也可以进行环境配置文件的自动更新。

2.2.2 .vimrc

Vim 的配置文件,此处不表。

2.2.3 .ssh/config

常见来说两种使用功能:

  • 自动指定跳板机或端口信息

  • 对应特定应用使用特定私钥

首先一般公司内部连接线上服务器都要求通过指定的跳板机和特定端口(非默认 22)来连接,每次指定自然非常繁琐,通过 .ssh/config 文件则可以大大减轻负担。

 Host *.dev
     HostName %h.xxx.com
     User wuzhiyu
     Port 10086
     ProxyJump <gateway>
     IdentityFile <path_to_private_key>

一般来说,如果我们要连接 master01.dev.xxx.com 这台机器一般要先跳到跳板机,然后使用如下命令:

 ssh -i <path_to_private_key> wuzhiyu@master01.dev.xxx.com -p 10086

但是有了上述配置,我们可以写作:

 ssh master01.dev

跳板机、端口、机器后缀、私钥位置自动带上了。

2.2.4 公钥私钥

接上文,对于不同的版本控制系统使用不同的密钥是很常见的一种场景。比如公司内部用一套密钥,个人 GitHub 账户使用另外一套,把公司的密钥放在 dotfile 私有库进行同步便于配置的保存与迁移。

2.2.5 业务脚本

比如数据库连接串的别称、自动登录脚本等,不再赘述。

3. 实践使用

Docker 运行的时候可以使用如下命令进行启动:

 docker run -it --rm -h docker --network host -v <path_to_dotfile_dir>:/home/wuzhiyu/.config-files:ro -v <path_to_zshrc_file>:/home/wuzhiyu/.zshrc:ro --name workspace workspace:latest /usr/bin/zsh

这里有几点要注意:

  1. 网络模式使用了 -—network host 模式是为了使用宿主机的 VPN 网络。

  2. 加载的时候直接挂在了 .zshrc 文件,这样容器运行 zshell 的时候就可以直接加载初始化的脚本。

  3. 默认指定一下 HOST 名称否则部分软件运行会报错。

即席查询平台的设计与实现

1. 即席查询平台简介

  • 即席查询平台是数据平台针对即席查询(Ad-Hoc)场景推出的一个解决方案。

  • 用户侧提供 SQL 的查询、结果数据的生命周期管理。

  • 运维测统一表权限、Hadoop 组账号、Yarn 队列等。

2. ZUE 的定位与同类产品的比较

离线数据开发平台的技术演进:

  • 石器时代:Hive 客户端直连

  • 青铜时代:beeline 客户端 + Hive Server

  • 英雄时代:HUE + Hive Server

  • 军团时代:ZUE + Moses + Hive Server

开发工具几个阶段的比较:

Hive Cli Beeline HUE ZUE
交互方式 终端 终端 WEB WEB
SQL 支持 全类型 全类型 全类型 部分语句
表权限控制 table.in 元数据与权限
组账号管理 支持 支持
异步查询 不支持 不支持 不支持 支持
查询结果缓存 不支持 不支持 弱支持 支持
资源隔离与限制 无限制 无限制 无限制 有限制

2.1 SQL 的支持

  • Hive Cli、Beeline、HUE 是全功能的客户端工具,提供了完整的 Hive Query 语法的支持以及 HDFS 的管理命令

  • ZUE 限制了可以执行的 SQL 子句类型,把 ZUE 定位为 DML 的平台,将 DDL 划分给元数据管理系统负责。

  • ZUE 仅支持 SELECTCREATE TABLE ASEXPLAIN 等少数语句,并且禁止通过 ZUE 向线上生产表写入数据。

  • 元数据针对 DDL 提供了更加丰富的业务规范与控制,如业务划分、层级划分、表权限控制、变更历史、Location 限制等等。

2.2 表权限控制

  • Hive Cli 和 Beeline 都不具备表权限的功能。

  • HUE 本身不带读写权限的控制,table.in 作为一种 Hack 的方案先天不足(黑名单)。

  • ZUE 用词法解析获取读写的表,结合元数据做权限的控制。

  • 元数据提供了完整的权限申请工作流。

2.3 组账号管理

  • Hive Cli 和 Beeline 没有统一的组账号管理方式,默认以个人账号提交。

  • HUE 的组账号与个人账号等价,无法灵活的切换组账号。

  • Hive Cli、Beeline 和 HUE 大量个人账号的存在导致许多数据出现 owner 的权限问题。

  • ZUE 收敛组账号,从属于业务的数据由统一的组账号进行读写,避免权限问题的发生。

2.4 异步查询

  • Hive Cli、Beeline 和 HUE 的查询操作都是同步,一个客户端会话同一时间只能进行一个查询。关闭客户端或者会话都会导致查询被取消。

  • ZUE 采取异步查询的方式,在同一个窗口可以提交多个查询,多个查询可以并发运行。

2.5 查询结果缓存

  • Hive Cli 和 Beeline 只能手动将查询结果导出保存,否则同一条 SQL 必须重跑才能看到结果。

  • HUE 的查询结果只能在会话过程中查看,一旦页面关闭结果也就丢失了。

  • ZUE 的查询结果缓存 24 小时,重复查看和下载不需要耗费新的计算资源。

2.6 资源隔离与限制

  • Hive Cli、Beeline 和 HUE 无法对用户的查询进行队列的限制,默认提交至 default 队列。

  • Hive Cli、Beeline 和 HUE 运行时可以任意指定队列,无法针对业务线进行资源隔离和计费。

  • ZUE 针对业务线进行队列的限定,保证业务线之间资源的隔离与安全。

3. 功能与架构设计

3.1 HUE 的功能架构缺陷

  • HUE 虽然支持了很多功能,但是本质和 Beeline 并无不同,都是 Thrfit 接口中会话的一种可视化呈现。

  • Thrfit 的会话存在于 HiveServer 中,因此用户的请求必须通过一致性哈希路由到同一台服务器。缺陷在于:1)如果前端请求的路由策略不正确,请求到了其他服务器则该会话的上下文丢失(SET xxx)并且该会话下进行中的查询终止;2)如果某台服务器上 HUE 重启,意味着该服务器上的 Thrfit 会话终结,该服务器上的查询全部自动终止。

  • HUE 的水平扩展实质只能是多个单机节点对总体查询和负载进行分片,单机长时间保持状态。

  • HUE 查询请求量提升时更容易出现线程问题,导致整体不可使用。

  • HUE 虽然界面干净、交互友好,但是本质是适合小团队使用的单机系统。

3.2 ZUE 的整体设计架构

  • 将有状态与无状态的部分分离。无状态接口以容器方式部署,可伸缩性好,迭代升级易维护。长时间保持状态与 HiveServer 保持连接的部分抽象出查询中心模块,部署在物理机上。

  • Moses 查询中心通过暴露 RESTFul 的接口提供异步查询的能力,查询完成之后回调请求方的接口。

  • Moses 查询中心每个结点对等,通过持久化与 HiveServer 的会话信息实现服务重启过程中会话不丢失,不影响用户查询。

  • 使用 HDFS 和 Redis 来缓存查询的结果,缓存生命周期结束后自动回收存储空间。

4. 实现方案一些细节

4.1 语法限制与表权限

  • 对 SQL 进行词法解析,得到抽象语法树。

  • 对抽象语法树进行后续遍历获得语句的类型以及读写的表。

4.1.1 词法分析实例

举例:

 insert overwrite table tb.tb_2 select * from tb.tb_1;

解析成抽象语法树,采用后续遍历进行打印可以得到结果:

 nil
    TOK_QUERY
       TOK_FROM
          TOK_TABREF
             TOK_TABNAME
                tb
                tb_1
       TOK_INSERT
          TOK_DESTINATION
             TOK_TAB
                TOK_TABNAME
                   tb
                   tb_2
          TOK_SELECT
             TOK_SELEXPR
                TOK_ALLCOLREF
    <EOF>

几种 token 代表的含义:

  • TOK_TAB:写入目标表 tb.tb_2

  • TOK_TABREF:查询数据来源表 tb.tb_1

  • TOK_INSERT:语句的类型。

4.1.2 其他工具与缺陷

Hive 自己提供了一个血缘解析的工具:org.apache.hadoop.hive.ql.tools.LineageInfo

缺陷:

  1. 缺少上下文的支持,比如上文使用 USE <db> 语句,那么解析出来的输入表只包含表名,缺少库名。

  2. 反引号转义符需要另行清理。

  3. 对于 CTE 语句中的别名无法处理。

4.2 异步查询

  • 与 HUE 的区别:将用户会话与 Thrift 会话解耦,WEB 交互无状态,封装 Thrfit 会话。

  • 从 JDBC 获得的启示:底层原理同 Beeline 一致,对 Thrift 进行封装,通过覆盖特定的会话配置支持会话恢复重连,查询中心作为一个查询中间件可重启升级不影响用户查询。

     config.put("hive.server2.session.check.interval", "1h");
     config.put("hive.server2.close.session.on.disconnect", "false");
     config.put("hive.server2.idle.session.timeout", "24h");
     config.put("hive.server2.idle.operation.timeout", "24h");
     private synchronized TOperationHandle submitQuery(String sql) throws TException {
         log.info("Start to submit sql of task {} with content:\n{}", this.taskMeta.getTaskId(), sql);
         TExecuteStatementReq execReq = new TExecuteStatementReq(this.sessionHandle, sql);
         execReq.setRunAsync(true);
 
         TExecuteStatementResp execResp = this.client.ExecuteStatement(execReq);
         log.info("execResp = " + execResp);
         this.checkStatus(execResp.getStatus());
 
         return execResp.getOperationHandle();
     }

4.3 结果缓存

结果缓存分为三种:

  1. 较复杂 SQL 或者大数据量的查询结果通过改写 SQL 将结果以 Avro 的形式存储在 HDFS 上,定时回收存储空间。

  2. 简单 SQL 如单表查询直接将结果缓存于 Redis 中,设置缓存的过期时间。

  3. 使用 CTAS 创建的临时表,定时从临时库中清除。

5. 本季度的迭代方向

  • 更友好的故障排查(Yarn Application 日志)与异常诊断。

  • 更多元化的大数据计算引擎的集成(如 Presto)。

6. FAQ

  1. code 2 如何排查

  2. Method Not Found

  3. job counter 不准

  4. read timeout

腌笃鲜

  • 配料表:

    1. 带皮竹笋 550 g

    2. 火腿片 100 g

    3. 肋排 400 g

    4. 鲜香菇若干

    5. 百页结 200 g

    6. 新鲜竹荪 100 g

    7. 姜片、冰糖若干

  • 注意点:

    1. 竹笋去皮滚刀切块,煮一锅沸水加盐飞水去涩味

    2. 肋排冷水入锅加适量料酒飞水

    3. 煮一锅开水放入肋排、火腿、若干姜片炖煮半小时

    4. 放入竹笋、香菇、百页结、冰糖炖煮 20 分钟

    5. 放入竹荪炖煮 10 分钟

  • 经验总结:

    1. 火腿片的咸度已经足够,基本不需要再加盐

    2. 竹荪加入前首先用水泡 10 分钟

    3. 火腿片和排骨炖的时候注意水不要干

Spring 与 Auto-Configuration

1. 简述

最近在捣鼓一个类似 Spring Boot starter 的小项目:把常见的 Web 开发所使用的组件或者技术方案封装在一起,通过一个 Maven 引用快速地将项目搭建起来。

2. 遇到的问题

遇到了一个问题:我在被引用项目中用 @Configuration 注解标注了一个类,在开发项目中这个类却没有被实例化。

该类代码如下所示:

  package com.avaloninc.web.aop.config;
  
  import com.avaloninc.web.aop.filter.RequestBodyWrapperFilter;
  import com.avaloninc.web.aop.interceptor.RequestAuditInterceptor;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.beans.factory.annotation.Value;
  import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;
  import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
  import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
  
  /**
   * @Author: wuzhiyu.
   * @Date: 2019-02-22 15:15:18.
   * @Description:
   */
  @Configuration
  @ConditionalOnProperty(value = "log.request.audit.enable", havingValue = "true")
  public class RequestAuditConfiguration extends WebMvcConfigurerAdapter {
  
    private final String   logPartSeparator;
    private final String[] uriWhiteList;
  
    @Autowired
    public RequestAuditConfiguration(@Value("${log.request.audit.separator:'||'}") String logPartSeparator,
                                     @Value("${log.request.audit.whitelist:''}") String[] uriWhiteList) {
      this.logPartSeparator = logPartSeparator;
      this.uriWhiteList = uriWhiteList;
    }
  
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(new RequestAuditInterceptor(logPartSeparator, uriWhiteList));
    }
  
    @Bean
    public RequestBodyWrapperFilter getRequestBodyWrapperFilter() {
      return new RequestBodyWrapperFilter();
    }
  }

3. 解决方案

定下心来想了一下,被引用项目的配置类全名为:com.avaloninc.web.aop.config.RequestAuditConfiguration ,而开发项目中以 @SpringBootApplication 修饰的启动类全名为 com.avaloninc.web.demo.Main

第一个想法是:会不会是因为包名的问题导致 Spring 没有去扫描 com.avaloninc.web.aop 包下面的 Bean 呢?

尝试了解决办法:在启动类上加上注解 @ComponentScan("com.avaloninc"),果然好使!

但是转念一想,Spring Boot 的各种 starter 中定义的 Bean 也不会和我们自己的项目同属于一个包下面啊,为什么它们没有这个问题呢?

于是找了一篇自定义 starter 的文章1看了看了一下。大部分没什么特别,但是其中提到了一个 spring.factories 文件唤醒了我记忆中关于 Spring Boot 初始化过程的一点记忆。所以果断在被引用项目中也加了这个文件 src/main/resources/META-INF/spring.factories,内容为:

  org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.avaloninc.web.aop.config.RequestAuditConfiguration

果然好使!

当然还是参考官方文档2来的更加详细准确。

4. 参考文献

土豆炖牛肉

  • 配料表:

    1. 牛肉一斤

    2. 西红柿 4 个

    3. 土豆 2 个

    4. 白洋葱 1 个

    5. 白胡椒粉

    6. 现磨黑胡椒

    7. 冰糖

    8. 香叶若干

  • 注意点:

    1. 牛肉飞水,加入姜片

    2. 捞干净血沫,热水清洗牛肉,保留牛肉汤

    3. 西红柿切块,洋葱切块

    4. 油锅下姜片,加入牛肉和白洋葱翻炒

    5. 加入切块的西红柿(最好事先去皮)

    6. 加入牛肉高汤没过食材,放入适量冰糖

    7. 大火煮开,撒入少量白胡椒粉、现磨黑胡椒、香叶若干片

    8. 加入土豆小火炖煮

    9. 起锅前洒入适量食盐再稍加闷煮

    10. 大火收汁

  • 经验总结:

    1. 牛肉最好选用有嚼劲的部位

    2. 炖肉的过程中要时不时搅拌一下,小心收汁过头锅底糊了

  • 参考资料:

    1. 土豆炖牛肉

Java 8 DateTimeFormatter 踩坑

1. 问题描述

本月是 2019 年的第一个月,除了迎接新年之外同样也迎来了跨年带来的关于时间参数的 bug。

废话不多说,进入正题。之前写了一个任务调度系统,支持以周为粒度进行任务的调度。针对任务的每次运行都会产生一个批次号,一般以 任务名_<年份><周数> 的格式编号(比如 job_201850 ),但是在 2018 年 12 月 31 日却发现了自然周的序号发生了跳跃。

2. 分析出问题的代码

查看了一下代码,问题出在 DateTime 格式化上,原来的 DateTimeFormatter 的定义如下:

DateTimeFormatter.ofPattern("yyyyww")

预想的是 yyyy 代表年,ww 代表自然周,对于 2018 年 12 月 31 日原本预计得到的结果是 201853,但是此处 ww 得到的结果竟然是 01。

百思不得其解之下上网查了一下,发现原来错的不是 ww,而是 yyyy

一般来说 yyyy 是和自然年的月份、日期搭配的,对于和自然周的搭配是要用 YYYY 的。代码如下:

@Test
public void testForWeek() {
    DateTimeFormatter rightFormatter = DateTimeFormatter.ofPattern("YYYYww");
    DateTimeFormatter wrongFormatter = DateTimeFormatter.ofPattern("yyyyww");

    LocalDateTime now = LocalDateTime.of(2019, 1, 7, 0, 30, 0);
    System.out.println(now);
    String right = rightFormatter.format(now);
    String wrong = wrongFormatter.format(now);
    System.out.println("right = " + right);
    System.out.println("wrong = " + wrong);

    now = LocalDateTime.of(2018, 12, 31, 0, 30, 0);
    System.out.println(now);
    right = rightFormatter.format(now);
    wrong = wrongFormatter.format(now);
    System.out.println("right = " + right);
    System.out.println("wrong = " + wrong);
}

输出结果:

2019-01-07T00:30
right = 201902
wrong = 201902
2018-12-31T00:30
right = 201901
wrong = 201801

也就是说 2018 年 12 月 31 日实际上算是 2019 年的第一周,这也算刷新了我的认知!

3. 后续订正

今天又出幺蛾子了!

今天是 2019-04-28 星期日,按理来说今天应该是 2019 年的第十七周的最后一天。可是今天用 YYYYww 格式化日期得到的却是 201918,代码如下:

  @Test
  public void testForDateTimeFormatter() {

    LocalDateTime now = LocalDateTime.now();
    DateTimeFormatter week = DateTimeFormatter.ofPattern("YYYYww");

    LocalDateTime yesterday = now.plusDays(-1);
    System.out.println("yesterday = " + yesterday);
    System.out.println(yesterday.getDayOfWeek().getValue());
    System.out.println(week.format(yesterday));
    System.out.println(this.getWeekFormat(yesterday));

    System.out.println("now = " + now);
    System.out.println(now.getDayOfWeek().getValue());
    System.out.println(week.format(now));
    System.out.println(this.getWeekFormat(now));

    LocalDateTime tomorrow = now.plusDays(1);
    System.out.println("tomorrow = " + tomorrow);
    System.out.println(tomorrow.getDayOfWeek().getValue());
    System.out.println(week.format(tomorrow));
    System.out.println(this.getWeekFormat(tomorrow));
  }

  private String getWeekFormat(LocalDateTime localDateTime) {
    int year = localDateTime.get(IsoFields.WEEK_BASED_YEAR);
    int weekNum = localDateTime.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
    return year + String.format("%02d", weekNum);
  }

输出的内容为:

yesterday = 2019-04-27T10:00
6
201917
201917
now = 2019-04-28T10:00
7
201918
201917
tomorrow = 2019-04-29T10:00
1
201918
201918

也就是说按照 YYYYww 格式化的时候在星期日就会进行周号的递增!

正确格式化的的方式应该是:

  private String getWeekFormat(LocalDateTime localDateTime) {
    int year = localDateTime.get(IsoFields.WEEK_BASED_YEAR);
    int weekNum = localDateTime.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
    return year + String.format("%02d", weekNum);
  }

虎皮尖椒酿肉

  • 配料表:

    1. 尖椒 500 g(大约四个比较大的尖椒)

    2. 绞肉(七分瘦,三分肥)350 g

    3. 姜片 2 片 切末

    4. 生抽 15 mL

    5. 老抽 7 mL

    6. 鸡蛋 1 个

    7. 马铃薯淀粉 10 g

    8. 芝麻油 5 mL

    9. 葱花 一小把(缺失)

    10. 蒜 2 瓣 切碎

    11. 生抽 10 mL

    12. 醋 20 mL

    13. 糖 10 g

    14. 水 50 mL

    15. 盐 2 g

    16. 芡汁(5 g 水 + 5 g 淀粉)

  • 注意点:

    1. 材料 2~ 9 搅拌均匀拌成肉馅。

    2. 尖椒切段去籽填充肉馅。

    3. 小火将尖椒一面煎至虎皮状,翻面盖上锅盖继续煎至虎皮状。

    4. 放入蒜蓉炒香,加入 11 ~ 14,盖上锅盖中火煮开大约再煮 5 分钟。

    5. 加入芡汁收汁。

  • 经验总结:

    1. 肉馅放入的酱油量基本可以,有足够的咸味。

    2. 调味汁还是有淀粉的腥味,这个要想想办法。

    3. 调味汁有醋的味道不是很喜欢,可以尝试一下其他配方。

  • 参考资料:

    1. 虎皮尖椒酿肉

卤金钱腱

  • 配料表

    1. 7 fresh 金钱腱一个

    2. 台湾信牌卤料包一个

    3. 红茶包 1 个

    4. 黄色多晶冰糖 50 g

    5. 生抽 100 mL

    6. 老抽 50 mL

    7. 葱姜

    8. 料酒

  • 注意点

    1. 金钱腱抹上盐,放入冰箱腌制 1~2 天。

    2. 金钱腱、葱姜、料酒加入冷水飞水,大约一个小时,中间不断捞出血沫做牛肉高汤。

    3. 捞出金钱腱用沸水再次冲洗。

    4. 在铸铁锅(22cm)中放入金钱腱,加入牛肉高汤,如果水不够一次加足开水。

    5. 加入冰糖、生抽、老抽、卤料表和茶包。

    6. 大火烧开,小火炖两个小时。

    7. 将金钱腱放入密封袋,冷藏一晚。

  • 经验总结

    1. 注意红茶的茶叶包一定要结实的否则卤水只能用一次

  • 参考资料

    1. 五香卤牛肉简易做法,附一般卤水保养方法

    2. 真羡慕你们,年纪轻轻就养了一锅老卤