Spring 中 Configuration 的顺序及 ConditionalOnBean 的注意事项

微信群里有网友发了一张图并说道: 这个代码 注释掉能跑  不注释掉 不能跑啥问题 来个大佬

经过一翻讨论,我们知道了答案:

@ConditionalOnBean 依赖于 bean 初始化的顺序。

上图中,DfsProcessor.class 对应的 bean 的初始化时间晚于 MediaConfiguration 类 ,由于在初始化 MediaConfiguration 时,DfsProcessor.class 对应的 bean 还不存在,所以条件就不成立,导致 VideoProcessor 这个 bean 没被初始化。

这在 @ConditionalOnBean 的文档中也有特别提及:

The condition can only match the bean definitions that have been processed by the application context so far and, as such, it is strongly recommended to use this condition on auto-configuration classes only. If a candidate bean may be created by another auto-configuration, make sure that the one using this condition runs after.

文档给的建议是,@ConditionalOnBean 应该用在 auto-configuration 类中,并在需要时指定顺序。

为了加深理解,我们写一个可复现的 demo 项目。


demo 项目包含如下 beanconfiguration 类:

public class Tom {
    {
        System.out.println("Init: " + this.getClass());
    }
}

public class Jerry {
    {
        System.out.println("Init: " + this.getClass());
    }
}

@Configuration
public class TomConfig {

    {
        System.out.println("Init: " + this.getClass());
    }

    @Bean
    Tom tom() {
        return new Tom();
    }
}

@Configuration
public class JerryConfig {

    {
        System.out.println("Init: " + this.getClass());
    }
    
    @Bean
    Jerry jerry() {
        return new Jerry();
    }
}

为了便于观察 bean 的初始化顺序,我们在各自的构造函数中都加了一条 System.out.println() 的输出。

项目启动类是一个典型的 Spring Boot 主类:

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.classargs);
    }
}

运行该启动类,可以看到如下打印:

Init: class com.example.demo.config.JerryConfig$$EnhancerBySpringCGLIB$$41c83f4c
Init: class com.example.demo.config.TomConfig$$EnhancerBySpringCGLIB$$c009d080
Init: class com.example.demo.bean.Jerry
Init: class com.example.demo.bean.Tom

(注意 $EnhancerBySpringCGLIBxxx 部分,这是因为 Spring 会对 configuration 类进行特殊操作(比如防止 bean method 执行多次返回不同的实例),需要 cglib 库对它们进行字节码增加(enhance))

从日志可以看出,Jerry 先于 Tom 被初始化。如果(重要!),我们想让 Jerry 依赖 Tom,即,当不存在 Tom 对应的 bean 时,就不初始化 Jerry。我们可能会这么做:

// 有 Tom, 才有 Jerry
@ConditionalOnBean(Tom.class)
@Bean
Jerry jerry() 
{
    return new Jerry();
}

再次运行项目,我们看到了如下输出:

Init: class com.example.demo.config.JerryConfig$$EnhancerBySpringCGLIB$$53ca8447
Init: class com.example.demo.config.TomConfig$$EnhancerBySpringCGLIB$$d20c157b
Init: class com.example.demo.bean.Tom

什么情况?明明有 Tom 这个 beanJerry 却不见了?

如文章开头所说,@ConditionalOnBeanbean 的注册顺序有要求,由于 JerryConfig 先于 TomConfig 被发现和注册, 导致在运行 JerryConfig 内的方法时,Tom 还不存在,因此 Jerry 不再被初始化。

按照官方文本的指示,我们要做如下调整,以指定正确的加载顺序:

  1. resources/META-INF 下新建一个 spring.factories 的空白文件,并加入以下内容:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.demo.config.TomConfig,\
com.example.demo.config.JerryConfig

它用来配置项目中的 auto-configuration 类,该文件会被 SpringFactoriesLoader 类加载解析。

  1. 指定正确的顺序
// 指定当前 config 要在 TomConfig 之后执行
@AutoConfigureAfter(TomConfig.class)
@Configuration
public class JerryConfig 
{
  // 不变,省略
}

这里我们用了 AutoConfigureAfter 注解,另外还有 AutoConfigureBeforeAutoConfigureOrder 可供使用。

有了以上2步,我们再运行启动类,其日志是:

Init: class com.example.demo.config.TomConfig$$EnhancerBySpringCGLIB$$d9c9a644
Init: class com.example.demo.bean.Tom
Init: class com.example.demo.config.JerryConfig$$EnhancerBySpringCGLIB$$5b881510
Init: class com.example.demo.bean.Jerry

符合预期了。

再试试把 Tom 注释掉:

//    @Bean
//    Tom tom() {
//        return new Tom();
//    }

重新运行程序,得到的日志是:

Init: class com.example.demo.config.TomConfig$$EnhancerBySpringCGLIB$$c1e02b9
Init: class com.example.demo.config.JerryConfig$$EnhancerBySpringCGLIB$$8ddc7185

也没问题,因为没了 Tom,所以依赖它的 Jerry 也没了。


额外的话,其实我们使用 auto-configuration 类和各种Conditional 类,主要是为了满足这样的场景:

库的设计者提供一些默认的 bean,使用了该库的项目,一是会自动注册这些 bean,二是可以用自定义 bean 替代库中默认的 bean,以达到个性化使用的目的。这也是 Spring 框架的强大之处,既提供了默认的实现,又支持扩展、定制。

我们在编写自己的库时,也可借鉴 —— 不可把实现写死!


结构化的省市区数据及SQL语句

做商城系统时必不可少的一步,是找到完整的中国省市区数据,并导入到数据表中,因为收发货免不了要有地址管理。

搜索网络时,看到有这么一个项目 wecatch/china_regions[1],它通过爬取国家统计局网站上公布的中国区划数据,来生成地区 JSON、SQL 等结构化数据。

有一点不方便的是,它的省、市、区等都是放在各自的表中,没有一个统合的表(即,把各级数据放在一张表中),为此,我写了一个 CLI 对它的数据进行二次加工,以满足自己项目的需求。

如果你感兴趣, CLI 的地址是 https://github.com/Youmoo/unify-regions。

另外,整合后的数据表结构是:

