开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

final关键字深入解析 final关键字特性

发表于 2018-08-31

final关键字特性

final关键字在java中使用非常广泛,可以申明成员变量、方法、类、本地变量。一旦将引用声明为final,将无法再改变这个引用。final关键字还能保证内存同步,本博客将会从final关键字的特性到从java内存层面保证同步讲解。这个内容在面试中也有可能会出现。

final使用

final变量

final变量有成员变量或者是本地变量(方法内的局部变量),在类成员中final经常和static一起使用,作为类常量使用。其中类常量必须在声明时初始化,final成员常量可以在构造函数初始化。

1
2
3
4
5
6
7
8
复制代码public class Main {
public static final int i; //报错,必须初始化 因为常量在常量池中就存在了,调用时不需要类的初始化,所以必须在声明时初始化
public static final int j;
Main() {
i = 2;
j = 3;
}
}

就如上所说的,对于类常量,JVM会缓存在常量池中,在读取该变量时不会加载这个类。

1
2
3
4
5
6
7
8
9
10
复制代码
public class Main {
public static final int i = 2;
Main() {
System.out.println("调用构造函数"); // 该方法不会调用
}
public static void main(String[] args) {
System.out.println(Main.i);
}
}

final方法

final方法表示该方法不能被子类的方法重写,将方法声明为final,在编译的时候就已经静态绑定了,不需要在运行时动态绑定。final方法调用时使用的是invokespecial指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码class PersonalLoan{
public final String getName(){
return"personal loan”;
}
}

class CheapPersonalLoan extends PersonalLoan{
@Override
public final String getName(){
return"cheap personal loan";//编译错误,无法被重载
}

public String test() {
return getName(); //可以调用,因为是public方法
}
}

final类

final类不能被继承,final类中的方法默认也会是final类型的,java中的String类和Integer类都是final类型的。

1
2
3
4
复制代码final class PersonalLoan{}

class CheapPersonalLoan extends PersonalLoan { //编译错误,无法被继承
}

final关键字的知识点

  1. final成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误。final变量一旦被初始化后不能再次赋值。
  2. 本地变量必须在声明时赋值。 因为没有初始化的过程
  3. 在匿名类中所有变量都必须是final变量。
  4. final方法不能被重写, final类不能被继承
  5. 接口中声明的所有变量本身是final的。类似于匿名类
  6. final和abstract这两个关键字是反相关的,final类就不可能是abstract的。
  7. final方法在编译阶段绑定,称为静态绑定(static binding)。
  8. 将类、方法、变量声明为final能够提高性能,这样JVM就有机会进行估计,然后优化。

final方法的好处:

  1. 提高了性能,JVM在常量池中会缓存final变量
  2. final变量在多线程中并发安全,无需额外的同步开销
  3. final方法是静态编译的,提高了调用速度
  4. final类创建的对象是只可读的,在多线程可以安全共享

从java内存模型中理解final关键字

java内存模型对final域遵守如下两个重拍序规则

  1. 初次读一个包含final域的对象的引用和随后初次写这个final域,不能重拍序。
  2. 在构造函数内对final域写入,随后将构造函数的引用赋值给一个引用变量,操作不能重排序。

以上两个规则就限制了final域的初始化必须在构造函数内,不能重拍序到构造函数之外,普通变量可以。

具体的操作是

  1. java内存模型在final域写入和构造函数返回之前,插入一个StoreStore内存屏障,静止处理器将final域重拍序到构造函数之外。
  2. java内存模型在初次读final域的对象和读对象内final域之间插入一个LoadLoad内存屏障。

new一个对象至少有以下3个步骤

  1. 在堆中申请一块内存空间
  2. 对象进行初始化
  3. 将内存空间的引用赋值给一个引用变量,可以理解为调用invokespecial指令

普通成员变量在初始化时可以重排序为1-3-2,即被重拍序到构造函数之外去了。 final变量在初始化必须为1-2-3。

读写final域重拍序规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码public class FinalExample {
int i;
final int j;
static FinalExample obj;

public void FinalExample () {
i = 1; // 1
j = 2; // 2
}

public static void writer () { //写线程A
obj = new FinalExample (); // 3
}

public static void reader () { //读线程B执行
if(obj != null) { //4
int a = object.i; //5
int b = object.j; //6
}
}
}

我们可以用happens-before来分析可见性。结果是保证a读取到的值可能为0,或者1 而b读取的值一定为2。
首先,由final的重拍序规则决定3HB2,但是3和1不存在HB关系,原因在上面说过了。 因为线程B在线程A之后执行,所以3HB4。
那么2和4的HB关系怎么确定?? final的重拍序规则规定final的赋值必须在构造函数的return之前。所以2HB4。因为在一个线程内4HB6.所以可以得出结论2HB5。则b一定能得到j的最新值。而a就不一定了,因为没有HB关系,可以读到任意值。

HB判断可见性关系真是太方便了。可以参考我的另外一个博客http://medesqure.top/2018/08/25/happen-before/

可能发生的执行时序如下所示。

final对象是引用类型

如果final域是一个引用类型,比如引用的是一个int类型的数组。对于引用类型,写final域的重拍序规则增加了如下的约束

  1. 在构造函数内对一个final引用的对象的成员域的写入和随后在构造函数外将被构造对象的引用赋值给引用变量之间不能重拍序。 即先写int[]数组的内容,再将引用抛出去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码public class FinalReferenceExample {
final int[] intArray; //final是引用类型
static FinalReferenceExample obj;

public FinalReferenceExample () { //构造函数 在构造函数中不能被重排序 final类型在声明或者在构造函数中要赋值。
intArray = new int[1]; //1
intArray[0] = 1; //2
}

public static void writerOne () { //写线程A执行
obj = new FinalReferenceExample (); //3
}

public static void writerTwo () { //写线程B执行
obj.intArray[0] = 2; //4
}

public static void reader () { //读线程C执行
if (obj != null) { //5
int temp1 = obj.intArray[0]; //6
}
}
}

JMM保证了3和2之间的有序性。同样可以使用HB原则去分析,这里就不分析了。执行顺序如下所示。

6DBA7734-EFF8-4AC2-8E3B-E1645889A109

final引用不能从构造函数“逸出”

JMM对final域的重拍序规则保证了能安全读取final域时已经在构造函数中被正确的初始化了。
但是如果在构造函数内将被构造函数的引用为其他线程可见,那么久存在对象引用在构造函数中逸出,final的可见性就不能保证。 其实理解起来很简单,就是在其他线程的角度去观察另一个线程的指令其实是重拍序的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;

public FinalReferenceEscapeExample () {
i = 1; //1写final域
obj = this; //2 this引用在此“逸出” 因为obj不是final类型的,所以不用遵守可见性 }

public static void writer() {
new FinalReferenceEscapeExample ();
}

public static void reader {
if (obj != null) { //3
int temp = obj.i; //4
}
}
}

操作1的和操作2可能被重拍序。在其他线程观察时就会访问到未被初始化的变量i,可能的执行顺序如图所示。

AAF34760-7112-463C-852F-25CB775AFD62

本文结束,欢迎阅读。
本人博客 medesqure.top/ 欢迎观看

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

前端使用puppeteer 爬虫生成《Reactjs 小书

发表于 2018-08-29

1、puppeteer 是什么?

puppeteer: Google 官方出品的 headless Chrome node 库

puppeteer github仓库

puppeteer API

官方介绍:

您可以在浏览器中手动执行的大多数操作都可以使用Puppeteer完成!

生成页面的屏幕截图和PDF。

抓取SPA并生成预渲染内容(即“SSR”)。

自动化表单提交,UI测试,键盘输入等。

创建最新的自动化测试环境。使用最新的JavaScript和浏览器功能直接在最新版本的Chrome中运行测试。

捕获时间线跟踪 您的网站,以帮助诊断性能问题。

测试Chrome扩展程序。

2、爬取网站生成PDF

2.1 安装 puppeteer

1
2
3
4
5
arduino复制代码// 安装 puppeteer
// 可能会因为网络原因安装失败,可使用淘宝镜像
// npm install -g cnpm --registry=https://registry.npm.taobao.org
npm i puppeteer
# or "yarn add puppeteer"

2.2 《React.js小书》简介

《React.js小书》简介

关于作者@胡子大哈

这是⼀本关于 React.js 的⼩书。
因为⼯作中⼀直在使⽤ React.js,也⼀直以来想总结⼀下⾃⼰关于 React.js 的⼀些
知识、经验。于是把⼀些想法慢慢整理书写下来,做成⼀本开源、免费、专业、简单
的⼊⻔级别的⼩书,提供给社区。希望能够帮助到更多 React.js 刚⼊⻔朋友。

下图是《React.js 小书》部分截图:
《React.js 小书》部分截图

2.3 一些可能会用到的 puppeteer API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
csharp复制代码// 新建 reactMiniBook.js, 运行 node reactMiniBook.js 生成pdf
const puppeteer = require('puppeteer');

(async () => {
// 启动浏览器
const browser = await puppeteer.launch({
// 无界面 默认为true,改成false,则可以看到浏览器操作,目前生成pdf只支持无界面的操作。
// headless: false,
// 开启开发者调试模式,默认false, 也就是平时F12打开的面版
// devtools: true,
});
// 打开一个标签页
const page = await browser.newPage();
// 跳转到页面 http://huziketang.mangojuice.top/books/react/
await page.goto('http://huziketang.com/books/react/', {waitUntil: 'networkidle2'});
// path 路径, format 生成pdf页面格式
await page.pdf({path: 'react.pdf', format: 'A4'});
// 关闭浏览器
await browser.close();
})();

知道这启动浏览器打开页面关闭浏览器主流程后,再来看几个API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码const args = 1;
let wh = await page.evaluate((args) => {
// args 可以这样传递给这个函数。
// 类似于 setTimeout(() => {console.log(args);}, 3000, args);
console.log('args', args); // 1
// 这里可以运行 dom操作等js
// 返回通过dom操作等获取到的数据
return {
width: 1920,
height: document.body.clientHeight,
};
}, args);
// 设置视图大小
await page.setViewport(wh);
// 等待2s
await page.waitFor(2000);
1
2
3
4
ini复制代码// 以iPhone X执行。
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone X'];
await page.emulate(iPhone);

2.4 知道了以上这些API后,就可以开始写主程序了。

简单说下:实现功能和主流程。从上面React.js小书截图来看。

1、打开浏览器,进入目录页,生成0. React 小书 目录.pdf

2、跳转到1. React.js 简介页面,获取左侧所有的导航a链接的href,标题。

3、用获取到的a链接数组进行for循环,这个循环里主要做了如下几件事:

3.1 隐藏左侧导航,便于生成pdf

3.2 给**React.js简介**等标题 加上序号,便于查看

3.3 设置docment.title 加上序号, 便于在页眉中使用。

3.4 隐藏 传播一下知识也是一个很好的选择 这一个模块(因为页眉页脚中设置了书的链接等信息,就隐藏这个了)

3.5 给 分页 上一节,下一节加上序号,便于查看。

3.6 最末尾声明下该pdf的说明,仅供学习交流,严禁用于商业用途。

3.7 返回宽高,用于设置视图大小

3.8 设置视图大小,创建生成pdf

4、关闭浏览器

具体代码:可以查看这里爬虫生成《React.js小书》的pdf每一小节的代码

1
2
3
scss复制代码// node 执行这个文件
// 笔者这里是:
node src/puppeteer/reactMiniBook.js

即可生成如下图:每一小节(0-46小节)的pdf

每一小节(0-46小节)的pdf

生成这些后,那么问题来了,就是查看时总不能看一小节,打开一小节来看,这样很不方便。

于是接下来就是合并这些pdf成为一个pdf文件。

3、合并成一个PDF文件 pdf-merge

起初,我是使用在线网站Smallpdf,合并PDF。合并的效果还是很不错的。这网站还是其他功能。比如word转pdf等。

后来找到社区提供的一个npm packagepdf merge。 (毕竟笔者是写程序的,所以就用代码来实现合并了)

这个pdf-merge依赖 pdftk

安装 PDFtk

Windows

下载并安装

笔者安装后,重启电脑才能使用。

Debian, Ubuntu 安装

笔者在Ubuntu系统安装后,即可使用。

apt-get install pdftk

使用例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
javascript复制代码const PDFMerge = require('pdf-merge');

const files = [
`${__dirname}/1.pdf`,
`${__dirname}/2.pdf`,
];

// Buffer (Default)
PDFMerge(files)
.then((buffer) => {...});

// Stream
PDFMerge(files, {output: 'Stream'})
.then((stream) => {...});

// 笔者这里使用的是这个
// Save as new file
PDFMerge(files, {output: `${__dirname}/3.pdf`})
.then((buffer) => {...});

知道这些后,可以开始写主程序了。

简单说下主流程

1、读取到生成的所有pdf文件路径,并排序(0-46)

2、判断下输出文件夹是否存在,不存在则创建

3、合并这些小节的pdf保存到新文件 React小书(完整版)-作者:胡子大哈-时间戳.pdf

具体代码:可以查看这里爬虫生成《React.js小书》的pdf合并pdf的代码

最终合并的pdf文件可供下载。github下载链接:React小书(完整版)-作者:胡子大哈。

本想着还可以加下书签和页码,没找到合适的生成方案,那暂时先不加了。如果读者有好的方案,欢迎与笔者交流。

小结

1、puppeteer是Google 官方出品的 headless Chrome node库,可以在浏览器中手动执行的大多数操作都可以使用Puppeteer完成。总之可以用来做很多有趣的事情。

2、用 puppeteer 生成每一小节的pdf,用依赖pdftk的pdf-merge npm包, 合并成一个新的pdf文件。或者使用Smallpdf等网站合并。

3、《React.js小书》,推荐给大家。爬虫生成pdf,应该不会对作者@胡子大哈有什么影响。作者写书服务社区不易,尽可能多支持作者。

最后推荐几个链接,方便大家学习 puppeteer。

puppeteer入门教程

Puppeteer 初探之前端自动化测试

爬虫生成ES6标准入门 pdf

大前端神器安利之 Puppeteer

puppeteer API中文文档

关于

作者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,唯善学。

个人博客

segmentfault前端视野专栏,开通了前端视野专栏,欢迎关注~

掘金专栏,欢迎关注~

知乎前端视野专栏,开通了前端视野专栏,欢迎关注~

github blog,求个star^_^~

微信公众号 若川视野

欢迎关注【若川视野】。也可以加微信 ruochuan12,注明来源,拉您进【前端视野交流群】。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

美图离线ETL实践

发表于 2018-08-28

感谢阅读「美图数据技术团队」的第 13 篇文章,关注我们持续获取美图最新数据技术动态。

美图收集的日志需要通过 ETL 程序清洗、规整,并持久化地落地于 HDFS / Hive,便于后续的统一分析处理。

图 1

/ 什么是 ETL? /

ETL 即 Extract-Transform-Load,用来描述将数据从来源端经过抽取(extract)、转换(transform)、加载(load)至目的端的过程。ETL 一词较常用在数据仓库,但其对象并不限于数据仓库。

在美图特有的业务环境下,ETL 需要做到以下需求:

1.大数据量、高效地清洗落地。美图业务繁多、用户基数大、数据量庞大,除此之外业务方希望数据采集后就能快速地查询到数据。

2.灵活配置、满足多种数据格式。由于不断有新业务接入,当有新业务方数据接入时要做到灵活通用、增加一个配置信息就可以对新业务数据进行清洗落地;同时每个业务方的数据格式各式各样,ETL 需要兼容多种通用数据格式,以满足不同业务的需求(如 json、avro、DelimiterText 等)。

3.约束、规范。需要满足数据库仓库规范,数据按不同层(STG 层、ODS 层等)、不同库(default.db、meipai.db 等)、不同分区(必须指定时间分区)落地。

4.容错性。考虑业务日志采集可能存在一定的脏数据,需要在达到特定的阈值时进行告警;并且可能出现 Hadoop 集群故障、Kafka 故障等各种状况,因此需要支持数据重跑恢复。

ETL 有两种形式:实时流 ETL 和 离线 ETL。

如图 2 所示,实时流 ETL 通常有两种形式:一种是通过 Flume 采集服务端日志,再通过 HDFS 直接落地;另一种是先把数据采集到 Kafka,再通过 Storm 或 Spark streaming 落地 HDFS,实时流 ETL 在出现故障的时候很难进行回放恢复。美图目前仅使用实时流 ETL 进行数据注入和清洗的工作。

图 2

根据 Lambda 结构,如果实时流 ETL 出现故障需要离线 ETL 进行修补。离线 ETL 是从 Kafka拉取消息,经过 ETL 再从 HDFS 落地。为了提高实时性及减轻数据压力,离线 ETL 是每小时 05 分调度,清洗上一个小时的数据。为了减轻 HDFS NameNode 的压力、减少小文件,日期分区下同个 topic&partition 的数据是 append 追加到同一个日志文件。

/ 离线 ETL 的架构设计及实现原理 /

离线 ETL 采用 MapReduce 框架处理清洗不同业务的数据,主要是采用了分而治之的思想,能够水平扩展数据清洗的能力;

图 3:离线 ETL 架构

如图 3 所示,离线 ETL 分为三个模块:

  • Input(InputFormat) :主要对数据来源(Kafka 数据)进行解析分片,按照一定策略分配到不同的 Map 进程处理;创建 RecordReader,用于对分片数据读取解析,生成 key-value 传送给下游处理。
  • Map(Mapper):对 key-value 数据进行加工处理。
  • Output (OutputFormat) :创建 RecordWriter 将处理过的 key-value 数据按照库、表、分区落地;最后在 commit 阶段检测消息处理的完整性。

离线 ETL 工作流程

图 4

如图 4 所示是离线 ETL 的基本工作流程:

1.kafka-etl 将业务数据清洗过程中的公共配置信息抽象成一个 etl schema ,代表各个业务不同的数据;

2.在 kafka-etl 启动时会从 zookeeper 拉取本次要处理的业务数据 topic&schema 信息;

3.kafka-etl 将每个业务数据按 topic、partition 获取的本次要消费的 offset 数据(beginOffset、endOffset),并持久化 mysql;

4.kafka-etl 将本次需要处理的 topic&partition 的 offset 信息抽象成 kafkaEvent,然后将这些 kafkaEvent 按照一定策略分片,即每个 mapper 处理一部分 kafkaEvent;

5.RecordReader 会消费这些 offset 信息,解析 decode 成一个个 key-value 数据,传给下游清洗处理;

6.清洗后的 key-value 统一通过 RecordWriter 数据落地 HDFS。

离线 ETL 的模块实现

数据分片(Split)

我们从 kafka 获取当前 topic&partition 最大的 offset 以及上次消费的截止 offset ,组成本次要消费的[beginOffset、endOffset]kafkaEvent,kafkaEvent 会打散到各个 Mapper 进行处理,最终这些 offset 信息持久化到 mysql 表中。

图 5

那么如何保证数据不倾斜呢?首先通过配置自定义mapper个数,并创建对应个数的ETLSplit。由于kafkaEevent包含了单个topic&partition之前消费的Offset以及将要消费的最大Offset,即可获得每个 kafkaEvent 需要消费的消息总量。最后遍历所有的 kafkaEevent,将当前 kafkaEevent 加入当前最小的 ETLSplit(通过比较需要消费的数据量总和,即可得出),通过这样生成的 ETLSplit 能尽量保证数据均衡。

数据解析清洗(Read)

图 6

如图 6 所示,首先每个分片会有对应的 RecordReader 去解析,RecordReade 内包含多个 KafkaConsumerReader ,就是对每个 KafkaEevent 进行消费。每个 KafkaEevent 会对应一个 KafkaConsumer,拉取了字节数据消息之后需要对此进行 decode 反序列化,此时就涉及到 MessageDecoder 的结构。MessageDecoder 目前支持三种格式:

格式 涉及 topic
Avro android、ios、ad_sdk_android…
Json app-server-meipai、anti-spam…
DelimiterText app-server-youyan、app-server-youyan-im…

MessageDecoder 接收到 Kafka 的 key 和 value 时会对它们进行反序列化,最后生成 ETLKey 和 ETLValue。同时 MessageDecoder 内包含了 Injector,它主要做了如下事情:

  • 注入 Aid :针对 arachnia agent 采集的日志数据,解析 KafkaKey 注入日志唯一标识 Aid;
  • 注入 GeoIP 信息 :根据 GeoIP 解析 ip 信息注入地理信息(如 country_id、province_id、city_id);
  • 注入 SdkDeviceInfo : 本身实时流 ETL 会做注入 gid、is_app_new 等信息,但是离线 ETL 检测这些信息是否完整,做进一步保障。

过程中还有涉及到 DebugFilter,它将 SDK 调试设备的日志过滤,不落地到 HDFS。

多文件落地(Write)

由于 MapReduce 本身的 RecordWriter 不支持单个落地多个文件,需要进行特殊处理,并且 HDFS 文件是不支持多个进程(线程)writer、append,于是我们将 KafkaKey+ 业务分区+ 时间分区 + Kafka partition 定义一个唯一的文件,每个文件都是会到带上 kafka partition 信息。同时对每个文件创建一个 RecordWriter。

图 7

如图 7 所示,每个 RecordWriter 包含多个 Writer ,每个 Writer 对应一个文件,这样可以避免同一个文件多线程读写。 目前是通过 guava cache 维护 writer 的数量,如果 writer 太多或者太长时间没有写访问就会触发 close 动作,待下批有对应目录的 kafka 消息在创建 writer 进行 append 操作。这样我们可以做到在同一个 map 内对多个文件进行写入追加。

检测数据消费完整性 (Commit)

图 8

MapReduce Counter 为提供我们一个窗口,观察统计 MapReduce job 运行期的各种细节数据。并且它自带了许多默认 Counter,可以检测数据是否完整消费:

reader_records: 解析成功的消息条数;

decode_records_error: 解析失败的消息条数;

writer_records: 写入成功的消息条数;

…

最后通过本次要消费 topic offset 数量、reader_records 以及 writer_records 数量是否一致,来确认消息消费是否完整。

*允许一定比例的脏数据,若超出限度会生成短信告警

/ ETL 系统核心特征 /

数据补跑及其优化

ETL 是如何实现数据补跑以及优化的呢?首先了解一下需要重跑的场景:

*当用户调用 application kill 时会经历三个阶段: 1) kill SIGTERM(-15) pid;2) Sleep for 250ms;3)kill SIGKILL(-9) pid 。

那么有哪些重跑的方式呢?