CREATE TABLE `china_region` (
  `id` int(11NOT NULL AUTO_INCREMENT,
  `name` varchar(50NOT NULL,
  `no` bigint(10NOT NULL,
  `parent_no` bigint(10NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_parent_no` (`parent_no`)
);

其中:

  • name: 地区的名称
  • no: 地区的编号
  • parent_no: 父级地区的编号

有了这 3 个数据,基本能满足使用了。

如果你有额外的需求,欢迎交流。

参考资料

[1]

wecatch/china_regions: https://github.com/wecatch/china_regions


自定义 Stream Collector 的一个现实例子:从 stream 中获取多个值

我们使用 Stream 通常是为了将一个集合转换为另一个集合或另一个单值。比如,有以下动物类(Animal):

@Data
@AllArgsConstructor
public class Animal {
    private String type;
    private String name;
}

我们有一个 Animal 集合:

List<Animal> animals = new ArrayList<>();
animals.add(new Animal("Cat""Tom"));
animals.add(new Animal("Mouse""Jerry"));

如果我们想拿到该集合中所有动物的名字(获取另一个集合),可以这么写:

List<String> allNames = animals.stream()
        .map(Animal::getName)
        .collect(Collectors.toList());

如果我们想拿到最长的名字的长度(获取一个单值),可以这样写:

int maxNameLength = animals.stream()
        .map(Animal::getName)
        .mapToInt(String::length)
        .max()
        .orElse(0);

问题来了,如果想在一次操作中同时获取所有动物的名字和最长名字的长度,怎么写?

其实除了默认的 Collectors 下的工具方法,我们还可以编写自己的 Collector 实现。

比如,上面的需求可以这样实现:

  1. 先定义一个用来存储结果的类:
/**
* 用来存放名字和最大的名字的长度
*/

@Data
public class Holder {
  List<String> names = new ArrayList<>();
  int maxNameLength;
}
  1. 编写自定义的 Collector
public class NamesAndMaxNameLengthCollector implements Collector<AnimalHolderHolder{
  
  @Override
  public Supplier<Holder> supplier() {
      return Holder::new;
  }

  @Override
  public BiConsumer<Holder, Animal> accumulator() {
      return (h, a) -> {
          h.names.add(a.getName());
          h.maxNameLength = Math.max(h.maxNameLength, a.getName().length());
      };
  }

  @Override
  public BinaryOperator<Holder> combiner() {
      return (h1, h2) -> {
          Holder holder = new Holder();
          holder.getNames().addAll(h1.names);
          holder.getNames().addAll(h2.names);
          holder.maxNameLength = Math.max(h1.maxNameLength, h2.maxNameLength);
          return holder;
      };
  }

  @Override
  public Function<Holder, Holder> finisher() {
      return Function.identity();
  }

  @Override
  public Set<Characteristics> characteristics() {
      return Collections.singleton(Characteristics.IDENTITY_FINISH);
  }
}

测试一下看看结果:

Holder holder = animals.stream()
        .collect(new NamesAndMaxNameLengthCollector());

可以看到符合预期。


实际上,如果你觉得上面的 Collector 实现比较繁琐,Stream 类也提供了简化的实现方法,比如,我临时想到了下面两种:

// 方式一
Holder holder = animals.stream().collect(Holder::new, (h, a) -> {
    h.names.add(a.getName());
    h.maxNameLength = Math.max(h.maxNameLength, a.getName().length());
}, (h1, h2) -> {
    h1.names.addAll(h2.names);
    h1.maxNameLength = Math.max(h1.maxNameLength, h2.maxNameLength);
});


// 方式二
Holder holder2 = animals.stream().reduce(new Holder(), (h, a) -> {
    h.names.add(a.getName());
    h.maxNameLength = Math.max(h.maxNameLength, a.getName().length());
    return h;
}, (h1, h2) -> {
    h1.names.addAll(h2.names);
    h1.maxNameLength = Math.max(h1.maxNameLength, h2.maxNameLength);
    return h1;
});

counter(counterh2)总结

通过自定义 Collector ,我们可以根据需要拿取多个值,而不用像原先那样,每拿一个值都要重新遍历一次集合。


巧用 JavaScript Generator 函数对多个数组进行合并遍历

平时很少会去用 Generator 函数,但真要用到时,发现真是方便 :-)

回顾:关于 Generator 的一些用处,之前的文章也稍有提过:


编码过程中,偶尔会遇到这种场景:

有多个同类型的数组(假设是2个,数组 foo数组 bar),每个数组有一套自己的数据处理逻辑,然后全部数组又共享了另一部分处理逻辑,此时,如何有效的组织代码,就需要一些考验了。

TypeScript 演示:

// 两个类型相同的数组,foo 和 bar
const foo = ['hello''world'];
const bar = ['你好''世界'];

// foo 有一套自己的处理逻辑
for (const e of foo) {
    handleFoo(e);
}

// bar 也有一套自己的处理逻辑
for (const e of bar) {
    handleBar(e);
}

// foo 和 bar 还有一套共用的逻辑
// 问号部分怎么写
for (const e of ???) {
    handleAll(e);
}

常规的写法有2种:

// 使用 concat 来整合数组
for (const e of foo.concat(bar)) {
    handleAll(e);
}

// 使用 spread 来整合数组
for (const e of [...foo, ...bar]) {
    handleAll(e);
}

这样当然可以,但不可避免的,这2种方式都需要构造一个更大的数组,以包含所有的元素。

另一种方法是,利用 Generator 来整合两个(或更多个)数组:

/**
 * 该工具方法用来整合多个同类型数组
 */

functioniter<E>(...arrs: E[][]{
    for (const arr of arrs) {
        yield* arr;
    }
}

// 这样调用
for (const e of iter(foo, bar)) {
    handleAll(e);
}

更多的思考

最近写的几篇文章大都是关于怎么写出更优雅的代码,如果你也读过,应该知道我不喜欢上面那种一个一个调用函数的编码风格。实际上,我会这样写:

const handlers = [handleFoo, handleBar, handleAll];
[foo, bar, iter(foo, bar)].forEach((v, i) => {
    for (const e of v) {
        handlers[i](e);
    }
});

这样写的好处是,当有更多的数组和 handlers 时,直接往两个数组里塞即可。我称这种写法是 配置式写法,即,功能是可配置的。

如果你觉得这样写比较有意思,或者想提高编码风格水平,欢迎加我的微信号(youmoolee)进行交流。


TypeScript 给函数声明额外的属性

分享一个 TypeScript 中给函数声明类型的一个小技巧。

假设有如下函数:

function add(a: number, b: number{
    return a + b;
}

我们知道,如果要给该函数声明类型,可能会这样写:

type Add = (a: number, b: number) => number;
// 不会报错
const a: Add = add;

其实,还能像下面这样声明类型(以对象的形式):

// 以对象的形式声明函数类型
type Add2 = {
    (a: number, b: number): number;
}
// 不会报错
const b: Add2 = add;

这样做的一个好处是,如果想给函数添加额外的属性,只要这样:

type Add3 = {
    (a: number, b: number): number;
    // 函数有一个 author 属性
    author: string,
}
// 报错,下一行去掉即可
const c: Add3 = add;
// add.author = 'youmoo';

这样一来,我们既可以调用该函数,也可以访问它的属性:

是不是很方便?

谢谢阅读!


300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 300字原创 

Node.js 连接阿里云 MQTT 微消息队列

工作中需要用 MQTT 与 IOT 客户端数据互通,虽然业务代码是用 Java 开发,但平时为了测试方便,也写了一部分 Node.js 代码来交互。这里分享一下 Node.js 如何与阿里云 MQTT 微消息队列打通。

1开通并配置阿里云 MQTT

可以在 mqtt.console.aliyun.com[1] 开通 MQTT 服务。

开通后注意保存 Access KeySecret Key。在实例列表中点开实例详情,可以看到 实例id公网接入点 url

在实例详情页左侧栏中依次点开 Topic 管理Group 管理,并新建一个 TopicGroup Id

以上工作完成后,我们看 Node.js 这边要做什么。

2Node.js 连接阿里云 MQTT

首先安装客户端库:

npm install mqtt

连接前,先要了解签名鉴权的格式[2]

这里我们选择以 username + password 的形式鉴权,其中:

  1. username 的格式是:Signature|Access Key|实例 id
  2. password 的格式是:用 Secret Key 作为密钥, 对 Client Id 进行 HMAC-SHA1 加密,将得到的二进制数组进行 Base64 编码的结果,就是 password

我们先写一个 hmac-sha1.js ,它主要用来生成 password:

import crypto from 'crypto';

/**
 * 根据  secret key 和 client id 生成 password
 *
 * @see https://help.aliyun.com/document_detail/48271.html
 */

export const sha1 = (secretKey, clientId) => crypto.createHmac('sha1', secretKey)
    .update(clientId)
    .digest('base64');

再写一个 client.js 的文件用来连接:

import mqtt from 'mqtt';
import {sha1} from "./hmac-sha1";

const instanceId = '实例id';
const accessKey = 'xxx';
const secretKey = 'yyy';

const groupId = 'GID_TEST_YOUMOO';
const clientId = `${groupId}@@@youmoo`;

const client = mqtt.connect('mqtt://xxx.mqtt.aliyuncs.com:1883', {
    clientId,
    protocol"tcp",
    username`Signature|${accessKey}|${instanceId}`,
    password: sha1(secretKey, clientId),
});

client.on('connect', (e) => {
    console.log(clientId, 'connected');
});

如果运行该文件,可以看到如下输出:

说明连接成功了。

我们再实验一下 pub/sub 功能:

const parentTopic = 'Topic-Test-Youmoo';
const topic = `${parentTopic}/youmoo`;

client.on('connect', (e) => {
    console.log(clientId, 'connected');
    // 订阅
    client.subscribe(topic, err => {
        if (err) {
            throw err;
        }
        // 发布
        client.publish(topic, "Hello mqtt");
    });
});

// 监听消息
client.on('message'function (topic, message{
    console.log({topic, message: message.toString()})
});

运行该代码,可以看到:

发送 p2p 消息

有时我们想给别一台特定的设备发消息,此时可以发 p2p 消息,此类消息的 Topic 结构比较特别,其格式是:Parent Topic/p2p/Client Id

const parentTopic = 'Topic-Test-Youmoo';
const topic = `${parentTopic}/youmoo`;

const p2pTopic = `${parentTopic}/p2p/${clientId}`;

client.on('connect', (e) => {
    console.log(clientId, 'connected');

    // 订阅
    client.subscribe(topic, err => {
        if (err) {
            throw err;
        }
        // 发布普通消息
        client.publish(topic, "Hello pqtt");
        // 发布 p2p 消息
        client.publish(p2pTopic, "Hello p2p");
    });
});

// 监听消息
client.on('message'function (topic, message{
    console.log({topic, message: message.toString()})
});

运行代码,得到如下输出:

注意:p2p 消息不需要客户端主动订阅,在监听消息时,要注意 topic 的值。


好了,以上就是简单的使用入门了。

参考资料

[1]

mqtt.console.aliyun.com: https://mqtt.console.aliyun.com/

[2]

签名鉴权模式: https://help.aliyun.com/document_detail/48271.html


用 Skaffold 搭一个 Kubernetes & Spring Boot CI/CD 工作流


现在的前端开发越来越复杂,于是出现了诸如 webpack 这类的构建工具;而后端引入 Kubernetes 生态后,复杂程度也变得更甚以往了。

那么问题来了,后端好用的构建、部署工具是什么?

Skaffold 吧!

(注,这样的类比也许并不合适,但有助于理解。)


开发人员应该把主要精力放在编写代码上,而不是项目的编译、打包、部署等。引入 Docker 和 Kubernetes 后,项目的发布流程更长、更复杂,占据了开发人员不少的精力。而 Skaffold 为应对这个问题,对这些工具进行了整合并提供了更加简化的操作方式。

Skaffold 的优点

后端开发常常比较,尤其是涉及到 Kubernetes 时。但 Skaffold 却有以下优点:

  • 单纯是个客户端工具,不需要服务端做额外配合,维护成本低。
  • 易于分享:一个 Skaffold 管理的项目,只需要 git clone && skaffold run 就能跑起来。
  • 简化开发:以前,项目要部署到 k8s 上,你起码要 compile / build / push / deploy 四连,而这又涉及了不同的命令(mvn / docker / kubectl / blah blah)。有了 skaffold,你只需要 skaffold runscaffold dev。虽然这仍要依赖于上面那些命令,但整合后整个过程显然变得简单了。

1Skaffold 实战

前提条件

要完成本任务,你需要先:

  1. 安装 Skaffold
  2. 安装 Docker Desktop for Mac,并启用 Kubernetes
  3. 安装 kubectl

如果你用的是 Mac 并且安装了 brew,你只需要:

# 老版本的 brew 用这个命令安装 docker
# brew cask install docker

brew install homebrew/cask/docker
brew install kubectl skaffold

使用其它 OS 的同学请参考相关官方指导进行安装。

开始实战

Skaffold 的运行需要一个 skaffold.yaml 的文件,它可以由 skaffold init 命令生成。

skaffold init 命令需要一些特定的参数:

  1. 指定打包工具。

Skaffold 支持 Docker、Jib(--XXenableJibInit) (见我的另一篇文章: 使用 Jib 代替 Docker CLI 打包一个 Spring Boot 应用) 和 Buildpacks(--XXenableBuildpacksInit)等打包方式。

  1. 有效的 Kubernetes manifest 文件(deployment、pod、service等),放在项目根目录的 k8s 文件夹下。

我们先创建一个 deployment:

# 使用的是上篇文章里提供的镜像
kubectl create deploy jib-demo --image=registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo --dry-run -oyaml

把上述命令的输出保存到 k8s/deployment.ymal 文件下,其内容如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: jib-demo
  name: jib-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: jib-demo
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: jib-demo
    spec:
      containers:
      - image: registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo
        name: jib-demo
        resources: {}
status: {}

再创建一个 service:

kubectl expose deployment jib-demo --type=NodePort --port=8080 --dry-run -oyaml

将输出保存到 k8s/service.yaml 文件下:

apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    app: jib-demo
  name: jib-demo
spec:
  ports:
  - port: 8080
    protocol: TCP
    targetPort: 8080
  selector:
    app: jib-demo
  type: NodePort
status:
  loadBalancer: {}

可以执行 skaffold init 操作了:

# 我们使用 jib 进行打包,见上一篇文章
skaffold init --XXenableJibInit

得到如下输出:

apiVersion: skaffold/v2beta12
kind: Config
metadata:
  name: jib-demo
build:
  artifacts:
  - image: registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo
    jib:
      project: com.example:jib-demo
deploy:
  kubectl:
    manifests:
    - k8s/deployment.yaml
    - k8s/service.yaml

? Do you want to write this configuration to skaffold.yaml? (y/N) y
? Do you want to write this configuration to skaffold.yaml? Yes
Configuration skaffold.yaml was written
You can now run [skaffold build] to build the artifacts
or [skaffold run] to build and deploy
or [skaffold dev] to enter development mode, with auto-redeploy

打开项目,可以看到根目录下多了一个 skaffold.yaml 的文件。

准备工作已经完成,要启动 CI/CD 工作流,只需要运行 skaffold dev:

➜  jib-demo skaffold dev
Listing files to watch...
 - registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo
Generating tags...
 - registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo -> registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo:latest
Some taggers failed. Rerun with -vdebug for errors.
Checking cache...
 - registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo: Found Locally
Tags used in deployment:
 - registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo -> registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo:c9dbc70d39ff63ded37e5e82e5d3c99e7eaa60175247b294938f421a6cd15c3e
Starting deploy...
 - deployment.apps/jib-demo created
 - service/jib-demo created
Waiting for deployments to stabilize...
 - deployment/jib-demo is ready.
Deployments stabilized in 3.385 seconds
Press Ctrl+C to exit
Watching for changes...
[jib-demo]
[jib-demo]   .   ____          _            __ _ _
[jib-demo]  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
[jib-demo] ( ( )\___ | '
_ | '_| | '_ \/ _` | \ \ \ \
[jib-demo]  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
[jib-demo]   '  |____| .__|_| |_|_| |_\__, | / / / /
[jib-demo]  =========|_|==============|___/=/_/_/_/
[jib-demo]  :: Spring Boot ::                (v2.4.5)

可以看到跑起来了~。使用 kubectl 再检查一遍:

➜  ~ k get svc
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
jib-demo     NodePort    10.100.39.118   <none>        8080:32220/TCP   109s
kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP          48m

完美!

试试热部署,把 k8s/deployment.yamlreplicas 由 1 改为 2,会发现项目会自动重新部署,kubectl 命令也显示有2个 jib-demo 的实例:

➜  ~ k get po
NAME                       READY   STATUS    RESTARTS   AGE
jib-demo-ffdff55d6-8b2gp   1/1     Running   0          6m24s
jib-demo-ffdff55d6-lkb99   1/1     Running   0          3m6s

除了 skaffold dev 命令,还有 skaffold run ,前者会在项目文件有变动时自动打包部署,而后者只会部署一次。

当使用 ctrl c 关掉 skaffold dev 进程时,它所创建的 k8s 对象也会全部销毁。


以上仅是 Skaffold 的简单入门,更灵活、强大的配置和使用,请移步官方文档。

文章参考自 CI/CD Workflow for Spring Boot Applications on Kubernetes via Skaffold[1] 以及 Skaffold Documentation[2]

参考资料

[1]

CI/CD Workflow for Spring Boot Applications on Kubernetes via Skaffold: https://medium.com/javarevisited/skaffolding-springboot-application-dbfbc463e558

[2]

Skaffold Documentation: https://skaffold.dev/docs/

使用 Jib 代替 Docker CLI 打包一个 Spring Boot 应用

一般在将应用(本文指 Java 应用)打包为容器镜像时,打包机器上要安装 Docker 相关组件(docker cli、docker daemon等),而且还要为应用配置一个 Dockerfile 文件。如果能避免这些操作,只需要将类似功能集成到项目中,实现一键打包,岂不是更爽。

你想要的,Jib 都能满足。

我们以一个 Spring Web 项目为例,提供一个循序渐进的使用演示。


先通过 start.spring.io[1] 创建一个名为 jib-demo 的 Spring Web 项目(项目模板在这里[2])。

不借助 docker 命令行,把它打包为一个容器镜像。

场景1:

开发电脑上已经装了 Docker Desktop for Mac,想把上面的 jib-demo 项目打包到本机的 Docker daemon,用到的命令是:

mvn compile com.google.cloud.tools:jib-maven-plugin:3.0.0:dockerBuild

其中,com.google.cloud.tools:jib-maven-plugin:3.0.0 是 jib maven 插件,而 dockerBuild 是该插件提供的类似于 docker build 的一个命令。

通过 docker images 指令查看有没有构建成本,得到的输出是:

REPOSITORY   TAG              IMAGE ID       CREATED        SIZE
jib-demo     0.0.1-SNAPSHOT   0e500a1167e8   51 years ago   241MB

说明成功了。(注意这里的 CREATED 时间为 51 years ago,即1970年,这是为了构建 reproducible 镜像,而把时间定格了。)

场景2:

电脑上没装 Docker 相关组件 ,想把项目构建完成后直接推送到 Docker 远程仓库。

以阿里云提供的容器镜像服务为例(官方 docker hub 同理):

mvn compile com.google.cloud.tools:jib-maven-plugin:3.0.0:build -Dimage=registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo

显然,-Dimage 参数是用来设置镜像名称的。

我们登录阿里云看看构建结果:

嗯,也成功了。

你可能会疑惑,这个过程不用输入密码吗?

当然要,之所以没输出密码也能成功,是因为我的电脑之前通过 docker login 授权过该仓库,当运行 jib 插件时,它默认会从~/.docker/config.json 文件中读取授权信息,这从构建日期中可以看出:

[INFO] Using credentials from Docker config (/Users/youmoo/.docker/config.json) for adoptopenjdk:8-jre
[INFO] Using base image with digest: sha256:e873b3cd76edc27903a5c2d3e92dff993601367149379968ca723fe2ed820036
[INFO]
[INFO] Container entrypoint set to [java, -cp, /app/resources:/app/classes:/app/libs/*, com.example.jibdemo.JibDemoApplication]
[INFO]
[INFO] Built and pushed image as registry.cn-hangzhou.aliyuncs.com/fenke/jib-demo

场景3:

上面的命令太长,难以记住,可以直接配置在项目中吗?

当然可以。在项目下的 pom.xml 中找到 plugins 节点,加入以下子节点:

<plugin>
 <groupId>com.google.cloud.tools</groupId>
 <artifactId>jib-maven-plugin</artifactId>
 <version>3.0.0</version>
 <configuration>
  <from>
   <image>gcr.io/distroless/java:8</image>
  </from>
  <to>
   <image>registry.cn-hangzhou.aliyuncs.com/fenke/jib-demo</image>
  </to>
 </configuration>
</plugin>

配置也较易理解,from.image 配置基础镜像,to.image 用来配置结果镜像。更多的参数可自行参看文档。

如果你没有在打包机器上运行过 docker login (希望你没有,因为我们本文的目的是不安装 Docker 相关组件),你还要配置 Docker 仓库的授权密码:

编辑  ~/.m2/settings.xml,在 servers 节点下加如下配置:

<server>
  <id>registry.cn-hangzhou.aliyuncs.com</id>
  <username>my username</username>
  <password>my password</password>
</server>

其中 id 是你 Docker 远程仓库的域名、usernamepassword 不说自明。(更安全的放置密码的方法见这里 https://maven.apache.org/guides/mini/guide-encryption.html。)

然后就可以直接构建了,在项目下运行:

mvn compile jib:build

以下是部分输出结果:

[INFO]
[INFO] ----------------------------------------
[INFO] BUILD SUCCESS
[INFO] ----------------------------------------
[INFO] Total time:  4.780 s
[INFO] ----------------------------------------

在一个有权访问该仓库的机器上运行该镜像试试效果:

docker run --rm -it -p 8080:8080 registry.cn-hangzhou.aliyuncs.com/xxx/jib-demo

一个熟悉的结果出来了:


以上就是 Jib 的简单入门了。

掌握了这个,以后开发者的电脑以及发布机上,都不用装 Docker 相关组件就能直接构建 Docker 镜像了。

参考

  • Jib 官方文档[3]
  • Containerizing Spring Boot Application with Jib[4]

参考资料

[1]

start.spring.io: https://start.spring.io/

[2]

jib-demo 项目模板: https://start.spring.io/#!type=maven-project&language=java&platformVersion=2.4.5.RELEASE&packaging=jar&jvmVersion=1.8&groupId=com.example&artifactId=jib-demo&name=jib-demo&description=Demo%20project%20for%20Spring%20Boot&packageName=com.example.jib-demo&dependencies=web

[3]

Jib 官方文档: https://github.com/GoogleContainerTools/jib/tree/master/jib-maven-plugin

[4]

Containerizing Spring Boot Application with Jib: https://medium.com/javarevisited/containerizing-springboot-application-with-jib-716daa3e0850


k8s: 从 Pod chroot 进 Node

自从1年多前在阿里云上开通 k8s 并把 Spring Cloud 应用部署进去后,就很少去看 k8s 的东西了。期间应用的部署和运行一直很顺畅,直到昨天。。,有个项目发布总是失败,通过 k get no 查看时,发现有个节点的 status 显示 NotReady

想 ssh 进节点看看发生了什么,竟然忘记怎么进去。。

查了之前写的学习笔记,上面有写一种借助 Privileged Pod 的方法,这里分享一下。

整个 Pod 的配置如下:

apiVersion: v1
kind: Pod
metadata:
  name: privileged-pod
  namespace: default
spec:
  containers:
    - name: busybox
      image: busybox
      resources:
        limits:
          cpu: 200m
          memory: 100Mi
        requests:
          cpu: 100m
          memory: 50Mi
      stdin: true
      securityContext:
        privileged: true
      volumeMounts:
        - name: host-root-volume
          mountPath: /host
          # 如果你要在 Node 上进行写操作,将此置为 false
          readOnly: true
  volumes:
    - name: host-root-volume
      hostPath:
        path: /
  hostNetwork: true
  hostPID: true
  restartPolicy: Never
  nodeSelector:
    # 指定在哪个 Node 上运行该 Pod
    kubernetes.io/hostname: cn-hangzhou.172.16.181.222

要注意的地方有2点:

  1. 通过 volumeMounts 将宿主机的 / 路径挂载到 Pod 的 /host 路径下。
  2. 通过 nodeSelector 指定 Pod 运行在哪个 Node 上。

Apply 上面的 yaml 之后,执行 k exec -it privileged-pod -- chroot /host 即可进入指定的宿主机。

如果你想要每个节点上都运行该 Pod ,以方便对每个节点进行检查,将上面模板改成 DaemonSet 即可:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: privileged-daemon
spec:
  selector:
    matchLabels:
      name: privileged-pod
  template:
    metadata:
      labels:
        name: privileged-pod
    spec:
      containers:
        - name: busybox
          image: busybox
          resources:
            limits:
              cpu: 200m
              memory: 100Mi
            requests:
              cpu: 100m
              memory: 50Mi
          stdin: true
          securityContext:
            privileged: true
          volumeMounts:
            - name: host-root-volume
              mountPath: /host
              # 如果你要在 Node 上进行写操作,将此置为 false
              readOnly: true
      volumes:
        - name: host-root-volume
          hostPath:
            path: /
      hostNetwork: true
      hostPID: true
      restartPolicy: Always

参考

  • The most pointess Kubernetes command ever[1]
  • Privileged Pod – Debug kubernetes node[2]

参考资料

[1]

The most pointess Kubernetes command ever: https://raesene.github.io/blog/2019/04/01/The-most-pointless-kubernetes-command-ever/

[2]

Privileged Pod – Debug kubernetes node: https://dev.to/dannypsnl/privileged-pod-debug-kubernetes-node-5129


OTA 升级与 MQTT 通讯说明

Part1OTA 升级与 MQTT 通讯说明

因为客户端升级只针对该单个客户端,应该发送点对点消息。

点对点消息的 topic 格式(对于服务端而言):

parent_topic/p2p/gid@@@client_id

着色部分需要替换为实际的值。两端对接时,它们的值分别是:

  • 开发、测试环境

    1. parent_topic: Topic-Test-Youmoo
    2. gid: GID_TEST_YOUMOO
    3. client_id: 取决于客户端
  • 生产环境

    1. parent_topic: Topic-Prod
    2. gid: GID_TEST_YOUMOO
    3. client_id: 取决于客户端

注意,根据阿里云文档,客户端不用主动订阅该 topic,用的库会自动订阅。

1消息体说明

  1. 设备端推送版本号时

    • Topic: parent_topic/version
    • 消息体格式:
      {
        // 产品一级分类
        "cat1""XXXXXXX",
        // 产品二级分类
        "cat2""XXXXXXX",
        // 产品代号
        "product""heycode-mini",
        // 设备id
        "id""XXX",
        // 当前版本号
        "version""XXX",
        // 动作,版本升级
        "action""UPGRADE"
      }

该消息在设备开机时会发到服务端,服务端据此推送最新的版本号到设备端。

  1. 服务端推送版本号时

    • Topic: 上面的点对点消息

    • 消息体格式

      {
        // 产品一级分类
        "cat1""XXXXXXX",
        // 产品二级分类
        "cat2""XXXXXXX",
        // 产品代号
        "product""heycode-mini",
        // 设备id
        "id""XXX",
        // 当前版本号
        "version""XXX",
        // 最新的版本号
        "newVersion""YYY",
        // 固件下载地址
        "url""https://example.com",
        // 固件 md5
        "md5""ttt",
        "action""UPGRADE"
      }

注意,当服务端发现设备端是最新版本时,将不再推送更新信息。

  1. 设备端推送更新进度

    其中 step取值:

    • Topic: parent_topic/version

    • 消息体格式:

       {
         // 产品一级分类
         "cat1""XXXXXXX",
         // 产品二级分类
         "cat2""XXXXXXX",
         // 产品代号
         "product""heycode-mini",
         // 设备id
         "id""XXX",
         // 升级中的版本号
         "version""XXX",
         // 动作,版本升级
         "action""PROGRESS",
         // OTA 升级进度消息
         "step""",
         // 进度描述
         "desc"""
       }
    1. -1: 升级失败
    2. -2: 下载失败
    3. -3: 校验失败
    4. -4: 烧写失败
    5. 0: 升级成功


重构你的代码风格 —— 从帮一个朋友改进代码说起

今天根据具体实例谈一个代码风格的问题。


在一个群里,朋友 A 发来一段代码:

public void convertData(Long companyId) {
    try {
        convertOperator(companyId);
        convertDict(companyId);
        converSupplier(companyId);
        converCustomer(companyId);
        convertEnum(companyId);
        convertMenu(companyId, LanguageEnum.defaultValue());
    } catch (Exception e) {
        log.error("数据label转换异常", e);
    } finally {
        clearTheadLocal();
    }
}

他问,如果一个方法报错,不影响后面的方法执行,有什么好办法吗

朋友 B 看到后说,每个方法里面单独 try catch

我看到后回了一句 你们函数式思维有待加强

朋友 A 回复朋友 B 说,我的解决方案和你一样,但是我不想写这么挫的代码,就过来问问你们

接着我发了一段下面的代码到群里:

List<Consumer<Long>> consumers = Arrays.asList(
        this::convertOperator,
        this::convertDict,
        // 自己补后面的,

        id -> this.convertMenu(id, LanguageEnum.defaultValue())
);

boolean result = consumers.stream().reduce(true, (a, b) -> {
    try {
        b.accept(companyId);
        return a;
    } catch (Exception e) {
        return false;
    }
}, Boolean::logicalAnd);

if (!result) {
    log.error("数据label转换异常", e);
}
clearTheadLocal();

如果你能看明白并且习惯于这种编码风格就不用往下读了。下面我会再唠叨几句。


1关于函数式风格

我们写代码的目的通常是为了处理数据,而编码的风格一般是针对数据,写一些处理数据的函数,然后将数据作为参数依次调用这些函数

这种编码风格的重心是 面向数据,函数只是处理数据的工具。

而我上面说的 函数式编程,它的重心是 面向函数,数据只是函数运行时依赖的参数。

这种重心的转变会造成完全迥异的编码风格。

函数式编程的一个特点,是把一个需求分成若干小的函数块。函数执行完,需求也就完成了。

切记,它的重点是整个过程是面向函数的。

结合上面朋友问的问题来说,对于数据 companyId,它要执行很多的 convertXX() 函数。单个函数的失败不会影响后续函数的执行,当所有函数执行结束后,如果有失败的,则打印一条日志,最终做一些清理工作。

以上,我们可以做如下封装:

  1. 一组 convertXX() 函数。每个函数都可能抛异常,为了不影响后续函数的执行,我们将函数的成功执行返回 true,失败返回 false
  2. 将这组函数的最终执行结果进行 逻辑与 操作,判断是否全部成功,如果不是,则打印日志。
  3. 做一些清理工作。

最终的实现:

我们可以把这组 convertXX() 函数打包成一个 Stream 流,依次执行它们并将执行结果通过 reduce()  操作转换成最终的值,最后再做日志和清理工作。

根据以上分析,我们甚至能给出更加函数式的代码(相对于上面发到群里的而言。同时为了方便解释,做了特殊的格式化):

public boolean convertData(Long companyId) {

    return Stream.<Consumer<Long>>of( // 一组函数
            this::convertOperator,
            this::convertDict,
            // 自己补后面的,

            // 把参数的函数转换成单参数的
            id -> this.convertMenu(id, LanguageEnum.defaultValue())
    ).collect(collectingAndThen(
            reducing(
                    true,
                    (c) -> { // 依次执行单个函数,成功返回 true;失败 false
                        try {
                            c.accept(companyId);
                            return true;
                        } catch (Exception e) {
                            // todo log e
                            return false;
                        }
                    },
                    Boolean::logicalAnd // 汇总所有函数的执行结果
            ),
            r -> { // 所有函数执行完成后,执行日志和清理
                
                if (!r) {
                    log.error("数据label转换异常");
                }
                clearTheadLocal();
                return r;
            }));
}

其实,类似的编码风格我在之前的文章中也有提到:编码技巧:pipe 思想

怎么样,有收获吗?


TypeScript 中的 Existential Types

这里要明确两点:

  1. TypeScript 并不(直接)支持 Existential Types。

  2. 什么是 Existential Types?

    这涉及到(我不懂的)高阶数学和逻辑推导,所以我只说一个大概的理解:Existential Types 就是你知道一个参数是泛型参数,但又不知道具体的泛型类型时要用的类型。

下面的内容整合自一篇外文博客[1]和 StackOverflow 网站的问答[2]


假如你有如下接口和实现:

interface Property<T> {
    pget: () => T;
    pset: (value: T) => void;
}

class NumberProperty implements Property<number> {
    constructor(private value: number) {
    }

    pget() { return this.value }
    pset(value: number) { this.value = value }
}

class DateProperty implements Property<Date> {
    constructor(private value: Date) {
    }

    pget() { return this.value }
    pset(value: Date) { this.value = value }
}

现在你要维护一组 Properties,你不在乎单个 Property 的类型是什么,只要它是有效的 Property 就行,你要怎么给它声明类型?

let properties: Property<???>[];

你当然可以使用 anyunknow。但使用 unknow 意味着后续调用时要进行类型转换操作,而你不一定知道要转换成哪种具体的类型;使用 any 就失去了 TypeScript 本身的意义了。

我们想要的效果是,对于数组变量 properties 内的每个元素,它有一个泛型类型 T,虽然我们不知 T 具体是什么,但它是 存在

TypeScript 官方仓库中,有人提议用 Property<*> 这种语法来表达上述含义,但至今没被采纳和实现。

作者提到了一种利用 continuations 来实现它的方式。

/**
 * 注意这里比较绕:
 * 
 * 1. PropertyCont 是一个函数类型;
 * 2. 该函数的参数是一个回调函数,回调函数的入参是 Property<T> 类型,回调函数的返回类型是 R;
 * 3. PropertyCont 函数的返回类型是其入参(回调函数)的返回类型
 */

type PropertyCont = <R>(cont: <T>(prop: Property<T>) => R) => R;

/**
 * 注意这里:
 * 
 * 1. 该函数将 Property<T> 对象转换为 PropertyCont;
 * 2. return 后的语句其实是就是返回一个 PropertyCont 对象(一个函数);
 * 3. return 后的语句在 ts v4.2.3 中编译不通过,要改成我注释掉的 return 语句
 */

function makePropCont<T>(property: Property<T>): PropertyCont {

    return <R>(cont: <T>(prop: Property<T>) => R) => cont(property);

    // 正确的 return 语句
    // return (cont) => cont(property);
}

let properties: PropertyCont[] = [];;

整个核心部分都在上面的注释里,一定要完整消化!接下来看看它的使用:

// ...接着上面

properties.push(
    // 没问题
    makePropCont(new NumberProperty(0)),
    // 没问题
    makePropCont(new DateProperty(new Date)),

    // 可以检查出类型不匹配
    // ERR:Type 'number' is not assignable to type 'string'.(2322)
    makePropCont({
        pget: () => 44,
        pset: (badValue: string) => { }
    })
);


// 注意这里的调用,
// 要结合上面对 PropertyCont 类型的解释来理解
properties.forEach(cont => cont(prop => {
    // TS 可以推断出 val 的类型是 `T`
    const val = prop.pget();
    prop.pset(val);
}));

有了上面的理解,我们再看 StackOverflow 上的一个问题。

有一个泛型接口如下:

interface Transform<ArgType> {
    transformer: (input: string, arg: ArgType) => string;
    arg: ArgType;
}

如果要给一个字符串应用一组 Transform,这组 Transform 的类型要如何定义?代码大概像下面这样:

function append(input: string, arg: string): string {
    return input.concat(arg);
}

function repeat(input: string, arg: number): string {
    return input.repeat(arg);
}

const transforms = [
    {
        transformer: append,
        arg: " END"
    },
    {
        transformer: repeat,
        arg: 4
    },
];

function applyTransforms(input: string, transforms \*什么类型?*\): string {
    for (const transform of transforms) {
        input = transform.transformer(input, transform.arg);
    }

    return input;
}

有了前面对 Existential Types 的解释,我们这样解决:

type TransCont = <R>(cont: <T>(tran: Transform<T>) => R) => R;

function makeTransCont<T>(tran: Transform<T>): TransCont {
    return (cont) => cont(tran);
}

function applyTransforms(input: string, transforms: TransCont[]): string {
    for (const tran of transforms) {
        tran(tr => {
            input = tr.transformer(input, tr.arg);
        });
    }
    return input;
}

// 或者这样实现:
function applyTransformsV2(input: string, transforms: TransCont[]): string {
    return transforms.reduce((p, v) => v(tr => tr.transformer(p, tr.arg)), input);
}

使用起来也并不困难:

const tr: TransCont[] = [];

tr.push(makeTransCont({
    transformer: append,
    arg: " END"
}));

tr.push(makeTransCont({
    transformer: repeat,
    arg: 4
}));

const t = applyTransforms('Hello', tr);
console.log(t);

1总结

对于实现了同一泛型接口的对象,如果要将其放在数组内使用,又要保持各自的泛型类型,Existential Types 提供了一个可行的方案。

参考资料

[1]

Existential Types in Typescript Through Continuations: https://www.jalo.website/existential-types-in-typescript-through-continuations

[2]

How do you define an array of generics in TypeScript?: https://stackoverflow.com/questions/51879601/how-do-you-define-an-array-of-generics-in-typescript


TypeScript 类型推断的一个盲点——从 v2ex 上一个问题想到的

逛 v2ex.com 时,看到这样一个帖子[1]:==>

标题: typescript 类型推导的问题

正文:

能正常推导的例子:

function createStore<
  R,
  E extends Record<string, (arg: R) => void>
>(reducers: R, effects: E
{ }

createStore(
  {
    hello() { },
  },
  {
    world(arg) {
      // 这里能自动推导 arg: { hello(): void }
      arg.hello();
    },
  },
);

我封装库需要写成以下参数形式:

function createStore<
  R,
  E extends Record<string, (arg: R) => void>
>(store: { reducers: R; effects: E }
{ }

createStore({
  reducers: {
    hello() { },
  },
  effects: {
    world(arg) {
      // 这里无法推导 TS2571: Object is of type 'unknown'.
      arg.hello();
    },
  },
});

有没有办法让下面这种 store 对象参数形式也支持类型自动推导?


我的回答

后一个 createStore 之所以报错,是因为它无法推导出 R 的实际类型,我发现这样调用就行了:

createStore({
  reducers: {
    hello() { },
  } as const// 注意这里
  effects: {
    world(arg) {
      // 可以正常推断了
      arg.hello();
    },
  },
});

我的回答只是大致解释了的原因,以及给出了一个可行的方案,至于更细节的原因,我也说不上来。

而且我还发现,还有另一种形式的调用方式:

const reducers = {
  hello() { },
};
createStore({
  reducers,
  effects: {
    world(arg) {
      arg.hello();
    },
  },
});

即,把 reducers 提取出来再赋值进去,也能运行得通。

我比较迷惑,所以去 TypeScript 官方仓库上提了一个 bug report[2]

官方给的说法解开了我头上的迷雾:

Object literal methods ({ hello() {} }) are context sensitive due to having a this type parameter, so they are deferred in inference. So, the type of R gets inferred from world(arg) { ... } instead of from reducers: { hello() {} }, and since arg lacks a type annotation, it becomes unknown.

意思是,对象字面值内的方法是上下文敏感的(由于它内部的 this 参数),因此对它们的类型推断是延后的。这导致 R 类型的推断落在了 world(arg) { ... } 上,而非 reducers: { hello() {} },又因为 arg 参数没加类型声明,自然变成了 unknown

此外,作者还提供另一个解决方式——使用箭头函数(以摆脱 this 的影响):

createStore({
  reducers: {
    hello: () => { },
  },
  effects: {
    world(arg) {
      arg.hello();
    },
  },
});

你明白了吗?

1总结

对于泛型参数的推断,在受到对象字面值内的方法影响时,可以尝试使用箭头函数、as const声明或单独提取变量来避免。


思考:下面2种方式是对是错?

// 方式1
const f = function ({ };
createStore({
  reducers: {
    hello: f,
  },
  effects: {
    world(arg) {
      arg.hello();
    },
  },
});

// 方式2
createStore({
  reducers: {
    hello: (function ({ }).bind(null),
  },
  effects: {
    world(arg) {
      arg.hello();
    },
  },
});

参考资料

[1]

typescript 类型推导的问题: https://www.v2ex.com/t/744162

[2]

Object literal method not working as inference source: https://github.com/microsoft/TypeScript/issues/43704


不让定时器影响Node.js进程的退出

如果有一个 a.js ,它的文件内容如下:

console.log(1);

当使用 node a.js 运行时,它会打印 1 并退出进程。这是因为当 evnetloop 任务队列中没有更多的任务时,就会自动罢工

如果我们将文件内容改为如下:

console.log(1);

const timer = setInterval(() => {
    console.log(new Date"=> I'm alive!");
}, 1000);

运行它,进程就会保持运行,并一直有日志打印出来。

很好理解,这是因为 setInterval() 会一直往任务队列里加任务。

但有一些情况,我们希望在做主工作 A 时,让定时任务做一些非必要的辅助工作 B,当 A 完成后,整个进程自动退出,不用去考虑 B。即,忽略某些定时器对进程退出的阻碍。

怎么做呢?

其实 setInterval() 返回的是一个 Timeout 对象(setTimeout也是),该对象有一个 unref() 方法就是做这件事的。

所以我们可以这样写:

console.log(1);

const timer = setInterval(() => {
    console.log(new Date"=> I'm alive!");
}, 1000);

timer.unref();

同理,你也可以用 ref() 方法恢复默认效果。


原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字 原创300字


一条新鲜的英语句子:more 修饰不定式

在 css-tricks.com 上读到一段话,感觉有必要拿出来分析一下:

In fact, Snowpack and Vite actually use esbuild under the hood for certain tasks. Our goal is more to get a better view of the landscape of developer tools that run tasks to make our jobs easier. This way, we see what options are out there and how they stack up, so we can make the best choices when we need them.(SOURCE[1])

注意蓝色的句子:Our goal is more to ...

不出意外,我们可轻易地将其理解为 我们的目标更多的是为了 xxx

有意思的地方是,more 通常用来修饰 形容词副词,如:

  1. Be more careful next time.
  2. Can you run more quickly?

而像上面那种用 more 修饰不定式的则较为少见。学以致用,我们可模仿并写出下面这样的句子:

You task is more to help your mother with the housework.(你的任务更多的是要帮你母亲分担家务)

可以认为,该句更完整的表达法是这样的:

You task is more to help your mother with the housework than (to) do something else.

即,当我们使用 more 时,常常会有一个 than 的对象 (后者可以是隐藏的)。


以上之外,还要提到一个相关的英语学习技巧:仿写句子 —— 如果你觉得某句话新鲜有意思,就仿造它的结构多写几条类似的句子。这种主动的方式,比被动的背诵更能加深印象。

之前也写过一些英语学习技巧:

参考资料

[1]

Comparing the New Generation of Build Tools: https://css-tricks.com/comparing-the-new-generation-of-build-tools/


Promise.resolve() 的魔鬼细节

如果我们有:

const foo = Promise.resolve(1);

则最终我们从 foo 中得到的是 1 这个值。

如果像下面这样,从 bar 中能得到的是什么:

const foo = Promise.resolve(1);
const bar = Promise.resolve(foo);

其实得到的也是 1MDN[1] 文档中有相关解释:

The Promise.resolve() method returns a Promise object that is resolved with a given value. If the value is a promise, that promise is returned.

注意后面那句,如果传给 resolve() 的参数是一个 promise,则直接返回那个 promise

由此,我们知道上例中 foobar 引用的其实是同一个对象:

const foo = Promise.resolve(1);
const bar = Promise.resolve(foo);

// true
console.log(foo === bar);

1有意思的地方

那么,Promise.resolve() 怎么判断传入的参数是一个 promise ?

如果你修改了 foo.constructor 属性,会发现 foo 就不再等于 bar 了(SOURCE[2]):

const foo = Promise.resolve(1);
foo.constructor = {};
const bar = Promise.resolve(foo);

// false
console.log(foo === bar);

所以我们猜测它判断参数是否是 promise 依据的是参数的 constructor 属性。

(你可能能会说为什么不用 instanceof 来判断,因为改变 constructor 属性,instanceof 仍然是返回 true,所以用它判断是不可靠的)。

另外,Object.setPrototypeOf() 也能达到修改 constructor 的效果,这就有了另一个有意思的用法:a Promise of a Promise[3]

2A promise of a promise

既然 resolve() 入参如果是 Promise 对象,会直接被返回出去。我们比较叛逆,想实现一个传入的是 Promise,resolved 的值也是 Promise,怎么搞?

先试试下面这种写法(改 construcotr属性):

const foo = Promise.resolve(1);
foo.constructor = {};

const bar = Promise.resolve(foo);

// false
console.log(foo === bar);

// 1 false
bar.then(v => console.log(v, bar === foo));

行不通,resolved 的值是 1,我们想要的是 vfoo 引用的是同一个对象(都是Promise)。

再试试这种写法:

const foo = Promise.resolve(1);
Object.setPrototypeOf(foo, null);

const bar = Promise.resolve(foo);

// false
console.log(foo === bar);

// true
bar.then(v => console.log(v === foo));

成功。

3注意

这样写代码并没有什么意思,而且真要把 vPromise 来用,还得加一句:

Object.setPrototypeOf(v, Promise.prototype);

以恢复 v 的原型链。

不过本文的主要用意,是让你了解 Promise.resolve()方法以及关注对象的 constructor 属性在某些方面的用途。

要留心的是,Promise.reject() 并不遵从相同的范式。

4总结

对于 Promise.resolve(),如果传参是一个 promise,则会直接返回该 promise;但如果修改了参数的 constructor 属性,它会返回一个新的 promise(只是引用不同,最终 resolved 值是相同的,可能认为该参数变成了一个 thenable);如果修改了参数的 prototype,则 resolved 值直接变为参数本身(如同传的不是 promise 一样)。

参考资料

[1]

MDN: Promise.resolve(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve

[2]

Promise.resolve() makes decisions based on the .constructor property of its argument: https://twitter.com/addaleax/status/1382039026883047428

[3]

A Promise of a Promise: https://twitter.com/JFieldEffectT/status/1382077150002569218


process.nextTick()的两个用途

题外话,有网友问为什么公众号叫【背井】?这里解释一下:

背井】 的 【】 读第一声,指背着井,和【坐井观天】里的【】是同一口井,作为对自己的警醒。

有些人觉得自己经历了一些事,不会再是 井底之蛙 了,其实他们不过是把井从 脚底 换到 背上 了而已。人还是那个人。

回到正题。


写的项目也不少了,但回想起来,实际工作中并没有用过 process.nextTick() 方法(不排除第三方库会用到)。好奇的我于是从网上查了查到底何时要用它,下面是我找到的 2 个相对满意的答案:

1EventEmiter 子类的构造方法中使用

比如,当我们需要在 EventEmiter 实例化后立即发送一个事件(比如 init),代码可以这样写:

class MyEmitter extends EventEmitter {
    constructor() {
        super();
        // 实例化之后立即发送 init 事件
        process.nextTick(() => {
            this.emit('init');
        });
    }
}

// 使用
const myEmitter = new MyEmitter();

myEmitter.once('init', () => {
    console.log('init done!');
});

如果不使用 process.nextTick() 而是直接在构造器中调用 this.emit(),后面的 once() 调用并不能捕获到构造器中的 init 事件,因为在实例构造过程中,once 监听器还没来得及注册。

有疑问的童鞋可以试运行下面的代码:

class MyEmitter extends EventEmitter {
    constructor() {
        super();
        // 实例化之后立即发送 init 事件
        this.emit('init');
    }
}

// 使用
const myEmitter = new MyEmitter();

// 在注册该监听器之前,上面的 emit 就已经发送完成了
// 所以监听不到构造器方法中产生的事件
myEmitter.once('init', () => {
    console.log('init done!');
});

2保持函数的异步性

一个好的实践是,一个方法要么是同步的,要么是异步的,混用常常会产生意想不到的效果。

比如下面这个方法:

function maybeSync(arg, cb{
    if (arg) {
        // 同步调用
        cb();
        return;
    }

    // 异步调用
    fs.stat('file', cb);
}

该方法既可能是同步的也可能是异步的(取决于参数 arg 的值),当使用时:

const maybeTrue = Math.random() > 0.5;

maybeSync(maybeTrue, () => {
  foo();
});

bar();

你无法判断 foo()bar() 谁先执行,让代码难以推理(reason about)。

一个解决办法是,通过 process.nextTick()maybeSync() 完全变成异步的:

function definitelyAsync(arg, cb) {
  if (arg) {
    process.nextTick(cb);
    return;
  }

  fs.stat('file', cb);
}

这样一来,我们可以肯定地说,bar() 是先于 foo() 执行的。

3另一个疑问

有童鞋可能要问 process.nextTick() 到底什么时候执行?

简单点,可以这么说:当所有同步方法执行完后,最先被执行的就是 process.nextTick() 里的回调方法。基于此点,process.nextTick() 还有一个坑(gotcha):如果你递归调用 process.nextTick() ,eventloop 就再也没机会执行其它的方法了。


给小朋友使用的可视化编程语言及配套练习网站

如果产品和我说『给我画一个正方形』,我很可能回怼他/她说「没法画,你没说画多大的」。


现在少儿编程挺火的,我觉得少儿学编程,主要是训练「编程思维」。

我眼里的编程思维包含下面3点(我临时想的 😂 ):

  1. 追求问题的规范化。问题不能模棱两可,定义要清晰。(你也不想你理解的和他想表达的不一样,永远不在一个频道上。)
  2. 分而治之的思想。问题确定后,要会把大问题切分成小问题;解决时,也要学会分步骤地进行处理。
  3. 日常工作的自动化。程序很多时候就是为了代替人工,解决一些重复枯燥的问题。学会编程后,一些重复要做的事你会想方设法用程序来解决它。

同样以画一个正方形为例,知道画多大(即边长是多少)是一方面(规范),而怎么画则是另一个方面(分解),如果每天都画而且要画很多,你就会想着用程序自动处理了(自动化)。

画正方形,你可能会先定一个点,接着这个点画一条向上的直线,右转90度,再画一条直接,再右转90度,再画直线,...直到4条直线闭合,一个正方形才算完成。

这里的规范,就是「画一个连长为 X 的正方形」;而分解,则是基于正方形的特征(4条边长度相同,相邻2边垂直,4条边闭合),来一步一步地画线,自动化呢。。。你自己想。


今天推荐的语言就是训练编程思维的。语言名字叫 Shelly,配套的学习网站是 shelly.dev[1],。

以画正方形为例,如果一步一步地画,代码如下:

fw 100
right 90
fw 100
right 90
fw 100
right 90
fw 100

其中 fw前行(forward)right右转

代码要做的事是显而易见的,经过4次前行和右转 90 度,一个长度为 100 的正方形就形成了。

代码的执行结果:

仔细观察上面代码,发现出现了4次相同的 前行右转。有没有简化的书写方式呢(避免重复)?

Shelly 不仅能使用简单的 移动转向,还支持 循环控制 ,所以上面的代码可以改写如下:

repeat 4 (
    fw 100
    right 90
)

代码仍然比较直观: 将括号内的语句重复执行 4 次。

编程语言的强大还在于它支持函数,使得重复的操作可以进一步封装在一个可复用的指令中。

当你想画2个或更多个正方形时,将上面的循环语句复制粘贴总归是不好的。而使用函数,可以像下面这样写:

# 定义正方形函数
let squre = repeat 4 (
    fw 100
    right 90
)

# 画正方形
squre

# 向下面移动一点
up
bk 110
down

# 再画一个正方形
squre

它的运行效果是这样的:

上面的 squre 即是我们定义的「正方形函数」,而下面的代码则是对它的调用。(up、bk、down的作用,请感兴趣的读者自行学习)。

你可能会说,不是所有正方形都是 100 个长度,上面的函数可复用性比较差。

是的, Shelly 的设计者也想到了这点,所以它还支持给函数传递参数,比如把长度传给函数。(请自行学习)

(其实 Shelly 内置了画正方形的函数 square,这也是为什么我上面的函数名叫 squre,防止和内置的冲突了。)


介绍就说到这里,下面给一些实际使用效果。

用 Shelly 画六边形
画风车
画长廊
画螺纹

以及一个简单的圣诞树:

初看时,你可能会有畏难情绪,其实他们都是通过 移动、转向、循环一步一步完成的。有了这些基石,孩子的逻辑分解能力必会有所提高。

赶快学一学并把它教给自己的孩子吧!

参考资料

[1]

shelly.dev: https://shelly.dev/

- END -


撸一个简单的 Rate Limiter 组件

...通过重造轮子理解 Rate Limiter 的原理及作用。

写过爬虫的人都知道,如果一个网站你抓取的频率高了,请求多半会失败 (不设防的小网站除外😃 )。

这是因为触发了被爬网站的防御措施。这种防御就是 流控(Rate Limiting)

另外,一些开放平台为了保证自己服务的稳定性以及防止单个消费者的错误使用影响到其他消费者,也会对平台对外提供的开放 API 做一些限流。

比如我的公众号 API 就有下面这样的限制:

一天调用超过2000次,后续调用就会失败

通常而言,流控都是做在服务提供方这边。即,你对外提供服务,为了保证服务可用性,你要对自己加一层流控。但有时消费者自己为了高效使用被分配的调用限额(比如上图中API一天只能调用2000次),也会在自己这一方主动加入流控。

上面是流控的目的和作用,下面说说两种常见的实现方式。

固定时间窗口

假设你要为自己写的接口加入流控,要求 单个用户 每分钟 只能发来 5个请求

我们很容易想到用下面这种结构来设计,即,按分钟累计每个用户的请求次数:

其中,用户的标识可以使用其 ID 或 授权的TOKEN。如果你的系统也允许非登录人员访问,可以给非登录人员分配一个匿名的 TOKEN,无论如何,得存在辨别用户的机制。

分辨完用户,接下来就是在用户请求到来时,取出当前时间所在的分钟(0~59之间的值),然后判断该分钟下的请求记数是否超过限定的值,超过就拒绝请求,否则给请求计数加 1 并正常处理请求。

考虑到如今的系统都是分布式的(多点部署),为了保证流控状态的一致性(多个实例看到的计数是同步的),对于上图中的数据结构,用 Redis 来处理非常适合,所以我们就用它做实现的参考吧。

使用 Redis 实现流控最主要的代码:

multi
incr [user]:[minute]
expire [user]:[minute] 59
exec

其中 [user][minute] 分别是用户的标识和分钟,比如 user-1:0 表示 user-1 这个用户在某个点0分时的数据。

通过 incr 命令增加请求记数,当整个命令完成后,判断返回的记数是否大于限制的数字即可。

expire 命令在这里的作用是,每个小时都有自己的 0~59 分钟,要确保下一小时的该分钟到来时,该分钟的数据已经被重置过。

另外,上面图中 user分钟 是一对多的关系,而在具体实现时,我们把它铺展(flatten)开了,用 user+分钟 来代表用户在某一分钟下的数据,以简化实现。

你可能会想,每个用户都会在 Redis 中产生 60key(0~59对应的key),如果用户量很大,会不会增加 redis 的存储和清理负担?

。。可能会有负担吧,不过这里也提供一个优化方式。

可以对分钟取模,比如对 3 取模,这样一个用户只需要 3 个 key (即,0,1,2):

m = minute % 3
multi
incr [user]:[m]
expire [user]:[m] 59
exec

以上的实现,用的是固定时间窗口算法(Fixed Window),因为每个时间窗口(每1分钟)的时间范围是定死的,且和前后时间窗口没有交叉。但试想一下,上一分钟的后30秒和这一分钟的前30秒,其实也构成了一分钟,但它们却被分在了不同的时间窗口。

试想一下:

请求如果集中在上一分钟的后面几秒和这一分钟的前边几秒,系统在那几秒内请求会突然增加(系统负载会突然变高)。

比如一个系统本来一分钟只能撑住1万个请求,结果前1分钟只在第59秒时来了1万个请求,而后一分钟是在第1秒时来了1万个请求。

按固定时间窗口的算法,这些请求在各自的分钟内都是合法的,但这2万个请求其实是在短短的2秒内到来的,系统显然会扛不住。。。

怎么办?

滑动窗口算法

如果我们把用户每次请求发生的时间都记录在一个数组中,每次新请求到来时,先判断该数组中位于当前时间窗口内的元素有多少个,这个数量其实就是请求次数,基于它就能做限流判断了。

既要记录各个请求发生的时间,又要移除当前时间窗口之前的元素,Redis 中的 sorted set 就成了不二之选。

涉及的主要代码如下:

multi

# 移除时间窗口左侧的请求记录
zremrangebyscore [user] 0 [时间窗口开始时间]

# 查出时间窗口内总的请求次数
zcard [user]

# 将该次请求加到时间窗口内
zadd [user] [当前时间] [当前时间]

# 超过时间窗口后仍没有请求,则使数据过期
expire [user] <时间窗口长度>

exec

上面代码已经加了详细的注释。

外部拿到 zcard 的返回值后,如果其计数超过限制值,则拒绝请求。

这里还有个缺点,就是即使拒绝请求了,用户的该次请求还是被加入了当前时间窗口内,按理只有被处理过的请求才应该加进去。

一个解决办法是,使用 Lua 脚本,只有这样才能在一个事务中拿到 zcard 的值:

// 从参数中提取变量值
local user = KEYS[1] // 用户
local now = tonumber(ARGV[1]) // 当前请求时间
local window = tonumber(ARGV[2]) // 时间窗口长度
local limit = tonumber(ARGV[3]) // 限制的请求数

// 移除比 now - window 小的元素
local clearBefore = now - window
redis.call('ZREMRANGEBYSCORE', user, 0, clearBefore)

// 获取时间窗口内的请求数,即元素数量
local already_sent = redis.call('ZCARD', user)

if already_sent < limit then // 请求数小于限制请求数,记录该请求
redis.call('ZADD', user, now, now)
end

// 超过时间窗口长度没访问后,自动清理数据
redis.call('EXPIRE', user, window)

// 返回剩余可请求数据,如果返回值>=0,表示可以处理请求
return limit - already_sent

调用方式是:

eval [script] 1 [user] [当前时间(毫秒)] [时间窗口时长] [限制数量]

[script] 替换为上面的 Lua 脚本内容,其它变量也依次替换即可。

总结

通过上面2类 Rate Limiter 的实现,你不想自己编写试试吗。。

如果你感兴趣请留言说明,我用 Node.js 实现一款即时可用的。

文章参考:

  1. Basic Rate Limiting[1]
  2. Building a sliding window rate limiter with Redis[2]
  3. Implementing a sliding log rate limiter with Redis and Golang[3]

参考资料

[1]

Basic Rate Limiting: https://redislabs.com/redis-best-practices/basic-rate-limiting

[2]

Building a sliding window rate limiter with Redis: https://engagor.github.io/blog/2017/05/02/sliding-window-rate-limiter-redis/

[3]

Implementing a sliding log rate limiter with Redis and Golang: https://levelup.gitconnected.com/implementing-a-sliding-log-rate-limiter-with-redis-and-golang-79db8a297b9e

- END -


LeetCode 442:找到数组中的重复项

问题:

给定一个由整数组成的数组,其中 1 ≤ a[i] ≤ n (n 是数组长度),数组中有些元素会出现两次,而有些只出现一次。

找到这个数组中出现过两次的元素。

示例:

输入:
[4,3,2,7,8,2,3,1]

输出:
[2,3]

解析:

一个简单的解题思路是,将数组元素放在频率字典中(map),最后取出字典中频率大于1的元素即可。

TypeScript 代码:

function findDuplicates(nums: number[]): number[] {
    return nums.filter(function (this: Set<number>, v: number{
        if (this.has(v)) {
            return true;
        }
        this.add(v);
    }, new Set());
}

这里我们使用了 Set :凡是之前加过的元素都是重复的。

另外一个解答思路就比较讨巧了。

考虑到数组元素满足条件 1 ≤ a[i] ≤ n,那么 a[a[i]-1] 一定也是数组中的元素,因为 a[i]-1 下是数组所有下标的集合。

试想一下,如果我们在遍历过程中将 a[i]-1 对应的元素取反,后续遍历中如果存在 a[j] 等于 a[i],那么 a[j]-1 对应的元素一定是负数,此时 a[j] 就是那个出现了2次的元素。需要注意的是,遍历过程中后面的元素可能在之前被取反过,所以在应用 a[i]-1 时,要用 a[i] 的绝对值,即 Math.abs(a[i])-1

以数组 [2,1,1] 做推导示例:

当遍历到最后一个元素 1 时,发现之前有元素将 0 (即 1-1 ) 这个位置的元素变为负数了,所以 1 是重复的元素。

TypeScript 代码如下:

function findDuplicates(nums: number[]): number[] {
    return nums.reduce((p, v, i, a) => {
        // 当前元素的绝对值,防止之前被取反过
        const val = Math.abs(v);
        // 需要被取反的元素
        const target = val - 1;

        // 如果该元素已经被取反过,说明 val 是重复的元素,加入到返回值中
        // 否则进行取反操作
        a[target] < 0 ? p.push(val) : (a[target] = -a[target]);
        return p;
    }, [] as number[]);
}


上面两段代码中分别用到了 Array 对象 的 filterreduce 方法,在解决实际问题时,它们很有用。

- END -