如图 9 所示是第三种重跑方式的整体流程,ETL 是按照小时调度的,首先将数据按小时写到临时目录中,如果消费失败会告警通知并重跑消费当前小时。如果落地成功则合并到仓库目录的目标文件,合并失败同样会告警通知并人工重跑,将小文件合并成目标文件。

图 9

优化后的重跑情况分析如下表所示:

自动水平扩展

现在离线 Kafka-ETL 是每小时 05 分调度,每次调度的 ETL 都会获取每个 topic&partition 当前最新、最大的 latest offset,同时与上个小时消费的截止 offset 组合成本地要消费的 kafkaEvent。由于每次获取的 latest offset 是不可控的,有些情况下某些 topic&partition 的消息 offset 增长非常快,同时 kafka topic 的 partition 数量来不及调整,导致 ETL 消费处理延迟,影响下游的业务处理流程:

  • 由于扩容、故障等原因需要补采集漏采集的数据或者历史数据,这种情况下 topic&&partition 的消息 offset 增长非常快,仅仅依赖 kafka topic partiton 扩容是不靠谱的,补采集完后面还得删除扩容的 partition;
  • 周末高峰、节假日、6.18、双十一等用户流量高峰期,收集的用户行为数据会比平时翻几倍、几十倍,但是同样遇到来不及扩容 topic partition 个数、扩容后需要缩容的情况;

Kafka ETL 是否能自动水平扩展不强依赖于 kafka topic partition 的个数。如果某个 topic kafkaEvent 需要处理的数据过大,评估在合理时间范围单个 mapper 能消费的最大的条数,再将 kafkaEvent 水平拆分成多个子 kafkaEvent,并分配到各个 mapper 中处理,这样就避免单个 mapper 单次需要处理过大 kafkaEvent 而导致延迟,提高水平扩展能力。拆分的逻辑如图 10 所示:

图 10

后续我们将针对以下两点进行自动水平扩展的优化:

  • 如果单个 mapper 处理的总消息数据比较大,将考虑扩容 mapper 个数并生成分片 split 进行负载均衡。
  • 每种格式的消息处理速度不一样,分配时可能出现一些 mapper 负担比较重,将给每个格式配置一定的权重,根据消息条数、权重等结合一起分配 kafkaEvent。

美图在做这些事,了解一下?

美图大数据平台架构实践

美图个性化推荐的实践与探索

美图分布式Bitmap实践:Naix

基于用户行为的视频聚类方案

深度模型DNN在个性化推荐场景中的应用

美图AB
Test实践:Meepo系统

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Docker就应该通俗易懂一些 写在前面 正文 尾声

发表于 2018-08-24

写在前面

为了避免浪费各位小伙伴的时间,这篇文章是我个人对Docker的理解和总结。也就是说并非涉及到Docker的实战。所以如果想了解Docker在开发过程中的作用,可能这篇文章会让你失望了~

这里是Docker的官方网站,不过很可惜需要翻墙。

本文主要谈一谈这几个点

  • 什么是容器?
  • 容器与虚拟机的比较
  • 什么是Docker?
  • Docker能做什么?

正文

什么是容器?

按照Docker文档中的定义:将软件打包成标准化单元,用于开发,装运和部署。

Package Software into Standardized Units for Development, Shipment and Deployment

不知道这短短的一句话,小伙伴们是怎么理解的。如果我们把视线定位到后面俩个词:装运、部署。其实就相对好理解的多了。想要装运,那肯定需要载体,就像我们的集装箱。想要部署,就要有完整的环境,比如我们的Java,需要运行环境。

这俩个概念揉到一起:容器就是蕴涵程序及程序运行环境的合集。(打一个比喻:如果把我们正常的开发体系比作住宅的话;容器就像是房车。独立且五脏俱全)。

传统运行环境和容器的比喻

换个我们技术上的图,来加深一下对容器的理解:

来自官网的图片

我直接结合自己的理解,画的图…

Docker和容器的关系

容器与虚拟机的比较

其实上述巴拉巴拉说了这么多,又是装运,又是部署。说白了不就是隔离而已么?虚拟机也可以做啊!

没错,其实Docker官方文档也认可了这个说法。并没有否定:容器和虚拟机具有类似的资源隔离和分配优势。
Containers and virtual machines have similar resource isolation and allocation benefits

但是……官网祭出了它的杀器:

but function differently because containers virtualize the operating system instead of hardware. Containers are more portable and efficient.

说白了,容器的技术实现更骚:我不光能隔离;我还能小跑,还能小跳呢。你说气人不~~

沿用上述的比喻,如果传统的体系是别墅的话,容器还是房车的话。那么虚拟机就是多层的住宅区了~

既然提到了虚拟机,那么就让我们好好的聊一聊容器与虚拟机的不同。其实从Docker官方的解释就可以很清楚的看出俩者的不同:

容器:

容器是应用层的抽象,它将代码和依赖关系打包在一起。多个容器可以在同一台机器上运行,并与其他容器共享操作系统内核,每个容器在用户空间中作为独立进程运行。容器占用的空间比VM少(容器映像的大小通常为几十MB),可以处理更多的应用程序,并且需要更少的VM和操作系统。

虚拟机:

虚拟机(VM)是物理硬件的抽象,将一台服务器转变为多台服务器。虚拟机管理程序允许多台虚拟机在一台计算机上运行。每个VM都包含操作系统的完整副本,应用程序,必要的二进制文件和库 - 占用数十GB。虚拟机也可能很慢启动。

说白了,容器是基于操作系统实现的;而虚拟机则是抽闲的计算机硬件。所以相对来说容器的级别更低一点。所以二者各有千秋,就像Docker官网所说:

在一起使用的容器和VM在部署和管理应用程序时提供了极大的灵活性。(Containers and VMs used together provide a great deal of flexibility in deploying and managing app)

什么是Docker?

个人觉得上述的那张图(Docker和容器的关系),可以比较清晰的展现出Docker是什么~

当然,图片能直观的展示,不能具体的描述。接下来让我们通过文字去进一步深化Docker的概念。

个人的理解:它二者不是包含关系。而是杂糅在一起的。我个人喜欢把“容器”理解成是一种概念,而Docker是“容器”这个概念下的一种实现方式(一整套解决方案。毕竟人家是一家商业公司,要赚钱哒~肯定是提供一整套服务)。

按照Docker官网的介绍(对外zhuangbi):

  • 轻量,在一台机器上运行的多个Docker容器。可以共享这台机器的操作系统内核;它们能够迅速启动,只需占用很少的计算和内存资源。
  • 标准,Docker容器基于开放式标准,能够在所有主流Linux版本、Microsoft Windows以及包括VM、裸机服务器和云在内的任何基础设施上运行。
  • 安全,Docker赋予应用的隔离性不仅限于彼此隔离,还独立于底层的基础设施。Docker默认提供最强的隔离,因此应用出现问题,也只是单个容器的问题,而不会波及到整台机器。

说白了,作为一个容器,Docker可以帮我们隔离软件的运行环境。独立的数据库,独立的软件运行环境,独立的服务器进程。一切都是独立的,搞崩一个,还有一个,可劲霍霍~

借用百度的一个图:

Docker能做什么?

其实,有了上述那些内容,Docker能做什么已经比较清晰了。借用Docker官网对外“吹牛”的图:

  • 一致的运行环境:作为开发人员,我们经常会甩锅给运行环境。现在好了,Docker的镜像提供了除内核外完整的运行时环境。这个锅没的甩了。
  • 更快速的启动时间:Docker官方对外宣称,可以做到秒级、甚至毫秒级的启动时间。
  • 隔离性:这个就不用多说了…
  • 弹性伸缩,快速扩展:也不知道真的假的
  • 迁移方便:同JVM一个道理,可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上。
  • 持续交付和部署:使用Docker可以通过定制应用镜像来实现持续集成、持续交付、部署。

Docker能做这些内容主要依赖它的三个内容:镜像(Docker Image)、容器(Docker Containers)、仓库(Docker Registry)。

三个概念

镜像(Docker Image)

镜像是一个构建容器的只读模板,它包含了容器启动所需的所有信息,包括运行程序和配置数据。

容器(Docker Containers)

开发环境真正运行的载体。

仓库(Docker Registry)

存放镜像的地方。

总结

个人理解,镜像可以理解成类(Class),它拥有对象的抽象结构。而容器才是我们真正被new出来,能让我们使用的对象。

更多内容,可以移步官方文档呦,需翻墙

尾声

以上内容,是我工作以来自己对Docker的理解。如有不当之处,还望各位大佬能够批评指正~

这里是一个应届生/初程序员公众号~~欢迎围观

我是一个应届生,最近和朋友们维护了一个公众号,内容是我们在从应届生过渡到开发这一路所踩过的坑,已经我们一步步学习的记录,如果感兴趣的朋友可以关注一下,一同加油~

个人公众号:IT面试填坑小分队

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JavaScript集成Sentry Sentry-Java

发表于 2018-08-24

Sentry-JavaScript

Sentry是一套用于捕获产品错误的开源项目,其下支持很多语言、框架。

这里就只阐述在前端JavaScript方向的处理操作

在我们公司之前的应用场景里,很多项目都是使用kibana来做信息统计。但是我们无法清楚的知道应用的运行状态是怎么样的。当某个客户在使用我们开发产品时,如果报错、崩溃。用户只能向客服寻求帮助,再交接给我们的开发人员进行复现、修复。其中因为不清楚具体的数据,开发人员是在复现时会非常的耗时。

而Sentry的用途就是解决这一痛点问题,让开发人员快速准确的定位到问题的根源所在,以达到快速修复,让开发人员更注重于开发新的功能上面。减少时间资源上的浪费。

  1. JavaScript

1.1. 接入

因为Sentry使用的是一种Hook错误函数的技术,来达到捕获错误的目的,所以我们基本可以无损耗的接入到现有的项目中去。

下面是React与Sentry进行结合的一些基本步骤。

React:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码#SentryBoundary.js
import { Component } from "react";
import Raven from "raven-js";

export default class SentryBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}

componentDidCatch(error, errorInfo) {
this.setState({ error });
// 发送错误信息
Raven.captureException(error, { extra: errorInfo });
}

render() {
if (this.state.error) {
// 此处可以写成组件,当组件崩溃后,可以替换崩溃的组件
console.log("React Error");
}
return this.props.children;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码#index.js
Raven.config("DSN", {
release: release,
}).install();

ReactDOM.render(
<div>
<SentryBoundary>
<App />
</SentryBoundary>
</div>,
document.getElementById("root")
);

1.1.1. 上传source-map

如果上面的代码已经配置好后,那么现在的应用是可以捕获到错误的,但是存在了一个问题,我们目前的项目大多都使用webpack进行打包,而打包后的代码是混淆加密的代码,无法让我们准确的知道抛出错误的位置在哪里。所以我们需要上传source-map和混淆后的文件一起上传到Sentry服务器上。方便我们快速查找到问题所在的位置。

这个上传的配置及命令是比较繁琐的。也是项目结合Sentry的一个难点。

上传source-map目前有两种方式:

  • 使用Sentry提供的Webpack插件进行配置,但是其灵活性不高。
  • 使用sentry-cli的,其灵活性比较高,可以针对不同项目进行单独的配置。

其配置较为繁琐,这里就不在阐述。具体的React与Sentry结合的例子。可见我在github上的项目: react-sentry-demo。对每个配置都有详细的说明。其中的上传source-map,我使用的是第二种方法,并写了一个脚本,实现了: 打包、环境检测、认证检测、上传source-map、删除本地source-map的操作,完成自动化,可以把脚本直接迁移到现有的项目中去,改动也不会太大。

其核心上传命令如下:

1
复制代码sentry-cli releases files v1.8 upload-sourcemaps {js文件和js.map所在目录。如果没有找到,sentry会遍历其子目录} --url-prefix '~/{过滤规则}'`;

1.2. 浅入原理

在JavaScript中是有window.onerror这个方法的,而Sentry在前端的核心捕获原理,就是通过重写此方法,来对所有的错误进行捕获。其实现的代码大致如下:

1
2
3
4
5
6
7
8
9
复制代码let _winError = window.onerror;
window.onerror = function (message, url, lineNo, colNo, errorObj) {
console.log(`
错误信息: ${message}
错误文件地址: ${url}
错误行号: ${lineNo}
错误列号: ${colNo}
错误的详细信息 ${errorObj}`);
}

然后Sentry的工作就是获取非错误的数据,如: user-agent、浏览器信息、系统信息、自定义信息等信息,然后交给Sentry的生命周期函数,最后在把数据发送到Sentry服务端,进行错误信息展示。

1.2.1. 兼容性

这里所说的兼容性,其实也就是window.onerror的兼容性

1.2.1.1. 运行环境兼容性

环境 message url lineNo colNo errorObj
Firefox ✓ ✓ ✓ ✓ ✓
Chrom ✓ ✓ ✓ ✓ ✓
Edge ✓ ✓ ✓ ✓ ✓
IE 11 ✓ ✓ ✓ ✓ ✓
IE 10 ✓ ✓ ✓ ✓
IE 9 ✓ ✓ ✓ ✓
IE 8 ✓ ✓ ✓
Safari 10 and up ✓ ✓ ✓ ✓ ✓
Safari 9 ✓ ✓ ✓ ✓
Opera 15+ ✓ ✓ ✓ ✓ ✓
Android Browser 4.4 ✓ ✓ ✓ ✓
Android Browser 4 - 4.3 ✓ ✓
微信webview(安卓) ✓ ✓ ✓ ✓
微信webview(IOS) ✓ ✓ ✓ ✓ ✓
WKWebview ✓ ✓ ✓ ✓ ✓
UIWebview ✓ ✓ ✓ ✓ ✓

1.2.1.2. 标签兼容性

标签 window.onerror是否能捕获
img 可以
script 需要再script标签添加crossorigin属性,并在服务端允许跨域。如果不使用这个属性,错误信息只会显示Script error.
css 不能
iframe 不能

可以发现其浏览器都支持此方法。只是有些运行环境不支持colNo和errorObj,但是这块,Sentry已经帮你处理好了,所以不用担心。只是会在展示错误的时候,信息不太完整而已。

1.3. 所能捕获的信息

1.3.1. 错误信息

从上面的浅入原理可以看到,其核心捕获是window.onerror。那么只要它可以捕获到的错误,都会发送到Sentry上。

而window.onerror能捕获到的错误,除了Promise,基本上能在控制台出现的错误,都会捕获到。也就是运行时的错误,包括语法错误。

关于捕获Promise错误的方案,可以使用:

window.addEventListener('unhandledrejection', event => {})

来进行捕获,但是此事件的兼容性不太好,目前只有webkit内核支持这个事件。

如下代码,是此方法所能捕获到的:

1
2
3
4
5
复制代码const p = new Promise((reslove, reject) => reject('Error'))
p.then(data => {
console.log(data)
})
// Promise触发了reject回调函数,但是却没有相应到catch来应对。从而导致报错。

1.3.2. 面包屑信息

  • Ajax请求
  • URL地址的变化
  • UI点击和按下的DOM事件
  • 控制台的console信息
  • 之前的错误
  • 自定义的面包屑信息

1.4. 展示信息

image

  1. Electron集成

这里的集成,也不是说捕获Electron应用的错误,而是崩溃。因为Electron只是一个容器,里面的内容还是JavaScript应用。

2.1 接入

刚刚也说到这里的集成也只是去捕获Electron崩溃的信息。而当Electron崩溃时,会触发Electron的函数:crashReporter.start,那么我们在这个函数里去配置一下自己的sentry信息:

1
2
3
4
5
6
7
8
9
10
11
复制代码import { crashReporter } from 'electron'

crashReporter.start({
productName: 'aoc-desktop',
companyName: 'alo7',
submitURL:
'https://sentry.com/api/34615/minidump/?sentry_key=3e05fe101f1f85008e853ff56908b7eb', // sentry提供的minidump接口
extra: {
// 额外信息
}
})

配置好后,可以使用process.crash()来模拟崩溃,以便查看Sentry是否能收到崩溃信息。

2.1.1 上传Symbol(符号表)

在上面的应用说的是上传source-map,但是这里上传的是Symbol。可以把Symbol理解为另一种source-map。

Symbol的格式(后缀)有很多,Mac下是dSYM,windows是pdb。而在Sentry里,暂时是不支持上传pdb的。需要使用dump_syms.exe来把pdb格式转化成sym格式。再上传到Sentry里。这样就可以在Sentry崩溃的时候,看到起崩溃的上下文了。如下图:

这样就可以准确的定位到是哪里出现了问题。

  1. 浅入上传检索的原理

当Sentry服务端收到source-map时,是通过你上传时的url-prefix信息,与source-map文件以及运行时的js文件,产生对应。流程图如下:

其他

我司(爱乐奇)招人,感兴趣的小伙伴可以来投简历呀。

弹性工作制、每日水果、同事都特别nice、965、团建、五险一金…

地点上海浦软大厦

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

嘻哈说:设计模式之接口隔离原则

发表于 2018-08-24

1、定义

按照惯例,首先我们来看一下接口隔离原则的定义。

类间的依赖关系应该建立在最小的接口上。

接口中的方法应该尽量少,不要使接口过于臃肿,不要有很多不相关的逻辑方法。

有点类似于单一职责原则,都是功能尽可能的简单单一,不要冗余太多其他不相关的。

单一职责原则主要是类与方法,而接口隔离原则却是对接口而言的。

2、场景

小厨洗菜,大厨做饭。

在番茄餐厅的后厨,老板与求生欲极强的厨师长在聊天。

老板:最近我们番茄餐厅广受好评,菜品味道美味,这还得感谢你这位厨师长呀。

厨师长:应该的,这该感谢我。

老板:嗯?你确定?

厨师长:还没,还没说完,这该感谢我…们的郝老板,这次确定了。(冷汗)

老板:你这求生欲厉害了,叹为观止呀。不过现在随着顾客的增多,人手再次不够了,再招厨师肯定来不及了,你有什么好办法吗?

厨师长:办法我还真有一个,我们可以招点小厨,小厨要好招一些。

老板:但小厨做饭不够美味,很容易流失客户的。

厨师长:小厨不做饭,小厨只负责洗菜。这样呢,大厨就不用洗菜了,只负责做饭,这样效率就上去了。

老板:你是不是不想洗菜?

厨师长:当然不是,我就是,就是,就是替公司着想。

老板:好吧,准了,招人吧。

3、代码

之前我们在依赖倒置原则聊过对接口编程,所以,首先我们定义一个厨师接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csharp复制代码package com.fanqiekt.principle.segregation;

/**
* 厨师接口
*
* @author 懒人
*/
public interface IChef {

/**
* 洗菜
*/
void wash();

/**
* 做饭
*/
void cooking();
}

厨师做两件事,一个是洗菜,一个是做菜。

接下来,我们写一下大厨的代码,大厨实现了厨师接口。

大厨做饭,但不负责洗菜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
less复制代码package com.fanqiekt.principle.segregation;

/**
* 大厨
*
* @author 懒人
*/
public class BigChef implements IChef {

/**
* 洗菜的逻辑与大厨无关
*/
@Override
public void wash() {

}

@Override
public void cooking() {
System.out.println("大厨做饭");
}
}

我们再写一下小厨的部分,小厨也是实现厨师接口。

小厨不做饭,小厨只洗菜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typescript复制代码package com.fanqiekt.principle.segregation;

/**
* 小厨
*
* @author 懒人
*/
public class Kitchen implements IChef {
@Override
public void wash() {
System.out.println("小厨洗菜");
}

/**
* 做饭的逻辑与小厨无关
*/
@Override
public void cooking() {

}
}

这样的写法,好不好?

当然不好,每个类都冗余了与他不相关的方法。例如,BigChef中的wash方法、Kitchen中的cooking方法。

这种现象是怎么导致的呢?

接口不够最小化。接口隔离原则就是为了解决这种问题的。

我们可以写成两个接口,一个是做饭的接口,一个是洗菜的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
csharp复制代码package com.fanqiekt.principle.segregation;

/**
* 做饭接口
*
* @author 懒人
*/
public interface ICook {

/**
* 做饭
*/
void cooking();
}

做饭的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
csharp复制代码package com.fanqiekt.principle.segregation;

/**
* 洗菜接口
*
* @author 懒人
*/
public interface IWash {

/**
* 洗菜
*/
void wash();

}

洗菜的接口。

我们再来看一下符合接口隔离原则的具体实现类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
csharp复制代码package com.fanqiekt.principle.segregation;

/**
* 大厨
*
* @author 懒人
*/
public class BigChef implements ICook{

/**
* 大厨只负责做饭,只处理和做饭相关的逻辑
*/
@Override
public void cooking() {
System.out.println("大厨做饭");
}
}

这样就没有冗余代码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
csharp复制代码package com.fanqiekt.principle.segregation;

/**
* 小厨
*
* @author 懒人
*/
public class Kitchen implements IWash {

/**
* 小厨只负责洗菜,只处理和洗菜相关的逻辑
*/
@Override
public void wash() {
System.out.println("小厨洗菜");
}
}

小厨同样也没有冗余代码了。

这样的写法是不是更加的优雅了。

如果新增一种既洗菜又做饭的厨师类型,那同时实现ICook与IWash接口就可以了。

3、优点

它与单一职责原则类似,优点也是类似的。

降低风险
修改其中的一个业务,不会影响到业务。

易维护易扩展
没有冗余代码,符合接口隔离原则的接口,会更加容易扩展以及维护。

4、嘻哈说

接下来,请您欣赏接口隔离原则的原创歌曲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
makefile复制代码嘻哈说:接口隔离原则
作曲:懒人
作词:懒人
Rapper:懒人

奋斗了多年总算熬成了大厨才不要关心洗菜
这些琐事都摘不掉
刚入行一两年但兢兢业业的小厨还不到烹饪大菜
因为这些来不了
所以接口功能太多在胡闹
接口功能应该尽可能最少
这就是接口隔离
核心思想就是接口最小
这样才结构得体
风险降低易扩展维护也有格局
用起来它是绝对合理

试听请点击这里

闲来无事听听曲,知识已填脑中去;

学习复习新方式,头戴耳机不小觑。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

dubbo源码解析-服务暴露原理

发表于 2018-08-23

❈

之前讲完了dubbo集群容错系列,现在开始讲比较重要的环节,也就是dubbo面试中比较喜欢问的两个点:服务发布和服务引用.

❈

先插播一下dubbo源码专题的文章:

  1. dubbo源码解析-集群容错架构设计
    1. dubbo源码解析-详解directory
    2. dubbo源码解析-详解router
    3. dubbo源码解析-详解cluster
    4. dubbo源码解析-详解LoadBalance

插播面试题

服务发布过程中做了哪些事

dubbo都有哪些协议,他们之间有什么特点,缺省值是什么

什么是本地暴露和远程暴露,他们的区别

直入主题# 从启动日志说起

大家都知道,dubbo是阿里巴巴开源的一个项目,前阵子阿里不仅发布的代码规范手册,还发布了相应的插件,从这点我们就知道,阿里是很注重这个代码规范的,从dubbo项目我们也发现了,他有完善的测试体系.说了这么说,那和我们今天要讲的dubbo的服务发布有什么关系呢?当然有,一个规范的项目,必然是有健全的日志系统.做过其他开发比如iOS开发的都知道,这个控制台输出在生产环境是不能随便输出的,太多日志有时候和没有日志是一样的.所以我们来看看这个服务启动过程中,日志究竟输出了什么.


这里我用不同颜色的框将关键的地方画了出来,一共有6种颜色,我一次从上到下说一下这发布过程的一些动作

暴露本地服务

暴露远程服务

启动netty

连接zookeeper

到zookeeper注册

监听zookeeper

从文档入手,了解全局

经常有人问到,看源码要怎么看.要了解这个服务发布,文档就是一个很好的切入口,如下

再献上文档提供的一个时序图


当然这些都是给已经对源码有一定熟悉的人看的,而我的源码解析类文档,是给对源码不熟悉的人看的.

其实我的每周一更dubbo源码系列文章,我的初衷有以下两个,一个就是做到系统分享,比如dubbo系列文章,我会从不少于内核,服务发布,服务引用,编解码这四个模块去剖析,后面如果有时间,会临摹一个简易的dubbo框架.另一个初衷就是把自己思考的过程和大家分享,在这个分享过程中,其实就是回答了”怎么看源码”这个问题

那么准备好五菱宏光,向秋名山出发


出发秋名山

我早年做Android开发的时候,就发现身边的人都是死在了一个点上,那就是配置环境.对于看源码,很多人最常问的一句话就是,怎么入手,也就是切入点.那么我们还是以开头的日志为例,来找一个这个切入点

仔细看输出日志,就会发现在暴露本地服务之前,有一句很重要的日志,就是

The service ready on spring started. service: com.alibaba.dubbo.demo.DemoService, dubbo version: 2.0.0, current host: 127.0.0.1

我们利用编译器的搜索文本功能,定位到了ServiceBean这个类,这个类是干嘛的?好,我假设我也不知道,既然不知道,那我们来看一下他的继承体系图


从这个图我们看到了许多和spring有关的东西,还发现了一个重要的接口,那就是ApplicationListener.要能敏锐的发现这个关键的接口,首先还是要对spring有一定了解,这个就是spring的事件机制(event).什么是事件机制呢?就比如监听spring容器初始化完成.那我们就定位到这行日志的位置,往下debug




下面要开始敲黑板划重点了.



从方法名我们很直观知道目的,但是这里同事不止一次问过我,这个dubbo.properties文件是怎么时候加载的,好像我根本没有设置,另外这个dubbo.properties文件的名字能不能改?面对这种问题,我从来都是不会直接把答案告诉他,而是告诉他,我是怎么得到这个答案的,如下图


同理,对于log4j.xml这个文件,好像我从来没有写代码加载过,为什么他会加载呢,其实也是同理的


接下来继续往下走,下面这里就是我们的第二道面试题

这里为什么会进行遍历呢,因为dubbo是支持多协议的,看文档原话


dubbo支持多种协议,默认使用的是dubbo协议,具体介绍官方文档写得很清楚,传送地址:相关协议介绍

马不停蹄,下面就到了第三个面试题,也是服务发布的重点,本地暴露和远程暴露


为什么会有本地暴露和远程暴露呢?不从场景考虑讨论技术的没有意义是.在dubbo中我们一个服务可能既是Provider,又是Consumer,因此就存在他自己调用自己服务的情况,如果再通过网络去访问,那自然是舍近求远,因此他是有本地暴露服务的这个设计.从这里我们就知道这个两者的区别

本地暴露是暴露在JVM中,不需要网络通信.

远程暴露是将ip,端口等信息暴露给远程客户端,调用时需要网络通信.

本篇着重讲解服务发布的整体过程,细节和总结后面会陆续展开.我们忘记下.其实注意一下我这些截图就能发现,我截的时候会特意把类名和方法名都截出来,方便大家能迅速定位.


从上图可知,这里用到了Adaptive(这个东西非常关键,后面会专门一篇讲解).点进ProxyFactory查看源码


@Adaptive注解打在类上和方法上,他们是有区别的(面试也喜欢问这种区别类的问题,这个留在Adaptive专题说,记得关注肥朝每周一篇源码解析 _ ),他打在方法上,就会生成动态编译的Adaptive类,下面就介绍一下怎么看这个动态编译类的源码

首先要将这个log4j的level调整为DEBUG


为什么需要调整成DEBUG,更重要的是把如何知道的,告诉大家,比如如下图,当然这个时候可能会朋友说,我也不知道这个类也还是没办法找到这行代码.其实这个也是在后面专门的一篇Adaptive中会说,到时候我会提供另一种实现思路,我们一起思考对比优劣.


打开DEBUG后,我们重新启动,就会看到日志有如下输出,这段就是相关代码,我们根据包名新建文件,如下



我们在getInvoker方法上打上断点,重启一下.




由上图知道,本地暴露的url是以injvm开头的,下面来看下远程暴露,其实这个也是回答本地暴露和远程暴露区别的一个回答点.面试回答要的并不是一个满分的答案,而是从一些细节中,看出一个人,是否真的研究过源码.


还是回到开头那句话,dubbo命名是很规范的,从Wrapper这个命名,其实可以和Spring的Bean Wrapper,以及装饰者设计模式联系起来.同时可以看看文档中的编码约定

写在最后

上面讲到了getInvoker方法,也就是拿到了Invoker,如果注意到本篇开头的文档说明中的那句Dubbo 处理服务暴露的关键就在 Invoker 转换到 Exporter 的过程,就知道,其实这个服务暴露还有很多细节是未完待续的,但是由于本篇已有一定篇幅,所以只能下周再见.

❈

作者:肥朝链接:https://www.jianshu.com/p/60a9263f2ee2

❈


本号已开设如下二十大专题,关注后查看【我的主页】,批阅相关专题!

【极简入门专题】【dubbo实战专题】

【设计模式专题】【dubbo源码专题】

【数据结构专题】【 netty 源码专题】

【网络协议专题】【spring源码专题】

【并发编程专题】【springboot专题】

【架构技术专题】【zookeeper专题】

【BATj面试专题】【redis 实战专题】

【mq中间件专题】【mysql优化专题】

【grpc+etcd专题】【 线程相关专题】

【JVM调优专题】【springcloud专题】

闷骚的大屌程序员富一代们 ↓↓****↓↓****

点个 赞**、** 留个言 再走可好↓↓****

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

还在被数据类的序列化折磨?是时候丢弃 Gson 了

发表于 2018-08-21

今天我们来简单介绍下 kotlinx.serialization kotlinx.serialization 7 。

认识一下

看名字就知道这是 Kotlin 官方的序列化框架了,它支持 JSON/CBOR/Protobuf,下面我们主要以 JSON 为例介绍它的功能(因为后面那俩不是给人看的啊)。

它作为一套专门为 Kotlin 的类开发的序列化框架,自然要兼顾到 Kotlin 的类型的各种特性,你会发现用 Gson 来序列化 Kotlin 类时遇到的奇怪的问题在这里都没了。

最重要的是,跟其他 Kotlinx 家族的成员一样,它将来会以跨平台的身份活跃在 Kotlin 的所有应用场景,如果你想要构建可移植的程序,例如从 Android(Jvm)移植到 iOS(Native),用 Gson 那肯定是不行的了,但 kotlinx.serialization 就可以。尽管它现在在 Native 上的功能还有限制,不过,人家毕竟还是个宝宝嘛(0.6.1)。

开始用吧

闲话少说,咱们创建一个 Kotlin 的 Jvm 程序(毕竟它的功能最全,别的平台有的还不支持),创建好以后引入依赖,由于我用的是 Kotlin DSL 的 gradle,所以如果你用的仍然是 Groovy 的,请去参考 GitHub 仓库的介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复制代码plugins {
//注意 Kotlin 的版本要新,不要问旧版怎么用,因为人家官方说了旧版不能用
kotlin("jvm") version "1.2.60"
}

buildscript {
repositories {
jcenter()
//这个库因为还是个宝宝,所以还在自己的仓库里面,gradle 插件从这儿找
maven ("https://kotlin.bintray.com/kotlinx")
}
dependencies {
//序列化框架的重要部分:gradle 插件
classpath("org.jetbrains.kotlinx:kotlinx-gradle-serialization-plugin:0.6.1")
}
}

apply {
//咦,怎么没有 apply kotlin 呢?不知道为啥的看代码的第一行
plugin("kotlinx-serialization")
}

dependencies {
compile(kotlin("stdlib", "1.2.60"))
//加载自定义的 Serializer 有些情况下需要反射
compile(kotlin("reflect", "1.2.60"))
//序列化框架的重要部分:运行时库
compile("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.6.1")
}

repositories {
jcenter()
//运行时库从这儿找
maven ("https://kotlin.bintray.com/kotlinx")
}

有了这些,你就可以写这么一段代码运行一下了:

1
2
3
4
5
6
7
8
9
10
复制代码import kotlinx.serialization.*
import kotlinx.serialization.json.JSON

@Serializable
data class Data(val a: Int, @Optional val b: String = "42")

fun main(args: Array<String>) {
println(JSON.stringify(Data(42))) // {"a": 42, "b": "42"}
val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42")
}

很棒啊,官方的例子。不过你如果直接使用 IntelliJ 的运行按钮,你就会发现一个编译错误,看起来就是什么版本不兼容啦之类的。别理它,这时候你只需要打开 Preference,找到 gradle->runner,把里面的 Delegate IDE build/run actions to gradle 勾上

再运行,很好,你就会看到运行成功了:

来个嵌套的类型

像数值类型、字符串这样的基本类型通常与 JSON 的类型都可以对应上,但如果是 JSON 中不存在的一个类型呢?

1
复制代码data class User(val name: String, val birthDate: Date)

然后:

1
复制代码println(JSON.stringify(User("bennyhuo", Calendar.getInstance().apply { set(2000, 3, 1, 10, 24,0) }.time)))

结果呢?

这日期我去,看了半天我才看懂,哪儿成啊。所以我要给 Date 自定义一个序列化的格式,怎么办?

我们需要定义一个 KSerializer 来实现自定义序列化:

1
2
3
4
5
6
7
8
9
10
复制代码@Serializer(forClass = Date::class)
object DateSerializer : KSerializer<Date> {
private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")

override fun load(input: KInput) = simpleDateFormat.parse(input.readStringValue())

override fun save(output: KOutput, obj: Date) {
output.writeStringValue(simpleDateFormat.format(obj))
}
}

然后在使用处注明要使用的 Serializer:

1
2
3
复制代码@Serializable
data class User(val name: String,
@Serializable(with = DateSerializer::class) val birthDate: Date)

这样输出的日期格式就是我指定的了:

日期当然是瞎写的。。。

更复杂一点儿的情况

假设我们有需求要讲一个 Date 序列化成一个数组,为了表达方便,我们先定义一个类:

1
2
3
4
5
复制代码@Serializable
class MyDate(var year: Int = 0, var month: Int = 0, var day: Int = 0,
var hour: Int = 0, var minute: Int = 0, var second: Int = 0){
... //省略 toString()
}

我们希望下面的代码的序列化的结果按照数组的形式输出 MyDate 当中的参数:

1
复制代码MyDate(2000, 3, 1, 10, 24, 0)

这个对象序列化之后应该输出:[2000,3,1,10,24,0]

我们要怎么做呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
复制代码@Serializer(forClass = MyDate::class)
object MyDateSerializer : KSerializer<MyDate> {
private val jClassOfMyDate = MyDate::class.java

override fun load(input: KInput): MyDate {
val myDate = MyDate()
val arrayInput = input.readBegin(ArrayClassDesc)
for (i in 0 until serialClassDesc.associatedFieldsCount) {
val index = arrayInput.readElement(ArrayClassDesc)
val value = arrayInput.readIntElementValue(ArrayClassDesc, index)
jClassOfMyDate.getDeclaredField(serialClassDesc.getElementName(i)).apply { isAccessible = true }.set(myDate, value)
}
arrayInput.readEnd(ArrayClassDesc)
return myDate
}

override fun save(output: KOutput, obj: MyDate) {
val arrayOutput = output.writeBegin(ArrayClassDesc, 0)
for (i in 0 until serialClassDesc.associatedFieldsCount) {
val value = jClassOfMyDate.getDeclaredField(serialClassDesc.getElementName(i)).apply { isAccessible = true }.get(obj) as Int
arrayOutput.writeIntElementValue(ArrayClassDesc, i + 1, value)
}
arrayOutput.writeEnd(ArrayClassDesc)
}
}

save 方法可以让我们在序列化 MyDate 的对象时按数组的形式输出,而 load 方法则用于反序列化。这段代码看上去有些古怪,不过不要感到害怕,一般情况下我们不会需要这样的代码。有了 MyDateSerializer 之后,我们需要注册它才可以使用,即:

1
2
复制代码val json = JSON(context = SerialContext().apply { registerSerializer(MyDate::class, MyDateSerializer) })
val result = json.stringify(MyDate(2000, 3, 1, 10, 24, 0)) //result = "[2000,3,1,10,24,0]"

这似乎与前面的 Date 的情况不同。通常如果作为一个类的成员,我们可以通过注解 @Serializable(with = MyDateSerializer::class) 来指定序列化工具类,就像我们前面为 Date 指定序列化工具类一样:

1
2
3
复制代码@Serializable
data class User(val name: String,
@Serializable(with = DateSerializer::class) val birthDate: Date)

但如果我们针对类本身做序列化时,通过注解为一个类配置全局序列化工具则是徒劳的(也许是一个尚未实现的 feature,也许是一个 bug,也许是故意而为之呢),就像下面这种写法,实际上是没有意义的。

1
2
复制代码@Serializable(with = MyDateSerializer::class)
class MyDate(...){ ... }

当然你也可以通过自定义注解来为属性增加额外的信息,但这个使用场景比较少,就不介绍了。

Gson 做不到的事儿

看到这里 Gson 哥坐不住了,这事儿尼玛我也会啊,不就解析个 Json 串吗,有啥难的??

①构造方法默认值

这事儿还真不是说 Gson 的不是,Gson 作为 Java 生态中的重要一员,尽管它的速度不是最快的,但他的接口最好用啊,所以写 Java 的时候每次测试 Maven 库的时候我都会用引入 Gson 试试,嗯,它的 Maven id 是我认识 Kotlin 之前能背下来的唯一一个。

1
复制代码com.google.code.gson:gson:$version

那么的问题是啥?问题就是,它不是为 Kotlin 专门定制的。大家都知道,如果你想要在你的项目中做出成绩来,你必须要针对你的业务场景做优化,市面上所有的轮子都倾向于解决通用的问题,我们这些 GitHub 的搬运工的水平级别主要是看上轮子的时候谁的螺丝和润滑油上的更好。

我们还是看官方的那个例子:

1
2
复制代码@Serializable
data class Data(val a: Int, @Optional val b: String = "42")
1
2
复制代码val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42")
val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b="?")

不同的是,我们这回用 Gson 去反序列化同样的字符串,结果呢?

为什么会这样?因为 Gson 在反序列化的时候,构造对象实例时没有默认无参构造方法,同时又没有设置 TypeAdapter 的话,它就不知道该怎么实例化这个对象,于是用到了一个千年黑魔法 Unsafe 。尽管我们在 Data 的构造器里面给出了默认值,但 Gson 听了之后会说:啥玩意?啥默认值?

②属性的初始化值

1
2
3
4
5
6
7
8
9
复制代码@Serializable
data class Data(val a: Int, @Optional val b: String = "42"){
@Optional
private val c: Long = 9

override fun toString(): String {
return "Data(a=$a, b='$b', c=$c)"
}
}

好的,我们现在给 Data 添加了一个成语,注意它不在构造方法中,所以后面的 9 不是默认值,而是构造的时候的初始化值。同时由于默认的 toString 方法只有构造器中的属性,所以我们需要自己来一个,带上 c。

还是前面的程序,这次猜猜两个框架是如何初始化 c 的值的?

1
2
复制代码val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42", c=?)
val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b="null", c=?)

结果嘛,当然就是 Gson 没有对 c 做任何初始化的操作。

你当然可以骂 Gson “你瞎啊,那么明显的构造都不会执行?”,Gson 回复你的估计仍然是:

前面说过了,Gson 实例化的时候根本不会调用我们定义的构造器啊,这个初始化的值本身就是构造的一部分。

③属性代理

如果你在数据类(不是 data class 但也被当数据结构用的类也算)里面用到了属性代理,就像这样:

1
2
3
4
5
6
7
8
9
10
11
复制代码@Serializable
data class Data(val a: Int, @Optional val b: String = "42"){
@Optional
private val c: Long = 9

@Transient val d by lazy { b.length }

override fun toString(): String {
return "Data(a=$a, b='$b', c=$c, d=$d)"
}
}

我们定义了一个 d,它自己没有 backing field,我们用属性代理来让它代理 b 的长度,这样的用法本身也是经常见的。由于这个值本身自己只是一个代理,所以我们需要把它标记为 Transient,意思就是不参与序列化过程。

那么这时候同样,我们还是运行前面的那段代码:

1
2
复制代码val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42", c=9, d=?)
val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b=null, c=0, d=?)

其实猜结果的时候,我们能想到的差异就是,KS 能够正常的执行 Data 的初始化流程,因此可以覆盖到默认值、初始化值等等,而 Gson 不能,所以 Gson 一定不会处理 d。不过这次的结果可能就不是一个简单的 null 了,而是:

用 Gson 解析之后,如果我们想要访问 d,直接抛出空指针。这是为什么呢?因为属性代理会产生一个内部的代理属性,反编译之后我们就会看到是

1
复制代码private final Lazy d$delegate;

我们访问 d 的时候实际上就是去访问这个属性的 getValue 方法,而这个属性并没有被正常初始化,所以就有了空指针的结果了。

小结

序列化 Kotlin 数据类型的时候,以后可以考虑使用 kotlinx.serialization 这个框架了,它不仅 API 简单,还解决了我们经常遇到用别的 Java 框架带来的问题。


对啦,我的 Kotlin 新课 “基于 GitHub App 业务深度讲解 Kotlin1.2高级特性与框架设计” 上线之后,大家普遍反映有难度,有深度,如果哪位朋友想要吊打 Kotlin,不妨来看看哦!

coding.imooc.com/class/232.h…


转载请注明出处:微信公众号 Kotlin

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

初识NIO之Java小Demo

发表于 2018-08-21

Java中的IO、NIO、AIO:

BIO:在Java1.4之前,我们建立网络连接均使用BIO,属于同步阻塞IO。默认情况下,当有一条请求接入就有一条线程专门接待。所以,在客户端向服务端请求时,会询问是否有空闲线程进行接待,如若没有则一直等待或拒接。当并发量小时还可以接受,当请求量一多起来则会有许多线程生成,在Java中,多线程的上下文切换会消耗计算机有限的资源和性能,造成资源浪费。

NIO:NIO的出现是为了解决再BIO下的大并发量问题。其特点是能用一条线程管理所有连接。如下图所示:

图片来自网络

NIO是同步非阻塞模型,通过一条线程控制选择器(Selector)来管理多个Channel,减少创建线程和上下文切换的浪费。当线程通过选择器向某条Channel请求数据但其没有数据时,是不会阻塞的,直接返回,继续干其他事。而一旦某Channel就绪,线程就能去调用请求数据等操作。当该线程对某条Channel进行写操作时同样不会被阻塞,所以该线程能够对多个Channel进行管理。

NIO是面向缓冲流的,即数据写入写出都是通过 Channel —— Buffer 这一途径。(双向流通)

AIO:与之前两个IO模型不同的是,AIO属于异步非阻塞模型。当进行读写操作时只须调用api的read方法和write方法,这两种方法均是异步。对于读方法来说,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。换言之就是当调用完api后,操作系统完成后会调用回调函数。

总结:一般IO分为同步阻塞模型(BIO),同步非阻塞模型(NIO),异步阻塞模型,异步非阻塞模型(AIO)

同步阻塞模型指的是当调用io操作时必须等到其io操作结束

同步非阻塞模型指当调用io操作时不必等待可以继续干其他事,但必须不断询问io操作是否完成。

异步阻塞模型指应用调用io操作后,由操作系统完成io操作,但应用必须等待或去询问操作系统是否完成。

异步非阻塞指应用调用io操作后,由操作系统完成io操作并调用回调函数,应用完成放手不管。

NIO的小Demo之服务端

首先,先看下服务端的大体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码public class ServerHandle implements Runnable{
//带参数构造函数
public ServerHandle(int port){

}
//停止方法
public void shop(){

}
//写方法
private void write(SocketChannel socketChannel, String response)throws IOException{

}
//当有连接进来时的处理方法
private void handleInput(SelectionKey key) throws IOException{

}

//服务端运行主体方法
@Override
public void run() {

}
}

首先我们先看看该服务端的构造函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码public ServerHandle(int port){
try {
//创建选择器
selector = Selector.open();
//打开监听通道
serverSocketChannel = ServerSocketChannel.open();
//设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
//传入端口,并设定连接队列最大为1024
serverSocketChannel.socket().bind(new InetSocketAddress(port),1024);
//监听客户端请求
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//标记启动标志
started = true;
System.out.println("服务器已启动,端口号为:" + port);
} catch (IOException e){
e.printStackTrace();
System.exit(1);
}
}

在这里创建了选择器和监听通道,并将该监听通道注册到选择器上并选择其感兴趣的事件(accept)。后续其他接入的连接都将通过该 监听通道 传入。

然后就是写方法的实现:

1
2
3
4
5
6
7
8
9
复制代码    private void doWrite(SocketChannel channel, String response) throws IOException {
byte[] bytes = response.getBytes();
ByteBuffer wirteBuffer = ByteBuffer.allocate(bytes.length);
wirteBuffer.put(bytes);
//将写模式改为读模式
wirteBuffer.flip();
//写入管道
channel.write(wirteBuffer);
}

其次是当由事件传入时,即对连接进来的链接的处理方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
复制代码    private void handleInput(SelectionKey key) throws IOException{
//当该键可用时
if (key.isValid()){
if (key.isAcceptable()){
//返回该密钥创建的通道。
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
通过该通道获取链接进来的通道
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()){
//返回该密钥创建的通道。
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int readBytes = socketChannel.read(byteBuffer);
if (readBytes > 0){
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
String expression = new String(bytes, "UTF-8");
System.out.println("服务器收到的信息:" + expression);
//此处是为了区别打印在工作台上的数据是由客户端产生还是服务端产生
doWrite(socketChannel, "+++++" + expression + "+++++");
} else if(readBytes == 0){
//无数据,忽略
}else if (readBytes < 0){
//资源关闭
key.cancel();
socketChannel.close();
}
}
}
}

这里要说明的是,只要ServerSocketChannel及SocketChannel向Selector注册了特定的事件,Selector就会监控这些事件是否发生。
如在构造方法中有一通道serverSocketChannel注册了accept事件。当其就绪时就可以通过调用selector的selectorKeys()方法,访问”已选择键集“中的就绪通道。

压轴方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
复制代码    @Override
public void run() {
//循环遍历
while (started) {
try {
//当没有就绪事件时阻塞
selector.select();
//返回就绪通道的键
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
SelectionKey key;
while (iterator.hasNext()){
key = iterator.next();
//获取后必须移除,否则会陷入死循环
iterator.remove();
try {
//对就绪通道的处理方法,上述有描述
handleInput(key);
} catch (Exception e){
if (key != null){
key.cancel();
if (key.channel() != null) {
key.channel().close();
}
}
}
}
}catch (Throwable throwable){
throwable.printStackTrace();
}
}
}

此方法为服务端的主体方法。大致流程如下:

  1. 打开ServerSocketChannel,监听客户端连接
  2. 绑定监听端口,设置连接为非阻塞模式(阻塞模式下不能注册到选择器)
  3. 创建Reactor线程,创建选择器并启动线程
  4. 将ServerSocketChannel注册到Reactor线程中的Selector上,监听ACCEPT事件
  5. Selector轮询准备就绪的key
  6. Selector监听到新的客户端接入,处理新的接入请求,完成TCP三次握手,简历物理链路
  7. 设置客户端链路为非阻塞模式
  8. 将新接入的客户端连接注册到Reactor线程的Selector上,监听读操作,读取客户端发送的网络消息
    异步读取客户端消息到缓冲区
  9. 调用write将消息异步发送给客户端

NIO的小Demo之客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码public class ClientHandle implements Runnable{
//构造函数,构造时顺便绑定
public ClientHandle(String ip, int port){

}
//处理就绪通道
private void handleInput(SelectionKey key) throws IOException{

}
//写方法(与服务端的写方法一致)
private void doWrite(SocketChannel channel,String request) throws IOException{

}
//连接到服务端
private void doConnect() throws IOException{

}
//发送信息
public void sendMsg(String msg) throws Exception{

}
}

首先先看构造函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码    public ClientHandle(String ip,int port) {
this.host = ip;
this.port = port;
try{
//创建选择器
selector = Selector.open();
//打开监听通道
socketChannel = SocketChannel.open();
//如果为 true,则此通道将被置于阻塞模式;如果为 false,则此通道将被置于非阻塞模式
socketChannel.configureBlocking(false);
started = true;
}catch(IOException e){
e.printStackTrace();
System.exit(1);
}
}

接下来看对就绪通道的处理办法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
复制代码    private void handleInput(SelectionKey key) throws IOException{
if(key.isValid()){
SocketChannel sc = (SocketChannel) key.channel();
if(key.isConnectable()){
//这里的作用将在后面的代码(doConnect方法)说明
if(sc.finishConnect()){
System.out.println("已连接事件");
}
else{
System.exit(1);
}
}
//读消息
if(key.isReadable()){
//创建ByteBuffer,并开辟一个1k的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//读取请求码流,返回读取到的字节数
int readBytes = sc.read(buffer);
//读取到字节,对字节进行编解码
if(readBytes>0){
buffer.flip();
//根据缓冲区可读字节数创建字节数组
byte[] bytes = new byte[buffer.remaining()];
//将缓冲区可读字节数组复制到新建的数组中
buffer.get(bytes);
String result = new String(bytes,"UTF-8");
System.out.println("客户端收到消息:" + result);
}lse if(readBytes==0){
//忽略
}else if(readBytes<0){
//链路已经关闭,释放资源
key.cancel();
sc.close();
}
}
}
}

在run方法之前需先看下此方法的实现:

1
2
3
4
5
6
7
8
9
10
复制代码    private void doConnect() throws IOException{

if(socketChannel.connect(new InetSocketAddress(host,port))){
System.out.println("connect");
}
else {
socketChannel.register(selector, SelectionKey.OP_CONNECT);
System.out.println("register");
}
}

当SocketChannel工作于非阻塞模式下时,调用connect()时会立即返回:
如果连接建立成功则返回的是true(比如连接localhost时,能立即建立起连接),否则返回false。

在非阻塞模式下,返回false后,必须要在随后的某个地方调用finishConnect()方法完成连接。
当SocketChannel处于阻塞模式下时,调用connect()时会进入阻塞,直至连接建立成功或者发生IO错误时,才从阻塞状态中退出。

所以该代码在connect服务端后返回false(但还是有作用的),并在else语句将该通道注册在选择器上并选择connect事件。

客户端的run方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
复制代码    @Override
public void run() {
try{
doConnect();
}catch(IOException e){
e.printStackTrace();
System.exit(1);
}
//循环遍历selector
while(started){
try{
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
SelectionKey key ;
while(it.hasNext()){
key = it.next();
it.remove();
try{
handleInput(key);
}catch(Exception e){
if(key != null){
key.cancel();
if(key.channel() != null){
key.channel().close();
}
}
}
}
}catch(Exception e){
e.printStackTrace();
System.exit(1);
}
}
//selector关闭后会自动释放里面管理的资源
if(selector != null){
try{
selector.close();
}catch (Exception e) {
e.printStackTrace();
}
}

}

发送信息到服务端的方法:

1
2
3
4
5
复制代码    public void sendMsg(String msg) throws Exception{
//覆盖其之前感兴趣的事件(connect),将其更改为OP_READ
socketChannel.register(selector, SelectionKey.OP_READ);
doWrite(socketChannel, msg);
}

完整代码:

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码/**
* Created by innoyiya on 2018/8/20.
*/
public class Service {
private static int DEFAULT_POST = 12345;
private static ServerHandle serverHandle;
public static void start(){
start(DEFAULT_POST);
}

public static synchronized void start(int post) {
if (serverHandle != null){
serverHandle.shop();
}
serverHandle = new ServerHandle(post);
new Thread(serverHandle,"server").start();
}
}

服务端主体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
复制代码import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
* Created by innoyiya on 2018/8/20.
*/
public class ServerHandle implements Runnable{

private Selector selector;
private ServerSocketChannel serverSocketChannel;
private volatile boolean started;

public ServerHandle(int port){
try {
//创建选择器
selector = Selector.open();
//打开监听通道
serverSocketChannel = ServerSocketChannel.open();
//设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
//判定端口,并设定连接队列最大为1024
serverSocketChannel.socket().bind(new InetSocketAddress(port),1024);
//监听客户端请求
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//标记启动标志
started = true;
System.out.println("服务器已启动,端口号为:" + port);
} catch (IOException e){
e.printStackTrace();
System.exit(1);
}
}
public void shop(){
started = false;
}

private void doWrite(SocketChannel channel, String response) throws IOException {
byte[] bytes = response.getBytes();
ByteBuffer wirteBuffer = ByteBuffer.allocate(bytes.length);
wirteBuffer.put(bytes);
wirteBuffer.flip();
channel.write(wirteBuffer);
}

private void handleInput(SelectionKey key) throws IOException{
if (key.isValid()){
if (key.isAcceptable()){
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()){
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int readBytes = socketChannel.read(byteBuffer);
if (readBytes > 0){
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
String expression = new String(bytes, "UTF-8");
System.out.println("服务器收到的信息:" + expression);
doWrite(socketChannel, "+++++" + expression + "+++++");
} else if (readBytes < 0){
key.cancel();
socketChannel.close();
}
}
}
}

@Override
public void run() {
//循环遍历
while (started) {
try {
selector.select();
//System.out.println(selector.select());
Set<SelectionKey> keys = selector.selectedKeys();
//System.out.println(keys.size());
Iterator<SelectionKey> iterator = keys.iterator();
SelectionKey key;
while (iterator.hasNext()){
key = iterator.next();
iterator.remove();
try {
handleInput(key);
} catch (Exception e){
if (key != null){
key.cancel();
if (key.channel() != null) {
key.channel().close();
}
}
}
}
}catch (Throwable throwable){
throwable.printStackTrace();
}
}
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
复制代码/**
* Created by innoyiya on 2018/8/20.
*/
public class Client {
private static String DEFAULT_HOST = "localhost";
private static int DEFAULT_PORT = 12345;
private static ClientHandle clientHandle;
private static final String EXIT = "exit";

public static void start() {
start(DEFAULT_HOST, DEFAULT_PORT);
}

public static synchronized void start(String ip, int port) {
if (clientHandle != null){
clientHandle.stop();
}
clientHandle = new ClientHandle(ip, port);
new Thread(clientHandle, "Server").start();
}

//向服务器发送消息
public static boolean sendMsg(String msg) throws Exception {
if (msg.equals(EXIT)){
return false;
}
clientHandle.sendMsg(msg);
return true;
}

}

客户端主体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
复制代码import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
* Created by innoyiya on 2018/8/20.
*/

public class ClientHandle implements Runnable{
private String host;
private int port;
private Selector selector;
private SocketChannel socketChannel;
private volatile boolean started;

public ClientHandle(String ip,int port) {
this.host = ip;
this.port = port;
try{
//创建选择器
selector = Selector.open();
//打开监听通道
socketChannel = SocketChannel.open();
//如果为 true,则此通道将被置于阻塞模式;如果为 false,则此通道将被置于非阻塞模式
socketChannel.configureBlocking(false);
started = true;
}catch(IOException e){
e.printStackTrace();
System.exit(1);
}
}
public void stop(){
started = false;
}

private void handleInput(SelectionKey key) throws IOException{
if(key.isValid()){
SocketChannel sc = (SocketChannel) key.channel();
if(key.isConnectable()){
if(sc.finishConnect()){
System.out.println("已连接事件");
}
else{
System.exit(1);
}
}
//读消息
if(key.isReadable()){
//创建ByteBuffer,并开辟一个1M的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//读取请求码流,返回读取到的字节数
int readBytes = sc.read(buffer);
//读取到字节,对字节进行编解码
if(readBytes>0){
//将缓冲区当前的limit设置为position=0,用于后续对缓冲区的读取操作
buffer.flip();
//根据缓冲区可读字节数创建字节数组
byte[] bytes = new byte[buffer.remaining()];
//将缓冲区可读字节数组复制到新建的数组中
buffer.get(bytes);
String result = new String(bytes,"UTF-8");
System.out.println("客户端收到消息:" + result);
} else if(readBytes<0){
key.cancel();
sc.close();
}
}
}
}
//异步发送消息
private void doWrite(SocketChannel channel,String request) throws IOException{
byte[] bytes = request.getBytes();
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
writeBuffer.put(bytes);
//flip操作
writeBuffer.flip();
//发送缓冲区的字节数组
channel.write(writeBuffer);

}
private void doConnect() throws IOException{
if(socketChannel.connect(new InetSocketAddress(host,port))){
System.out.println("connect");
}
else {
socketChannel.register(selector, SelectionKey.OP_CONNECT);
System.out.println("register");
}
}
public void sendMsg(String msg) throws Exception{
//覆盖其之前感兴趣的事件,将其更改为OP_READ
socketChannel.register(selector, SelectionKey.OP_READ);
doWrite(socketChannel, msg);
}

@Override
public void run() {
try{
doConnect();
}catch(IOException e){
e.printStackTrace();
System.exit(1);
}
//循环遍历selector
while(started){
try{
selector.select();

Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
SelectionKey key ;
while(it.hasNext()){
key = it.next();
it.remove();
try{
handleInput(key);
}catch(Exception e){
if(key != null){
key.cancel();
if(key.channel() != null){
key.channel().close();
}
}
}
}
}catch(Exception e){
e.printStackTrace();
System.exit(1);
}
}
//selector关闭后会自动释放里面管理的资源
if(selector != null){
try{
selector.close();
}catch (Exception e) {
e.printStackTrace();
}
}

}
}

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码import java.util.Scanner;

/**
* Created by innoyiya on 2018/8/20.
*/
public class Test {
public static void main(String[] args) throws Exception {
Service.start();
Thread.sleep(1000);
Client.start();
while(Client.sendMsg(new Scanner(System.in).nextLine()));
}
}

控制台打印:

1
2
3
4
5
6
7
8
9
复制代码服务器已启动,端口号为:12345
register
已连接事件
1234
服务器收到的信息:1234
客户端收到消息:+++++1234+++++
5678
服务器收到的信息:5678
客户端收到消息:+++++5678+++++

如有不妥之处,请告诉我。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

惰性求值——lodash源码解读

发表于 2018-08-19

前言

lodash受欢迎的一个原因,是其优异的计算性能。而其性能能有这么突出的表现,很大部分就来源于其使用的算法——惰性求值。
本文将讲述lodash源码中,惰性求值的原理和实现。

一、惰性求值的原理分析

惰性求值(Lazy Evaluation),又译为惰性计算、懒惰求值,也称为传需求调用(call-by-need),是计算机编程中的一个概念,它的目的是要最小化计算机要做的工作。
惰性求值中的参数直到需要时才会进行计算。这种程序实际上是从末尾开始反向执行的。它会判断自己需要返回什么,并继续向后执行来确定要这样做需要哪些值。

以下是How to Speed Up Lo-Dash ×100? Introducing Lazy Evaluation.(如何提升Lo-Dash百倍算力?惰性计算的简介)文中的示例,形象地展示惰性求值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码function priceLt(x) {
return function(item) { return item.price < x; };
}
var gems = [
{ name: 'Sunstone', price: 4 },
{ name: 'Amethyst', price: 15 },
{ name: 'Prehnite', price: 20},
{ name: 'Sugilite', price: 7 },
{ name: 'Diopside', price: 3 },
{ name: 'Feldspar', price: 13 },
{ name: 'Dioptase', price: 2 },
{ name: 'Sapphire', price: 20 }
];

var chosen = _(gems).filter(priceLt(10)).take(3).value();

程序的目的,是对数据集gems进行筛选,选出3个price小于10的数据。

1.1 一般的做法

如果抛开lodash这个工具库,让你用普通的方式实现var chosen = _(gems).filter(priceLt(10)).take(3);那么,可以用以下方式:

_(gems)拿到数据集,缓存起来。

再执行filter方法,遍历gems数组(长度为10),取出符合条件的数据:

1
2
3
4
5
6
复制代码[
{ name: 'Sunstone', price: 4 },
{ name: 'Sugilite', price: 7 },
{ name: 'Diopside', price: 3 },
{ name: 'Dioptase', price: 2 }
]

然后,执行take方法,提取前3个数据。

1
2
3
4
5
复制代码[
{ name: 'Sunstone', price: 4 },
{ name: 'Sugilite', price: 7 },
{ name: 'Diopside', price: 3 }
]

总共遍历的次数为:10+3。
执行的示例图如下:

普通计算

1.2 惰性求值做法

普通的做法存在一个问题:每个方法各做各的事,没有协调起来浪费了很多资源。

如果能先把要做的事,用小本本记下来😎,然后等到真正要出数据时,再用最少的次数达到目的,岂不是更好。

惰性计算就是这么做的。

以下是实现的思路:

  • _(gems)拿到数据集,缓存起来
  • 遇到filter方法,先记下来
  • 遇到take方法,先记下来
  • 遇到value方法,说明时机到了
  • 把小本本拿出来,看下要求:要取出3个数,price<10
  • 使用filter方法里的判断方法priceLt对数据进行逐个裁决
1
2
3
4
5
6
7
8
9
10
复制代码[
{ name: 'Sunstone', price: 4 }, => priceLt裁决 => 符合要求,通过 => 拿到1个
{ name: 'Amethyst', price: 15 }, => priceLt裁决 => 不符合要求
{ name: 'Prehnite', price: 20}, => priceLt裁决 => 不符合要求
{ name: 'Sugilite', price: 7 }, => priceLt裁决 => 符合要求,通过 => 拿到2个
{ name: 'Diopside', price: 3 }, => priceLt裁决 => 符合要求,通过 => 拿到3个 => 够了,收工!
{ name: 'Feldspar', price: 13 },
{ name: 'Dioptase', price: 2 },
{ name: 'Sapphire', price: 20 }
]

如上所示,一共只执行了5次,就把结果拿到。

执行的示例图如下:

普通计算

1.3 小结

从上面的例子可以得到惰性计算的特点:

  • 延迟计算,把要做的计算先缓存,不执行
  • 数据管道,逐个数据通过“裁决”方法,在这个类似安检的过程中,进行过关的操作,最后只留下符合要求的数据
  • 触发时机,方法缓存,那么就需要一个方法来触发执行。lodash就是使用value方法,通知真正开始计算

二、惰性求值的实现

依据上述的特点,我将lodash的惰性求值实现进行抽离为以下几个部分:

2.1 实现延迟计算的缓存

实现_(gems)。我这里为了语义明确,采用lazy(gems)代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码var MAX_ARRAY_LENGTH = 4294967295; // 最大的数组长度

// 缓存数据结构体
function LazyWrapper(value){
this.__wrapped__ = value;
this.__iteratees__ = [];
this.__takeCount__ = MAX_ARRAY_LENGTH;
}

// 惰性求值的入口
function lazy(value){
return new LazyWrapper(value);
}
  • this.__wrapped__ 缓存数据
  • this.__iteratees__ 缓存数据管道中进行“裁决”的方法
  • this.__takeCount__ 记录需要拿的符合要求的数据集个数

这样,一个基本的结构就完成了。

2.2 实现filter方法

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码var LAZY_FILTER_FLAG = 1; // filter方法的标记

// 根据 筛选方法iteratee 筛选数据
function filter(iteratee){
this.__iteratees__.push({
'iteratee': iteratee,
'type': LAZY_FILTER_FLAG
});
return this;
}

// 绑定方法到原型链上
LazyWrapper.prototype.filter = filter;

filter方法,将裁决方法iteratee缓存起来。这里有一个重要的点,就是需要记录iteratee的类型type。

因为在lodash中,还有map等筛选数据的方法,也是会传入一个裁决方法iteratee。由于filter方法和map方法筛选方式不同,所以要用type进行标记。

这里还有一个技巧:

1
2
3
4
5
6
7
8
9
复制代码(function(){
// 私有方法
function filter(iteratee){
/* code */
}

// 绑定方法到原型链上
LazyWrapper.prototype.filter = filter;
})();

原型上的方法,先用普通的函数声明,然后再绑定到原型上。如果工具内部需要使用filter,则使用声明好的私有方法。

这样的好处是,外部如果改变LazyWrapper.prototype.filter,对工具内部,是没有任何影响的。

2.3 实现take方法

1
2
3
4
5
6
7
复制代码// 截取n个数据
function take(n){
this.__takeCount__ = n;
return this;
};

LazyWrapper.prototype.take = take;

2.4 实现value方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
复制代码// 惰性求值
function lazyValue(){
var array = this.__wrapped__;
var length = array.length;
var resIndex = 0;
var takeCount = this.__takeCount__;
var iteratees = this.__iteratees__;
var iterLength = iteratees.length;
var index = -1;
var dir = 1;
var result = [];

// 标签语句
outer:
while(length-- && resIndex < takeCount){
// 外层循环待处理的数组
index += dir;

var iterIndex = -1;
var value = array[index];

while(++iterIndex < iterLength){
// 内层循环处理链上的方法
var data = iteratees[iterIndex];
var iteratee = data.iteratee;
var type = data.type;
var computed = iteratee(value);

// 处理数据不符合要求的情况
if(!computed){
if(type == LAZY_FILTER_FLAG){
continue outer;
}else{
break outer;
}
}
}

// 经过内层循环,符合要求的数据
result[resIndex++] = value;
}

return result;
}

LazyWrapper.prototype.value = lazyValue;

这里的一个重点就是:标签语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
复制代码
outer:
while(length-- && resIndex < takeCount){
// 外层循环待处理的数组
index += dir;

var iterIndex = -1;
var value = array[index];

while(++iterIndex < iterLength){
// 内层循环处理链上的方法
var data = iteratees[iterIndex];
var iteratee = data.iteratee;
var type = data.type;
var computed = iteratee(value);

// 处理数据不符合要求的情况
if(!computed){
if(type == LAZY_FILTER_FLAG){
continue outer;
}else{
break outer;
}
}
}

// 经过内层循环,符合要求的数据
result[resIndex++] = value;
}

当前方法的数据管道实现,其实就是内层的while循环。通过取出缓存在iteratees中的裁决方法取出,对当前数据value进行裁决。

如果裁决结果是不符合,也即为false。那么这个时候,就没必要用后续的裁决方法进行判断了。而是应该跳出当前循环。

而如果用break跳出内层循环后,外层循环中的result[resIndex++] = value;还是会被执行,这是我们不希望看到的。

应该一次性跳出内外两层循环,并且继续外层循环,才是正确的。

标签语句,刚好可以满足这个要求。

2.5 小检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码
var testArr = [1, 19, 30, 2, 12, 5, 28, 4];

lazy(testArr)
.filter(function(x){
console.log('check x='+x);
return x < 10
})
.take(2)
.value();

// 输出如下:
check x=1
check x=19
check x=30
check x=2

// 得到结果: [1, 2]

2.6 小结

整个惰性求值的实现,重点还是在数据管道这块。以及,标签语句在这里的妙用。其实实现的方式,不只当前这种。但是,要点还是前面讲到的三个。掌握精髓,变通就很容易了。

结语

惰性求值,是我在阅读lodash源码中,发现的最大闪光点。

当初对惰性求值不甚理解,想看下javascript的实现,但网上也只找到上文提到的一篇文献。

那剩下的选择,就是对lodash进行剖离分析。也因为这,才有本文的诞生。

希望这篇文章能对你有所帮助。如果可以的话,给个star :)

最后,附上本文实现的简易版lazy.js完整源码:
github.com/wall-wxk/bl…


喜欢我文章的朋友,可以通过以下方式关注我:

  • 「star」 或 「watch」 我的GitHub blog
  • RSS订阅我的个人博客:王先生的基地
    wall的个人博客

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…886887888…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%