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

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


  • 首页

  • 归档

  • 搜索

Android 带你探究 LayoutInflater

发表于 2020-10-21

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


前言

  • 在 Android UI 开发中,经常需要用到 LayoutInflater 类,它的基本作用是将 xml 布局文件解析成 View / View 树。除了基本的布局解析功能,LayoutInflater 还可以用于实现 动态换肤、视图转换、属性转换 等需求。
  • 在这篇文章里,我将带你理解 LayoutInflater 的源码。另外,文末的应试建议也不要错过哦,如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

相关文章

  • 《Android | 一个进程有多少个 Context 对象(答对的不多)》
  • 《Android | 带你探究 LayoutInflater 布局解析原理》
  • 《Android | View & Fragment & Window 的 getContext() 一定返回 Activity 吗?》
  • 《Android | 说说从 android:text 到 TextView 的过程》

目录


  1. 获取 LayoutInflater 对象

1
2
3
4
kotlin复制代码@SystemService(Context.LAYOUT_INFLATER_SERVICE)
public abstract class LayoutInflater {
...
}

首先,你要获得 LayoutInflater 的实例,由于 LayoutInflater 是抽象类,不能直接创建对象,因此这里总结一下获取 LayoutInflater 对象的方法。具体如下:

  • 1. View.inflate(...)
1
2
3
4
less复制代码public static View inflate(Context context, @LayoutRes int resource, ViewGroup root) {
LayoutInflater factory = LayoutInflater.from(context);
return factory.inflate(resource, root);
}
  • 2. Activity#getLayoutInflater()
1
2
3
scss复制代码public LayoutInflater getLayoutInflater() {
return getWindow().getLayoutInflater();
}
  • 3. PhoneWindow#getLayoutInflater()
1
2
3
4
5
6
7
8
9
10
csharp复制代码private LayoutInflater mLayoutInflater;

public PhoneWindow(Context context) {
super(context);
mLayoutInflater = LayoutInflater.from(context);
}

public LayoutInflater getLayoutInflater() {
return mLayoutInflater;
}
  • 4. LayoutInflater#from(Context)
1
2
3
4
5
6
7
java复制代码public static LayoutInflater from(Context context) {
LayoutInflater LayoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
if (LayoutInflater == null) {
throw new AssertionError("LayoutInflater not found.");
}
return LayoutInflater;
}

可以看到,前面 3 种方法最后走到LayoutInflater#from(Context),这其实也是平时用的最多的方式。现在,我们看getSystemService(...)内的逻辑:

ContextImpl.java

1
2
3
4
typescript复制代码@Override
public Object getSystemService(String name) {
return SystemServiceRegistry.getSystemService(this, name);
}

SystemServiceRegistry.java

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
typescript复制代码private static final Map<String, ServiceFetcher<?>> SYSTEM_SERVICE_FETCHERS = new ArrayMap<String, ServiceFetcher<?>>();

static {
...
1. 注册 Context.LAYOUT_INFLATER_SERVICE 与服务获取器
关注点:CachedServiceFetcher
关注点:PhoneLayoutInflater
registerService(Context.LAYOUT_INFLATER_SERVICE, LayoutInflater.class, new CachedServiceFetcher<LayoutInflater>() {
@Override
public LayoutInflater createService(ContextImpl ctx) {
注意:getOuterContext(),参数使用的是 ContextImpl 的代理对象,一般是 Activity
return new PhoneLayoutInflater(ctx.getOuterContext());
}});
...
}

2. 根据 name 获取服务对象
public static Object getSystemService(ContextImpl ctx, String name) {
ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
return fetcher != null ? fetcher.getService(ctx) : null;
}

注册服务与服务获取器
private static <T> void registerService(String serviceName, Class<T> serviceClass, ServiceFetcher<T> serviceFetcher) {
SYSTEM_SERVICE_FETCHERS.put(serviceName, serviceFetcher);
}

3. 服务获取器创建对象
static abstract interface ServiceFetcher<T> {
T getService(ContextImpl ctx);
}

可以看到,ContextImpl 内部通过 SystemServiceRegistry 来获取服务对象,逻辑并不复杂:

1、静态代码块注册了 name - ServiceFetcher 的映射

2、根据 name 获得 ServiceFetcher

3、ServiceFetcher 创建对象

ServiceFetcher 的子类有三种类型,它们的getSystemService()都是线程安全的,主要差别体现在 单例范围,具体如下:

ServiceFetcher子类 单例范围 描述 举例
CachedServiceFetcher ContextImpl域 / LayoutInflater、LocationManager等(最多)
StaticServiceFetcher 进程域 / InputManager、JobScheduler等
StaticApplicationContextServiceFetcher 进程域 使用 ApplicationContext 创建服务 ConnectivityManager

对于 LayoutInflater 来说,服务获取器是 CachedServiceFetcher 的子类,最终获得的服务对象为 PhoneLayoutInflater。

这里有一个重点,这句代码非常隐蔽,要留意:

1
arduino复制代码 return new PhoneLayoutInflater(ctx.getOuterContext());

LayoutInflater.java

1
2
3
4
5
6
7
8
csharp复制代码public Context getContext() {
return mContext;
}

protected LayoutInflater(Context context) {
mContext = context;
initPrecompiledViews();
}

可以看到,实例化 PhoneLayoutInflater 时使用了 getOuterContext(),也就是参数使用的是 ContextImpl 的代理对象,一般就是 Activity 了。也就是说,在 Activity / Fragment / View / Dialog 中,获取LayoutInflater#getContext(),返回的就是 Activity。

小结:

  • 1、获取 LayoutInflater 对象只有通过LayoutInflater.from(context),内部委派给Context#getSystemService(...),线程安全;
  • 2、使用同一个 Context 对象,获得的 LayoutInflater 是单例;
  • 3、LayoutInflater 的实现类是 PhoneLayoutInflater。

  1. inflate(…) 主流程源码分析

上一节,我们分析了获取 LayoutInflater 对象的过程,现在我们可以调用inflate()进行布局解析了。LayoutInflater#inflate(...)有多个重载方法,最终都会调用到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
less复制代码public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
1. 解析预编译的布局
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
2. 构造 XmlPull 解析器
XmlResourceParser parser = res.getLayout(resource);
try {
3. 执行解析
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
  1. tryInflatePrecompiled(...)是解析预编译的布局,我后文再说;
  2. 构造 XmlPull 解析器 XmlResourceParser
  3. 执行解析,是解析的主流程

提示: 在这里,我剔除了与 XmlPull 相关的代码,只保留了我们关心的逻辑:

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
java复制代码public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
1. 结果变量
View result = root;
2. 最外层的标签
final String name = parser.getName();
3. <merge>
if (TAG_MERGE.equals(name)) {
3.1 异常
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
3.2 递归执行解析
rInflate(parser, root, inflaterContext, attrs, false);
} else {
4.1 创建最外层 View
final View temp = createViewFromTag(root, name, inflaterContext, attrs);

ViewGroup.LayoutParams params = null;

if (root != null) {
4.2 创建匹配的 LayoutParams
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
4.3 如果 attachToRoot 为 false,设置LayoutParams
temp.setLayoutParams(params);
}
}

5. 以 temp 为 root,递归执行解析
rInflateChildren(parser, temp, attrs, true);

6. attachToRoot 为 true,addView()
if (root != null && attachToRoot) {
root.addView(temp, params);
}

7. root 为空 或者 attachToRoot 为 false,返回 temp
if (root == null || !attachToRoot) {
result = temp;
}
}
return result;
}

-> 3.2
void rInflate(XmlPullParser parser, View parent, Context context, AttributeSet attrs, boolean finishInflate) {
while(parser 未结束) {
if (TAG_INCLUDE.equals(name)) {
1) <include>
if (parser.getDepth() == 0) {
throw new InflateException("<include /> cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) {
2) <merge>
throw new InflateException("<merge /> must be the root element");
} else {
3) 创建 View
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
4) 递归
rInflateChildren(parser, view, attrs, true);
5) 添加到视图树
viewGroup.addView(view, params);
}
}
}

-> 5. 递归执行解析
final void rInflateChildren(XmlPullParser parser, View parent, AttributeSet attrs,
boolean finishInflate) throws XmlPullParserException, IOException {
rInflate(parser, parent, parent.getContext(), attrs, finishInflate);
}

关于 <include> & <merge>,我后文再说。对于参数 root & attachToRoot的不同情况,对应得到的输出不同,我总结为一张图:


  1. createViewFromTag():从 到 View

在 第 2 节 主流程代码中,用到了 createViewFromTag(),它负责由 创建 View 对象:

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
ini复制代码已简化
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {

1. 应用 ContextThemeWrapper 以支持 android:theme
if (!ignoreThemeAttr) {
final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
final int themeResId = ta.getResourceId(0, 0);
if (themeResId != 0) {
context = new ContextThemeWrapper(context, themeResId);
}
ta.recycle();
}

2. 先使用 Factory2 / Factory 实例化 View,相当于拦截
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}

3. 使用 mPrivateFactory 实例化 View,相当于拦截
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}

4. 调用自身逻辑
if (view == null) {
if (-1 == name.indexOf('.')) {
4.1 <tag> 中没有.
view = onCreateView(parent, name, attrs);
} else {
4.2 <tag> 中有.
view = createView(name, null, attrs);
}
}
return view;
}

-> 4.2 <tag> 中有.

构造器方法签名
static final Class<?>[] mConstructorSignature = new Class[] {
Context.class, AttributeSet.class};

缓存 View 构造器的 Map
private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
new HashMap<String, Constructor<? extends View>>();

public final View createView(String name, String prefix, AttributeSet attrs) {
1) 缓存的构造器
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (constructor != null && !verifyClassLoader(constructor)) {
constructor = null;
sConstructorMap.remove(name);
}
Class<? extends View> clazz = null;
2) 新建构造器
if (constructor == null) {
2.1) 拼接 prefix + name 得到类全限定名
clazz = mContext.getClassLoader().loadClass(prefix != null ? (prefix + name) : name).asSubclass(View.class);
2.2) 创建构造器对象
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
2.3) 缓存到 Map
sConstructorMap.put(name, constructor);
}

3) 实例化 View 对象
final View view = constructor.newInstance(args);

4) ViewStub 特殊处理
if (view instanceof ViewStub) {
// Use the same context when inflating ViewStub later.
final ViewStub viewStub = (ViewStub) view;
viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
}
return view;
}

----------------------------------------------------

-> 4.1 <tag> 中没有.

PhoneLayoutInflater.java

private static final String[] sClassPrefixList = {
"android.widget.",
"android.webkit.",
"android.app."
};

已简化
protected View onCreateView(String name, AttributeSet attrs) {
for (String prefix : sClassPrefixList) {
View view = createView(name, prefix, attrs);
if (view != null) {
return view;
}
}
return super.onCreateView(name, attrs);
}
    1. 应用 ContextThemeWrapper 以支持android:theme,这是处理针对特定 View 设置主题;
    1. 使用 Factory2 / Factory 实例化 View,相当于拦截,我后文再说;
    1. 使用 mPrivateFactory 实例化View,相当于拦截,我后文再说;
    1. 调用 LayoutInflater 自身逻辑,分为:
      • 4.1 中没有.,这是处理<linearlayout>、<TextView>等标签,依次尝试拼接 3 个路径前缀,进入 3.2 实例化 View
      • 4.2 中有.,真正实例化 View 的地方,主要分为 4 步:
        1
        2
        3
        4
        sql复制代码1) 缓存的构造器
        2) 新建构造器
        3) 实例化 View 对象
        4) ViewStub 特殊处理

小结:

  • 使用 Factory2 接口可以拦截实例化 View 对象的步骤;
  • 实例化 View 的优先顺序为:Factory2 / Factory -> mPrivateFactory -> PhoneLayoutInflater;
  • 使用反射实例化 View 对象,同时构造器对象做了缓存;


  1. Factory2 接口

现在我们来讨论Factory2接口,上一节提到,Factory2可以拦截实例化 View 的步骤,在 LayoutInflater 中有两个方法可以设置:
LayoutInflater.java

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
ini复制代码方法1:
public void setFactory2(Factory2 factory) {
if (mFactorySet) {
关注点:禁止重复设置
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}

方法2 @hide
public void setPrivateFactory(Factory2 factory) {
if (mPrivateFactory == null) {
mPrivateFactory = factory;
} else {
mPrivateFactory = new FactoryMerger(factory, factory, mPrivateFactory, mPrivateFactory);
}
}

现在,我们来看源码中哪里调用这两个方法:

4.1 setFactory2()

在 AppCompatActivity & AppCompatDialog 中,相关源码简化如下:

AppCompatDialog.java

1
2
3
4
5
6
7
scss复制代码@Override
protected void onCreate(Bundle savedInstanceState) {
设置 Factory2
getDelegate().installViewFactory();
super.onCreate(savedInstanceState);
getDelegate().onCreate(savedInstanceState);
}

AppCompatActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scss复制代码@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
final AppCompatDelegate delegate = getDelegate();
设置 Factory2
delegate.installViewFactory();
delegate.onCreate(savedInstanceState);
夜间主题相关
if (delegate.applyDayNight() && mThemeId != 0) {
if (Build.VERSION.SDK_INT >= 23) {
onApplyThemeResource(getTheme(), mThemeId, false);
} else {
setTheme(mThemeId);
}
}
super.onCreate(savedInstanceState);
}

AppCompatDelegateImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
csharp复制代码public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(mContext);
if (layoutInflater.getFactory() == null) {
关注点:设置 Factory2 = this(AppCompatDelegateImpl)
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else {
if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
+ " so we can not install AppCompat's");
}
}
}

LayoutInflaterCompat.java

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码public static void setFactory2(@NonNull LayoutInflater inflater, @NonNull LayoutInflater.Factory2 factory) {
inflater.setFactory2(factory);

if (Build.VERSION.SDK_INT < 21) {
final LayoutInflater.Factory f = inflater.getFactory();
if (f instanceof LayoutInflater.Factory2) {
forceSetFactory2(inflater, (LayoutInflater.Factory2) f);
} else {
forceSetFactory2(inflater, factory);
}
}
}

可以看到,在 AppCompatDialog & AppCompatActivity 初始化时,都通过setFactory2()设置了拦截器,设置的对象是 AppCompatDelegateImpl:

AppCompatDelegateImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
less复制代码已简化
class AppCompatDelegateImpl extends AppCompatDelegate
implements MenuBuilder.Callback, LayoutInflater.Factory2 {

@Override
public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
return createView(parent, name, context, attrs);
}

@Override
public View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs) {
if (mAppCompatViewInflater == null) {
mAppCompatViewInflater = new AppCompatViewInflater();
}
}
委托给 AppCompatViewInflater 处理
return mAppCompatViewInflater.createView(...)
}

AppCompatViewInflater 与 LayoutInflater 的核心流程差不多,主要差别是前者会将<TextView>等标签解析为AppCompatTextView对象:

AppCompatViewInflater.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码final View createView(...) {
...

switch (name) {
case "TextView":
view = createTextView(context, attrs);
break;
...
default:
view = createView(context, name, attrs);
}
return view;
}

@NonNull
protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
return new AppCompatTextView(context, attrs);
}

4.2 setPrivateFactory()

setPrivateFactory()是 hide 方法,在 Activity 中调用,相关源码简化如下:

Activity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码final FragmentController mFragments = FragmentController.createController(new HostCallbacks());

final void attach(Context context, ActivityThread aThread,...) {
attachBaseContext(context);
mFragments.attachHost(null /*parent*/);

mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(this);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
关注点:设置 Factory2
mWindow.getLayoutInflater().setPrivateFactory(this);
...
}

可以看到,这里设置的 Factory2 其实就是 Activity 本身(this),这说明 Activity 也实现了 Factory2 :

1
2
3
4
5
6
7
8
9
10
scala复制代码public class Activity extends ContextThemeWrapper implements LayoutInflater.Factory2,...{

public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (!"fragment".equals(name)) {
return onCreateView(name, context, attrs);
}

return mFragments.onCreateView(parent, name, context, attrs);
}
}

原来<fragment>标签的处理是在这里设置的 Factory2 处理的,关于FragmentController#onCreateView(...)内部如何生成 Fragment 以及返回 View 的逻辑,我们在这篇文章里讨论,请关注:《你真的懂 Fragment 吗?—— AndroidX Fragment 核心原理分析》。

小结:

  • 使用 setFactory2() 和 setPrivateFactory() 可以设置 Factory2 接口(拦截器),其中同一个 LayoutInflater 的setFactory2()不能重复设置,setPrivateFactory() 是 hide 方法;
  • AppCompatDialog & AppCompatActivity 初始化时,调用了setFactory2(),会将一些<tag>转换为AppCompat版本;
  • Activity 初始化时,调用了setPrivateFactory(),用来处理<fragment>标签。


  1. & &

这一节,我们专门来讨论<include> & <merge> & <ViewStub>的用法与注意事项:

5.1 布局重用

5.2 降低布局层次

5.3 布局懒加载

Editting…


  1. 总结

  • 应试建议
  1. 理解 获取 LayoutInflater 对象的方式,知晓 3 种 getSystemService() 单例域的区别,其中 Context 域是最多的,LayoutInflater 是属于 Context 域。
  2. 重点理解 LayoutInflater 布局解析的 核心流程;
  3. Factory2 是一个很实用的接口,需要掌握通过 setFactory2() 拦截布局解析 的技巧。


推荐阅读

  • 密码学 | Base64是加密算法吗?
  • 算法面试题 | 回溯算法解题框架
  • 算法面试题 | 链表问题总结
  • Java | 带你理解 ServiceLoader 的原理与设计思想
  • Android | 面试必问的 Handler,你确定不看看?
  • Android | 带你理解 NativeAllocationRegistry 的原理与设计思想
  • 计算机组成原理 | Unicode 和 UTF-8是什么关系?
  • 计算机组成原理 | 为什么浮点数运算不精确?(阿里笔试)
  • 计算机网络 | 图解 DNS & HTTPDNS 原理

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!

本文转载自: 掘金

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

推荐4款个人珍藏的IDEA插件!帮你写出不那么差的代码

发表于 2020-10-21

目前的话,我(Guide哥)是在 Github 开源了两个轮子,一个简易的 RPC 框架,一个轻量级的 HTTP 框架。代码结构清晰,实现优雅(这个自夸就很不要脸),感兴趣的小伙伴可以看一下。


在写代码的时候,有几个 IDEA 插件对于我规范代码以及更高效地完成编码工作有奇效。

那今天就简单聊聊我平时写代码过程中,有哪些 IDEA 插件对我帮助最大吧!

Codota:代码智能提示

我一直在用的一个插件,可以说非常好用了(我身边的很多大佬平时写代码也会用这个插件)。

Codota 这个插件用于智能代码补全,它基于数百万Java程序,能够根据程序上下文提示补全代码。相比于IDEA自带的智能提示来说,Codota 的提示更加全面一些。

如果你觉得 IDEA 插件安装的太多比较卡顿的话,不用担心!Codota 插件还有一个对应的在线网站(www.codota.com/code),在这个网站上你可以根据代码关键字搜索相关代码示例,非常不错!

我在工作中经常会用到,说实话确实给我带来了很大便利,比如我们搜索 Files.readAllLines相关的代码,搜索出来的结果如下图所示:


另外,Codota 插件的基础功能都是免费的。你的代码也不会被泄露,这点你不用担心。

简单来看看 Codota 插件的骚操作吧!

代码智能补全

我们使用HttpUrlConnection 建立一个网络连接是真的样的:


我们创建线程池现在变成下面这样:


上面只是为了演示这个插件的强大,实际上创建线程池不推荐使用这种方式, 推荐使用 ThreadPoolExecutor 构造函数创建线程池。我下面要介绍的一个阿里巴巴的插件-Alibaba Java Code Guidelines 就检测出来了这个问题,所以,Executors下面用波浪线标记了出来。

代码智能搜索

除了,在写代码的时候智能提示之外。你还可以直接选中代码然后搜索相关代码示例。


Alibaba Java Code Guidelines:阿里巴巴 Java 代码规范


阿里巴巴 Java 代码规范,对应的Github地址为:github.com/alibaba/p3c 。非常推荐安装!

安装完成之后建议将与语言替换成中文,提示更加友好一点。


根据官方描述:

目前这个插件实现了开发手册中的的53条规则,大部分基于PMD实现,其中有4条规则基于IDEA实现,并且基于IDEA Inspection实现了实时检测功能。部分规则实现了Quick Fix功能,对于可以提供Quick Fix但没有提供的,我们会尽快实现,也欢迎有兴趣的同学加入进来一起努力。 目前插件检测有两种模式:实时检测、手动触发。

上述提到的开发手册也就是在Java开发领域赫赫有名的《阿里巴巴Java开发手册》。

手动配置检测规则

你还可以手动配置相关 inspection规则:

使用效果

这个插件会实时检测出我们的代码不匹配它的规则的地方,并且会给出修改建议。比如我们按照下面的方式去创建线程池的话,这个插件就会帮我们检测出来,如下图所示。


这个可以对应上 《阿里巴巴Java开发手册》 这本书关于创建线程池的方式说明。


CheckStyle: Java代码格式规范。


为何需要CheckStyle插件?

CheckStyle 几乎是 Java 项目开发必备的一个插件了,它会帮助我们检查 Java 代码的格式是否有问题比如变量命名格式是否有问题、某一行代码的长度是否过长等等。

在项目上,通过项目开发人员自我约束来规范代码格式必然是不靠谱的! 因此,我们非常需要这样一款工具来帮助我们规范代码格式。

如果你看过我写的轮子的话,可以发现我为每一个项目都集成了 CheckStyle,并且设置了 Git Commit 钩子,保证在提交代码之前代码格式没有问题。

Guide哥造的轮子(代码简洁,结构清晰,欢迎学习,欢迎一起完善):

  1. guide-rpc-framework :A custom RPC framework implemented by Netty+Kyro+Zookeeper.(一款基于 Netty+Kyro+Zookeeper 实现的自定义 RPC 框架-附详细实现过程和相关教程)
  2. jsoncat :仿 Spring Boot 但不同于 Spring Boot 的一个轻量级的 HTTP 框架

Git 钩子: Git 能在特定的重要动作比如commit、push发生时触发自定义脚本。 钩子都被存储在 Git 目录下的 hooks 子目录中。 也即绝大部分项目中的 .git/hooks 。

如何在Maven/Gradle项目中集成 Checksytle?

一般情况下,我们都是将其集成在项目中,并设置相应的 Git 钩子。网上有相应的介绍文章,这里就不多提了。

如果你觉得网上的文章不直观的话,可以参考我上面提到了两个轮子:

  1. guide-rpc-framework :Maven项目集成 Checksytle。
  2. jsoncat :Gradle项目集成 Checksytle。

如果你在项目中集成了 Checksytle 的话,每次检测会生成一个 HTML格式的文件告诉你哪里的代码格式不对,这样看着非常不直观。通过 Checksytle插件的话可以非常直观的将项目中存在格式问题的地方显示出来。


如果你只是自己在本地使用,并不想在项目中集成 Checksytle 的话也可以,只需要下载一个 Checksytle插件就足够了。

如何安装?

我们直接在 IDEA 的插件市场即可找到这个插件。我这里已经安装好了。


安装插件之后重启 IDEA,你会发现就可以在底部菜单栏找到 CheckStyle 了。

如何自定义检测规则?

如果你需要自定义代码格式检测规则的话,可以按照如下方式进行配置(你可以导入用于自定义检测规则的CheckStyle.xml文件)。

使用效果

配置完成之后,按照如下方式使用这个插件!

run-check-style

run-check-style

可以非常清晰的看到:CheckStyle 插件已经根据我们自定义的规则将项目中的代码存在格式问题的地方都检测了出来。

SonarLint:帮你优化代码

SonarLint 帮助你发现代码的错误和漏洞,就像是代码拼写检查器一样,SonarLint 可以实时显示出代码的问题,并提供清晰的修复指导,以便你提交代码之前就可以解决它们。


并且,很多项目都集成了 SonarQube,SonarLint 可以很方便地与 SonarQube 集成。

我的开源项目推荐

  1. JavaGuide :「Java学习+面试指南」一份涵盖大部分Java程序员所需要掌握的核心知识。准备 Java 面试,首选 JavaGuide!
  2. guide-rpc-framework :A custom RPC framework implemented by Netty+Kyro+Zookeeper.(一款基于 Netty+Kyro+Zookeeper 实现的自定义 RPC 框架-附详细实现过程和相关教程)
  3. jsoncat :仿 Spring Boot 但不同于 Spring Boot 的一个轻量级的 HTTP 框架
  4. programmer-advancement :程序员应该有的一些好习惯+面试必知事项!
  5. springboot-guide :Not only Spring Boot but also important knowledge of Spring(不只是SpringBoot还有Spring重要知识点)
  6. awesome-java :Collection of awesome Java project on Github(Github 上非常棒的 Java 开源项目集合).

本文使用 mdnice 排版

本文转载自: 掘金

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

忘记MySQL密码怎么办?一招教你搞定!

发表于 2020-10-20

在安装完 MySQL 或者是在使用 MySQL 时,最尴尬的就是忘记密码了,墨菲定律也告诉我们,如果一件事有可能出错,那么它一定会出错。那如果我们不小心忘记了 MySQL 的密码,该如何处理呢?别着急,本文教你一招搞定。

1.修改 MySQL 配置文件

首先我们需要打开 MySQL 的配置文件,如果是 Windows 系统的话,MySQL 的配置文件是 my.ini,它在 MySQL 的默认安装目录下;如果是 MacOS 系统的话,配置文件名是 my.cnf,它的路径是 /etc/my.cnf。

以 MacOS 为例,我们需要在配置文件的 [mysqld] 后面添加“skip-grant-tables”,意思是设置跳过权限验证,如下图所示:
image.png

MacOS 可以直接使用“sudo vim /etc/my.cnf”命令进行配置文件的编辑。

注意事项

在 MacOS 中如果找不到 MySQL 的配置文件,可以直接在 /etc 目录下创建 my.cnf,添加如下内容:

[client]
default-character-set=utf8

[mysqld]
bind-address = 127.0.0.1
character-set-server=utf8
skip-grant-tables

当然如果有其他配置项的话,也可以自行修改。

2.重启 MySQL

在修改完配置文件之后,我们需要重启 MySQL 服务才能让配置生效。

如果是 Windows 系统,我们可以先关闭再启动 MySQL,执行命令如下:

net stop mysql
net start mysql

如果是 Linux 系统,可以直接使用命令重启:

service mysql restart

如果是 Mac 系统,可通过界面进行重启操作,如下图所示:
image.png
image.png
image.png

3.设置新密码

重启服务之后我们就可以通过命令行工具来设置 MySQL 的新密码了,首先我们输入“mysql -u root -p”命令来连接 MySQL 服务器,当出现输入密码框时直接敲回车就可以登录了,如下图所示:
image.png

接下来我们再使用以下命令来设置新的密码:

update user set password=password(‘新密码’) where user=’root’;
flush privileges;
quit

注意事项

如果在执行该步骤的时候出现 ERROR 1290 (HY000): The MySQL server is running with the –skip-grant-tables option so it cannot execute this statement 错误。则需要先执行下 flush privileges 命令,再执行其他命令即可。

最后

当我们设置完新密码之后,记得要把 MySQL 配置文件中的“skip-grant-tables”(跳过权限验证)去掉,然后再重启 MySQL 的服务就可以正常使用了。

文末福利:搜索公众号「Java中文社群」发送“面试”,领取最新的面试资料。

本文转载自: 掘金

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

计算机网络基础知识总结

发表于 2020-10-20

我把自己以往的文章汇总成为了 Github ,欢迎各位大佬 star
github.com/crisxuan/be…

computer network1001
如果说计算机把我们从工业时代带到了信息时代,那么计算机网络就可以说把我们带到了网络时代。随着使用计算机人数的不断增加,计算机也经历了一系列的发展,从大型通用计算机 -> 超级计算机 -> 小型机 -> 个人电脑 -> 工作站 -> 便携式电脑 -> 智能手机终端等都是这一过程的产物。计算机网络也逐渐从独立模式演变为了 网络互联模式 。

computer network1002
可以看到,在独立模式下,每个人都需要排队等待其他人在一个机器上完成工作后,其他用户才能使用。这样的数据是单独管理的。

computer network1003
现在切换到了网络互联模式,在这种模式下,每个人都能独立的使用计算机,甚至还会有一个服务器,来为老大哥、cxuan 和 sonsong 提供服务。这样的数据是集中管理的。

计算机网络按规模进行划分,有 WAN(Wide Area Network, 广域网) 和 LAN(Local area Network, 局域网)。如下图所示

computer network1004
上面是局域网,一般用在狭小区域内的网络,一个社区、一栋楼、办公室经常使用局域网。

computer network1005
距离较远的地方组成的网络一般是广域网。

最初,只是固定的几台计算机相连在一起形成计算机网络。这种网络一般是私有的,这几台计算机之外的计算机无法访问。随着时代的发展,人们开始尝试在私有网络上搭建更大的私有网络,逐渐又发展演变为互联网,现在我们每个人几乎都能够享有互联网带来的便利。

计算机网络发展历程

批处理

就和早期的计算机操作系统一样,最开始都要先经历批处理(atch Processing)阶段,批处理的目的也是为了能让更多的人使用计算机。

批处理就是事先将数据装入卡带或者磁带,并且由计算机按照一定的顺序进行读入。

computer network1006
当时这种计算机的价格比较昂贵,并不是每个人都能够使用的,这也就客观暗示着,只有专门的操作员才能使用计算机,用户把程序提交给操作员,由操作员排队执行程序,等一段时间后,用户再来提取结果。

这种计算机的高效性并没有很好的体现,甚至不如手动运算快。

分时系统

在批处理之后出现的就是分时系统了,分时系统指的是多个终端与同一个计算机连接,允许多个用户同时使用一台计算机。分时系统的出现实现了一人一机的目的,让用户感觉像是自己在使用计算机,实际上这是一种 独占性 的特性。

computer network1007
分时系统出现以来,计算机的可用性得到了极大的改善。分时系统的出现意味着计算机越来越贴近我们的生活。

还有一点需要注意:分时系统的出现促进了像是 BASIC 这种人机交互语言的诞生。

分时系统的出现,同时促进者计算机网络的出现。

计算机通信

在分时系统中,每个终端与计算机相连,这种独占性的方式并不是计算机之间的通信,因为每个人还是在独立的使用计算机。

到了 20 世纪 70 年代,计算机性能有了高速发展,同时体积也变得越来越小,使用计算机的门槛变得更低,越来越多的用户可以使用计算机。

没有一个计算机是信息孤岛促使着计算机网络的出现和发展。

计算机网络的诞生

20 世纪 80 年代,一种能够互连多种计算机的网络随之诞生。它能够让各式各样的计算机相连,从大型的超级计算机或主机到小型电脑。

20 世纪 90 年代,真正实现了一人一机的环境,但是这种环境的搭建仍然价格不菲。与此同时,诸如电子邮件(E-mail)、万维网(WWW,World Wide Web) 等信息传播方式如雨后春笋般迎来了前所未有的发展,使得互联网从大到整个公司小到每个家庭内部,都得以广泛普及。

计算机网络的高速发展

现如今,越来越多的终端设备接入互联网,使互联网经历了前所未有的高潮,近年来 3G、4G、5G 通信技术的发展更是互联网高速发展的产物。

许多发展道路各不相同的网络技术也都正在向互联网靠拢。例如,曾经一直作为通信基础设施、支撑通信网络的电话网。随着互联网的发展,其地位也随着时间的推移被 IP(Internet Protocol) 网所取代,IP 也是互联网发展的产物。

computer network1008

网络安全

正如互联网也具有两面性,互联网的出现方便了用户,同时也方便了一些不法分子。互联网的便捷也带来了一些负面影响,计算机病毒的侵害、信息泄漏、网络诈骗层出不穷。

在现实生活中,通常情况下我们挨揍了会予以反击,但是在互联网中,你被不法分子攻击通常情况下是无力还击的,只能防御,因为还击需要你精通计算机和互联网,这通常情况下很多人办不到。

通常情况下公司和企业容易被作为不法分子获利的对象,所以,作为公司或者企业,要想不受攻击或者防御攻击,需要建立安全的互联网连接。

互联网协议

协议这个名词不仅局限于互联网范畴,也体现在日常生活中,比如情侣双方约定好在哪个地点吃饭,这个约定也是一种协议,比如你应聘成功了,企业会和你签订劳动合同,这种双方的雇佣关系也是一种 协议。注意自己一个人对自己的约定不能成为协议,协议的前提条件必须是多人约定。

那么网络协议是什么呢?

网络协议就是网络中(包括互联网)传递、管理信息的一些规范。如同人与人之间相互交流是需要遵循一定的规矩一样,计算机之间的相互通信需要共同遵守一定的规则,这些规则就称为网络协议。

没有网络协议的互联网是混乱的,就和人类社会一样,人不能想怎么样就怎么样,你的行为约束是受到法律的约束的;那么互联网中的端系统也不能自己想发什么发什么,也是需要受到通信协议约束的。

我们一般都了解过 HTTP 协议, HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范

但是互联网又不只有 HTTP 协议,它还有很多其他的比如 IP、TCP、UDP、DNS 协议等。下面是一些协议的汇总和介绍

网络体系结构 协议 主要用途
TCP/IP HTTP、SMTP、TELNET、IP、ICMP、TCP、UDP 等 主要用于互联网、局域网
IPX/SPX IPX、NPC、SPX 主要用于个人电脑局域网
AppleTalk AEP、ADP、DDP 苹果公司现有产品互联

ISO 在制定标准化的 OSI 之前,对网络体系结构相关的问题进行了充分的探讨,最终提出了作为通信协议设计指标的 OSI 参考模型。这一模型将通信协议中必要的功能分为了 7 层。通过这 7 层分层,使那些比较复杂的协议简单化。

computer network1009
在 OSI 标准模型中,每一层协议都接收由它下一层所提供的特定服务,并且负责为上一层提供服务,上层协议和下层协议之间通常会开放 接口,同一层之间的交互所遵守的约定叫做 协议。

OSI 标准模型

上图只是简单的介绍了一下层与层之间的通信规范和上层与下层的通信规范,并未介绍具体的网络协议分层,实际上,OSI 标准模型将复杂的协议整理并分为了易于理解的 7 层。如下图所示

computer network1010
互联网的通信协议都对应了 7 层中的某一层,通过这一点,可以了解协议在整个网络模型中的作用,一般来说,各个分层的主要作用如下

computer network1011

  • 应用层:应用层是 OSI 标准模型的最顶层,是直接为应用进程提供服务的。其作用是在实现多个系统应用进程相互通信的同时,完成一系列业务处理所需的服务。包括文件传输、电子邮件远程登录和远端接口调用等协议。
  • 表示层: 表示层向上对应用进程服务,向下接收会话层提供的服务,表示层位于 OSI 标准模型的第六层,表示层的主要作用就是将设备的固有数据格式转换为网络标准传输格式。
  • 会话层:会话层位于 OSI 标准模型的第五层,它是建立在传输层之上,利用传输层提供的服务建立和维持会话。
  • 传输层:传输层位于 OSI 标准模型的第四层,它在整个 OSI 标准模型中起到了至关重要的作用。传输层涉及到两个节点之间的数据传输,向上层提供可靠的数据传输服务。传输层的服务一般要经历传输连接建立阶段,数据传输阶段,传输连接释放阶段 3 个阶段才算完成一个完整的服务过程。
  • 网络层:网络层位于 OSI 标准模型的第三层,它位于传输层和数据链路层的中间,将数据设法从源端经过若干个中间节点传送到另一端,从而向运输层提供最基本的端到端的数据传送服务。
  • 数据链路层:数据链路层位于物理层和网络层中间,数据链路层定义了在单个链路上如何传输数据。
  • 物理层:物理层是 OSI 标准模型中最低的一层,物理层是整个 OSI 协议的基础,就如同房屋的地基一样,物理层为设备之间的数据通信提供传输媒体及互连设备,为数据传输提供可靠的环境。

TCP/IP 协议簇

TCP/IP 协议是我们程序员接触最多的协议,实际上,TCP/IP 又被称为 TCP/IP 协议簇,它并不特指单纯的 TCP 和 IP 协议,而是容纳了许许多多的网络协议。

OSI 模型共有七层,从下到上分别是物理层、数据链路层、网络层、运输层、会话层、表示层和应用层。但是这显然是有些复杂的,所以在TCP/IP协议中,它们被简化为了四个层次

computer network1012
和 OSI 七层网络协议的主要区别如下

  • 应用层、表示层、会话层三个层次提供的服务相差不是很大,所以在 TCP/IP 协议中,它们被合并为应用层一个层次。
  • 由于数据链路层和物理层的内容很相似,所以在 TCP/IP 协议中它们被归并在网络接口层一个层次里。

我们的主要研究对象就是 TCP/IP 的四层协议。

下面 cxuan 和你聊一聊 TCP/IP 协议簇中都有哪些具体的协议

IP 协议

IP 是 互联网协议(Internet Protocol) ,位于网络层。IP是整个 TCP/IP 协议族的核心,也是构成互联网的基础。IP 能够为运输层提供数据分发,同时也能够组装数据供运输层使用。它将多个单个网络连接成为一个互联网,这样能够提高网络的可扩展性,实现大规模的网络互联。二是分割顶层网络和底层网络之间的耦合关系。

ICMP 协议

ICMP 协议是 Internet Control Message Protocol, ICMP 协议主要用于在 IP 主机、路由器之间传递控制消息。ICMP 属于网络层的协议,当遇到 IP 无法访问目标、IP 路由器无法按照当前传输速率转发数据包时,会自动发送 ICMP 消息,从这个角度来说,ICMP 协议可以看作是 错误侦测与回报机制,让我们检查网络状况、也能够确保连线的准确性。

ARP 协议

ARP 协议是 地址解析协议,即 Address Resolution Protocol,它能够根据 IP 地址获取物理地址。主机发送信息时会将包含目标 IP 的 ARP 请求广播到局域网络上的所有主机,并接受返回消息,以此来确定物理地址。收到消息后的物理地址和 IP 地址会在 ARP 中缓存一段时间,下次查询的时候直接从 ARP 中查询即可。

TCP 协议

TCP 就是 传输控制协议,也就是 Transmission Control Protocol,它是一种面向连接的、可靠的、基于字节流的传输协议,TCP 协议位于传输层,TCP 协议是 TCP/IP 协议簇中的核心协议,它最大的特点就是提供可靠的数据交付。

TCP 的主要特点有 慢启动、拥塞控制、快速重传、可恢复。

UDP 协议

UDP 协议就是 用户数据报协议,也就是 User Datagram Protocol,UDP 也是一种传输层的协议,与 TCP 相比,UDP 提供一种不可靠的数据交付,也就是说,UDP 协议不保证数据是否到达目标节点,也就是说,当报文发送之后,是无法得知其是否安全完整到达的。UDP 是一种无连接的协议,传输数据之前源端和终端无需建立连接,不对数据报进行检查与修改,无须等待对方的应答,会出现分组丢失、重复、乱序等现象。但是 UDP 具有较好的实时性,工作效率较 TCP 协议高。

FTP 协议

FTP 协议是 文件传输协议,英文全称是 File Transfer Protocol,应用层协议之一,是 TCP/IP 协议的重要组成之一,FTP 协议分为服务器和客户端两部分,FTP 服务器用来存储文件,FTP 客户端用来访问 FTP 服务器上的文件,FTP 的传输效率比较高,所以一般使用 FTP 来传输大文件。

DNS 协议

DNS 协议是 域名系统协议,英文全称是 Domain Name System,它也是应用层的协议之一,DNS 协议是一个将域名和 IP 相互映射的分布式数据库系统。DNS 缓存能够加快网络资源的访问。

SMTP 协议

SMTP 协议是 简单邮件传输协议,英文全称是 Simple Mail Transfer Protocol,应用层协议之一,SMTP 主要是用作邮件收发协议,SMTP 服务器是遵循 SMTP 协议的发送邮件服务器,用来发送或中转用户发出的电子邮件

SLIP 协议

SLIP 协议是指串行线路网际协议(Serial Line Internet Protocol) ,是在串行通信线路上支持 TCP/IP 协议的一种点对点(Point-to-Point)式的链路层通信协议。

PPP 协议

PPP 协议是 Point to Point Protocol,即点对点协议,是一种链路层协议,是在为同等单元之间传输数据包而设计的。设计目的主要是用来通过拨号或专线方式建立点对点连接发送数据,使其成为各种主机、网桥和路由器之间简单连接的一种共通的解决方案。

网络核心概念

传输方式

网络根据传输方式可以进行分类,一般分成两种 面向连接型和面向无连接型。

  • 面向连接型中,在发送数据之前,需要在主机之间建立一条通信线路。
  • 面向无连接型则不要求建立和断开连接,发送方可用于任何时候发送数据。接收端也不知道自己何时从哪里接收到数据。

分组交换

在互联网应用中,每个终端系统都可以彼此交换信息,这种信息也被称为 报文(Message),报文是一个集大成者,它可以包括你想要的任何东西,比如文字、数据、电子邮件、音频、视频等。为了从源目的地向端系统发送报文,需要把长报文切分为一个个小的数据块,这种数据块称为分组(Packets),也就是说,报文是由一个个小块的分组组成。在端系统和目的地之间,每个分组都要经过通信链路(communication links) 和分组交换机(switch packets) ,分组要在端系统之间交互需要经过一定的时间,如果两个端系统之间需要交互的分组为 L 比特,链路的传输速率问 R 比特/秒,那么传输时间就是 L / R秒。

一个端系统需要经过交换机给其他端系统发送分组,当分组到达交换机时,交换机就能够直接进行转发吗?不是的,交换机可没有这么无私,你想让我帮你转发分组?好,首先你需要先把整个分组数据都给我,我再考虑给你发送的问题,这就是存储转发传输

存储转发传输

存储转发传输指的就是交换机再转发分组的第一个比特前,必须要接受到整个分组,下面是一个存储转发传输的示意图,可以从图中窥出端倪

computer network1013
由图可以看出,分组 1、2、3 向交换器进行分组传输,并且交换机已经收到了分组1 发送的比特,此时交换机会直接进行转发吗?答案是不会的,交换机会把你的分组先缓存在本地。这就和考试作弊一样,一个学霸要经过学渣 A 给学渣 B 传答案,学渣 A 说,学渣 A 在收到答案后,它可能直接把卷子传过去吗?学渣A 说,等我先把答案抄完(保存功能)后再把卷子给你。

排队时延和分组丢失

什么?你认为交换机只能和一条通信链路进行相连?那你就大错特错了,这可是交换机啊,怎么可能只有一条通信链路呢?

所以我相信你一定能想到这个问题,多个端系统同时给交换器发送分组,一定存在顺序到达和排队的问题。事实上,对于每条相连的链路,该分组交换机会有一个输出缓存(output buffer) 和 输出队列(output queue) 与之对应,它用于存储路由器准备发往每条链路的分组。如果到达的分组发现路由器正在接收其他分组,那么新到达的分组就会在输出队列中进行排队,这种等待分组转发所耗费的时间也被称为 排队时延,上面提到分组交换器在转发分组时会进行等待,这种等待被称为 存储转发时延,所以我们现在了解到的有两种时延,但是其实是有四种时延。这些时延不是一成不变的,其变化程序取决于网络的拥塞程度。

因为队列是有容量限制的,当多条链路同时发送分组导致输出缓存无法接受超额的分组后,这些分组会丢失,这种情况被称为 丢包(packet loss),到达的分组或者已排队的分组将会被丢弃。

下图说明了一个简单的分组交换网络

computer network1014
在上图中,分组由三位数据平板展示,平板的宽度表示着分组数据的大小。所有的分组都有相同的宽度,因此也就有相同的数据包大小。下面来一个情景模拟: 假定主机 A 和 主机 B 要向主机 E 发送分组,主机 A 和 B 首先通过100 Mbps以太网链路将其数据包发送到第一台路由器,然后路由器将这些数据包定向到15 Mbps 的链路。如果在较短的时间间隔内,数据包到达路由器的速率(转换为每秒比特数)超过15 Mbps,则在数据包在链路输出缓冲区中排队之前,路由器上会发生拥塞,然后再传输到链路上。例如,如果主机 A 和主机 B 背靠背同时发了5包数据,那么这些数据包中的大多数将花费一些时间在队列中等待。实际上,这种情况与许多普通情况完全相似,例如,当我们排队等候银行出纳员或在收费站前等候时。

转发表和路由器选择协议

我们刚刚讲过,路由器和多个通信线路进行相连,如果每条通信链路同时发送分组的话,可能会造成排队和丢包的情况,然后分组在队列中等待发送,现在我就有一个问题问你,队列中的分组发向哪里?这是由什么机制决定的?

换个角度想问题,路由的作用是什么?把不同端系统中的数据包进行存储和转发 。在因特网中,每个端系统都会有一个 IP 地址,当原主机发送一个分组时,在分组的首部都会加上原主机的 IP 地址。每一台路由器都会有一个 转发表(forwarding table),当一个分组到达路由器后,路由器会检查分组的目的地址的一部分,并用目的地址搜索转发表,以找出适当的传送链路,然后映射成为输出链路进行转发。

那么问题来了,路由器内部是怎样设置转发表的呢?详细的我们后面会讲到,这里只是说个大概,路由器内部也是具有路由选择协议的,用于自动设置转发表。

电路交换

在计算机网络中,另一种通过网络链路和路由进行数据传输的另外一种方式就是 电路交换(circuit switching)。电路交换在资源预留上与分组交换不同,什么意思呢?就是分组交换不会预留每次端系统之间交互分组的缓存和链路传输速率,所以每次都会进行排队传输;而电路交换会预留这些信息。一个简单的例子帮助你理解:这就好比有两家餐馆,餐馆 A 需要预定而餐馆 B 不需要预定,对于可以预定的餐馆 A,我们必须先提前与其进行联系,但是当我们到达目的地时,我们能够立刻入座并选菜。而对于不需要预定的那家餐馆来说,你可能不需要提前联系,但是你必须承受到达目的地后需要排队的风险。

下面显示了一个电路交换网络

computer network1015
在这个网络中,4条链路用于4台电路交换机。这些链路中的每一条都有4条电路,因此每条链路能支持4条并行的链接。每台主机都与一台交换机直接相连,当两台主机需要通信时,该网络在两台主机之间创建一条专用的 端到端的链接(end-to-end connection)。

分组交换和电路交换的对比

分组交换的支持者经常说分组交换不适合实时服务,因为它的端到端时延时不可预测的。而分组交换的支持者却认为分组交换提供了比电路交换更好的带宽共享;它比电路交换更加简单、更有效,实现成本更低。但是现在的趋势更多的是朝着分组交换的方向发展。

分组交换网的时延、丢包和吞吐量

因特网可以看成是一种基础设施,该基础设施为运行在端系统上的分布式应用提供服务。我们希望在计算机网络中任意两个端系统之间传递数据都不会造成数据丢失,然而这是一个极高的目标,实践中难以达到。所以,在实践中必须要限制端系统之间的 吞吐量 用来控制数据丢失。如果在端系统之间引入时延,也不能保证不会丢失分组问题。所以我们从时延、丢包和吞吐量三个层面来看一下计算机网络

分组交换中的时延

计算机网络中的分组从一台主机(源)出发,经过一系列路由器传输,在另一个端系统中结束它的历程。在这整个传输历程中,分组会涉及到四种最主要的时延:节点处理时延(nodal processing delay)、排队时延(queuing delay)、传输时延(total nodal delay)和传播时延(propagation delay)。这四种时延加起来就是 节点总时延(total nodal delay)。

如果用 dproc dqueue dtrans dpop 分别表示处理时延、排队时延、传输时延和传播时延,则节点的总时延由以下公式决定: dnodal = dproc + dqueue + dtrans + dpop。

时延的类型

下面是一副典型的时延分布图,让我们从图中进行分析一下不同的时延类型

computer network1016
分组由端系统经过通信链路传输到路由器 A,路由器A 检查分组头部以映射出适当的传输链路,并将分组送入该链路。仅当该链路没有其他分组正在传输并且没有其他分组排在该该分组前面时,才能在这条链路上自由的传输该分组。如果该链路当前繁忙或者已经有其他分组排在该分组前面时,新到达的分组将会加入排队。下面我们分开讨论一下这四种时延

节点处理时延

节点处理时延分为两部分,第一部分是路由器会检查分组的首部信息;第二部分是决定将分组传输到哪条通信链路所需要的时间。一般高速网络的节点处理时延都在微妙级和更低的数量级。在这种处理时延完成后,分组会发往路由器的转发队列中

排队时延

在队列排队转发过程中,分组需要在队列中等待发送,分组在等待发送过程中消耗的时间被称为排队时延。排队时延的长短取决于先于该分组到达正在队列中排队的分组数量。如果该队列是空的,并且当前没有正在传输的分组,那么该分组的排队时延就是 0。如果处于网络高发时段,那么链路中传输的分组比较多,那么分组的排队时延将延长。实际的排队时延也可以到达微秒级。

传输时延

队列 是路由器所用的主要的数据结构。队列的特征就是先进先出,先到达食堂的先打饭。传输时延是理论情况下单位时间内的传输比特所消耗的时间。比如分组的长度是 L 比特,R 表示从路由器 A 到路由器 B 的传输速率。那么传输时延就是 L / R 。这是将所有分组推向该链路所需要的时间。正是情况下传输时延通常也在毫秒到微妙级

传播时延

从链路的起点到路由器 B 传播所需要的时间就是 传播时延。该比特以该链路的传播速率传播。该传播速率取决于链路的物理介质(双绞线、同轴电缆、光纤)。如果用公式来计算一下的话,该传播时延等于两台路由器之间的距离 / 传播速率。即传播速率是 d/s ,其中 d 是路由器 A 和 路由器 B 之间的距离,s 是该链路的传播速率。

传输时延和传播时延的比较

计算机网络中的传输时延和传播时延有时候难以区分,在这里解释一下,传输时延是路由器推出分组所需要的时间,它是分组长度和链路传输速率的函数,而与两台路由器之间的距离无关。而传播时延是一个比特从一台路由器传播到另一台路由器所需要的时间,它是两台路由器之间距离的倒数,而与分组长度和链路传输速率无关。从公式也可以看出来,传输时延是 L/R,也就是分组的长度 / 路由器之间传输速率。传播时延的公式是 d/s,也就是路由器之间的距离 / 传播速率。

排队时延

在这四种时延中,人们最感兴趣的时延或许就是排队时延了 dqueue。与其他三种时延(dproc、dtrans、dpop)不同的是,排队时延对不同的分组可能是不同的。例如,如果10个分组同时到达某个队列,第一个到达队列的分组没有排队时延,而最后到达的分组却要经受最大的排队时延(需要等待其他九个时延被传输)。

那么如何描述排队时延呢?或许可以从三个方面来考虑:流量到达队列的速率、链路的传输速率和到达流量的性质。即流量是周期性到达还是突发性到达,如果用 a 表示分组到达队列的平均速率( a 的单位是分组/秒,即 pkt/s)前面说过 R 表示的是传输速率,所以能够从队列中推出比特的速率(以 bps 即 b/s 位单位)。假设所有的分组都是由 L 比特组成的,那么比特到达队列的平均速率是 La bps。那么比率 La/R 被称为流量强度(traffic intensity),如果 La/R > 1,则比特到达队列的平均速率超过从队列传输出去的速率,这种情况下队列趋向于无限增加。所以,设计系统时流量强度不能大于1。

现在考虑 La / R <= 1 时的情况。流量到达的性质将影响排队时延。如果流量是周期性到达的,即每 L / R 秒到达一个分组,则每个分组将到达一个空队列中,不会有排队时延。如果流量是 突发性 到达的,则可能会有很大的平均排队时延。一般可以用下面这幅图表示平均排队时延与流量强度的关系

computer network1017
横轴是 La/R 流量强度,纵轴是平均排队时延。

丢包

我们在上述的讨论过程中描绘了一个公式那就是 La/R 不能大于1,如果 La/R 大于1,那么到达的排队将会无穷大,而且路由器中的排队队列所容纳的分组是有限的,所以等到路由器队列堆满后,新到达的分组就无法被容纳,导致路由器 丢弃(drop) 该分组,即分组会 丢失(lost)。

计算机网络中的吞吐量

除了丢包和时延外,衡量计算机另一个至关重要的性能测度是端到端的吞吐量。假如从主机 A 向主机 B 传送一个大文件,那么在任何时刻主机 B 接收到该文件的速率就是 瞬时吞吐量(instantaneous throughput)。如果该文件由 F 比特组成,主机 B 接收到所有 F 比特用去 T 秒,则文件的传送平均吞吐量(average throughput) 是 F / T bps。

单播、广播、多播和任播

在网络通信中,可以根据目标地址的数量对通信进行分类,可以分为 单播、广播、多播和任播

单播(Unicast)

单播最大的特点就是 1 对 1,早期的固定电话就是单播的一个例子,单播示意图如下

computer network1018

广播(Broadcast)

我们一般小时候经常会跳广播体操,这就是广播的一个事例,主机和与他连接的所有端系统相连,主机将信号发送给所有的端系统。

computer network1019

多播(Multicast)

多播与广播很类似,也是将消息发送给多个接收主机,不同之处在于多播需要限定在某一组主机作为接收端。

computer network1020

任播(Anycast)

任播是在特定的多台主机中选出一个接收端的通信方式。虽然和多播很相似,但是行为与多播不同,任播是从许多目标机群中选出一台最符合网络条件的主机作为目标主机发送消息。然后被选中的特定主机将返回一个单播信号,然后再与目标主机进行通信。

computer network1021
物理媒介


网络的传输是需要介质的。一个比特数据包从一个端系统开始传输,经过一系列的链路和路由器,从而到达另外一个端系统。这个比特会被转发了很多次,那么这个比特经过传输的过程所跨越的媒介就被称为物理媒介(phhysical medium),物理媒介有很多种,比如双绞铜线、同轴电缆、多模光纤榄、陆地无线电频谱和卫星无线电频谱。其实大致分为两种:引导性媒介和非引导性媒介。

双绞铜线

最便宜且最常用的引导性传输媒介就是双绞铜线,多年以来,它一直应用于电话网。从电话机到本地电话交换机的连线超过 99% 都是使用的双绞铜线,例如下面就是双绞铜线的实物图

computer network1022
双绞铜线由两根绝缘的铜线组成,每根大约 1cm 粗,以规则的螺旋形状排列,通常许多双绞线捆扎在一起形成电缆,并在双绞馅的外面套上保护层。一对电缆构成了一个通信链路。无屏蔽双绞线一般常用在局域网(LAN)中。

同轴电缆

与双绞线类似,同轴电缆也是由两个铜导体组成,下面是实物图

computer network1023
借助于这种结构以及特殊的绝缘体和保护层,同轴电缆能够达到较高的传输速率,同轴电缆普遍应用在在电缆电视系统中。同轴电缆常被用户引导型共享媒介。

光纤

光纤是一种细而柔软的、能够引导光脉冲的媒介,每个脉冲表示一个比特。一根光纤能够支持极高的比特率,高达数十甚至数百 Gbps。它们不受电磁干扰。光纤是一种引导型物理媒介,下面是光纤的实物图

computer network1024
一般长途电话网络全面使用光纤,光纤也广泛应用于因特网的主干。

陆地无线电信道

无线电信道承载电磁频谱中的信号。它不需要安装物理线路,并具有穿透墙壁、提供与移动用户的连接以及长距离承载信号的能力。

卫星无线电信道

一颗卫星电信道连接地球上的两个或多个微博发射器/接收器,它们称为地面站。通信中经常使用两类卫星:同步卫星和近地卫星。

后记

这是计算机网络的第一篇文章,也是属于基础前置知识,后面会陆续更新计算机网络的内容。

如果文章还不错,希望小伙伴们可以点赞、在看、留言、分享,这就是最好的白嫖 。

另外,我输出了 六本 PDF,全集 PDF 如下。

链接: pan.baidu.com/s/1mYAeS9hI… 密码: p9rs

本文转载自: 掘金

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

Redis实现排行榜设计

发表于 2020-10-19

redis的zset结构有着天然的排序功能,十分适合并发量大的排行功能。通过key值确定排行榜的范围,使用members来作为排序的标识,score作为排序的依据。redis排行榜功能看似简单易用,但在实际应用中却遇到了很多问题,需要通过适当的操作来设计这个功能。接下来就来理清排行榜的设计思绪吧

1.排行榜的key设计

排行榜一般按照时间段进行分类,分别有周榜,月榜和年榜。这样要区分不同的榜单就需要和时间关联上,通过时间判断来将数据都保存到对应的zset中。

例如年榜可以用年份来标识,今年的key就是2020,去年的key是2019。月份的话通过月数来标识。周榜的情况有点特殊,可以通过当天处于本年的第几周来确定zset的key。这样设置的key为周数当,就可以统计当周的数据了。

java中有直接获取周数的方法

注意设置周一为一周的第一天,默认为周日。

1
2
3
4
5
ini复制代码Calendar cal = Calendar.getInstance();
//设置周一为一周的第一天
cal.setFirstDayOfWeek(Calenar.MONDAY);
cal.setTime(new Date());
int num = cal.get(Calenar.WEEK_OF_YEAR);

但是这个方法虽然可以快速获取到当天的所属周数,但是有的排行榜需要查看上一周的数据。那么上周周榜的key,即获取上一周的周数只需在本周的周数减去一就可以。

这个时候就有一个特殊情况出现了,假如今天是本年的第一周,那么上一周的周榜的key就等于0,显然这是不合理的。那么这个应该怎么处理呢?

![](https://gitee.com/songjianzaina/juejin_p16/raw/master/img/a45eb37cec9f0fc1aef5a9b62bd111e8c242be223568be2503b85db8e7acdbcf)

其实本年的第0周就是上一年的最后一周,只需要获取到上一年的最后一周的周数就可以。所以第一步先把当前日期往前推7天,在获取那天所属的周数,就可以解决跨年获取周数的问题。

2.ZSet同分排序规则

在现实生活中,单个字段排序的排行榜十分少见。这是因为排行榜可能存在大量的重复分数,这就导致了分数相同的情况下无法判断排名。这里就涉及到第一个问题,score相同的情况下,哪条数据会排在前面呢?

![](https://gitee.com/songjianzaina/juejin_p16/raw/master/img/4829afcec68080d0e01d25c5a62c9f24ceaceac1ee5b510a1a599703fccb85b5)

实践出真知,打开redis试一下。插入6条数据,分数都一样但是member不一样,分别是2020001, 2020002, 2020003, aa,bb,cc这6个。使用zrange进行排序,结果如下

![](https://gitee.com/songjianzaina/juejin_p16/raw/master/img/9eb63e9a402728017299587cb7af79aab1845207871b1fc5ba9d2550fe797905)

猜测可能与字符排列顺序有关,经查阅资料验证确实当score值相同时,按照member的字符顺序进行排列。

3.ZSet多字段排序

zset只能根据score进行排序,也就是单字段排序。但是很多时候排序的规则不止一条,例如闯关排行榜不仅比较闯关成功的次数,还会比较通关的时长,复活的次数等等。这就导致了zset的字段远远不够用,那应该怎么同时使用多条排序规则呢?接下来就是需要使用取巧的办法了。

![](https://gitee.com/songjianzaina/juejin_p16/raw/master/img/e9f629c3b3e47ac8bd28fb5dea9b2bd7950656685d5824e31bcb89e73182eea6)

既然只有一个排序字段,那么根据排序规则的权重重新组合这个数值。通过把权重高的数值放在组合数的最前面来达到数值比较上的优势,说起来有点绕口,直接上例子吧。

场景如下:游戏闯关排行榜以通关次数正序、复活次数倒序和第一次通关的时间倒序来进行排序。

具体化数值:闯关5次、复活2次和第一次通关时间2020-06-09

那么score的数值的处理如下:

  1. 闯关次数权重最高,所以5放在最前面
  2. 复活次数倒序,则需要取反。在取反之前,需要确定复活的最大次数,例如99次,那么取反之后得到97
  3. 通关时间倒序,也需要取反。先获取通关时间的时间戳,得到1591632000000。由于double位数限制,去掉最后毫秒数即最后三位,获取1591632000共十位,倒序则需要用最大数9999999999(10个9)减去当前值得8408367999

最终score=’5’+’97’+’8408367999’= 5978408367999 共13位数。显然通关次数小于5次的时候,score必然不能超过****5978408367999 。

这个方法需要注意的点就是zset的score字段是double类型。在double类型中的数值类型,需要注意整数的精度和小数点精度。double能保存最多16位的数字,如果复合排序字段中有时间的话,用于其他字段排序的数字只有6位,这就是复合数值排序的限制之处。

4.Redis数据备份

redis数据库属于内存性数据库,虽然速度快,也有持久化策略来保障高可用。但是大量的数据需要设置过期时间来腾出内存空间,所以需要通过定时任务将数据落到数据库中来保证数据,同时可以方便导出这些数据。实现起来也比较简单,通过当前所属周获取到redis的数据,对位落库就可以。

需要注意的就是定时任务的时间节点。为了保证本周的数据落库是最完整的数据,需要在下一周的第一次同步时再进行一次备份。这个时候就可以通过在redis中设置一个标志位,每次更新前判断是有上周数据已经同步的标志。

本文转载自: 掘金

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

3分钟短文:太爽了,用Laravel写API接口!

发表于 2020-10-18

引言

我们一直在讲,通过路由传达到控制器,处理好数据并渲染到视图,但是对于现代的应用,
前后端分离的情况下,后端写个接口就完事儿了。

img

本期为大家说一说用laravel写restful风格的API,看看能有多简单。

以路由开端

写API接口,与传统的渲染前端模板页面有什么区别?少了视图,只需要准备好数据,
并按照规则格式化,返回就可以了。

laravel默认的api接口路由在 routes/api.php 文件内定义,默认的情况下预定义了一个资源类型的api接口,代码如下:

1
2
3
php复制代码Route::middleware('auth:api')->get('/user', function (Request $request) {
return $request->user();
});

调用了 auth:api 中间件用于验证用户的授权,如果授权通过,声明的get方法获取用户的信息,并返回 User 模型。这在之前的章节是很常见的操作,我们不做赘述了。

那么这个路由文件,是什么时候加载上去的呢?在文件 app/Providers/RouteServiceProvider.php 内,看这样一段:

1
2
3
4
5
6
7
php复制代码protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
}

该服务提供者声明路由使用 api 字符前缀,并调用 api 中间件,该中间件定义在 app/Http/Kernel.php 文件内:

1
2
3
4
5
6
php复制代码protected $middlewareGroups = [
'api' => [
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];

至于命名空间 $this->namespace 一般返回 App\Http\Controllers,我们为了区分API与其他应用,在目录 app/Http/Controller 下创建 API 目录,用于存储所有API相关的控制器。

那么上述的 RouteServiceProvider.php 文件内 mapApiRoutes 方法内的 namespace 需要这样写:

1
php复制代码->namespace($this->namespace . '\API')

仍然以 Event 模型作为示例,在 routes/api.php 文件内声明一个资源类型的路由:

1
php复制代码Route::resource('/events', 'API\EventsController');

注意命名空间上多出来的前缀 API\ ,这说明我们是把 EventController 文件放在了 API 目录下。

用户权限

让我们把目光还聚焦在系统默认声明的那条路由:

1
2
3
php复制代码Route::middleware('auth:api')->get('/user', function (Request $request) {
return $request->user();
});

注意中间件 auth:api,因为api请求是无状态的,每次请求之间没有任何关联,所以使用用户权限区分资源的返回。那么我们怎么拿到用户授权呢?这在 config/auth.php 文件内定义,看系统自带的这一段配置代码:

1
2
3
4
5
6
7
php复制代码'guards' => [
'api' => [
'driver' => 'token',
'provider' => 'users',
'hash' => false,
],
],

这一段定义了我们使用何种方式认证用户的身份。默认的驱动 token 定义在框架文件 laravel/framework/src/Illuminate/Auth/TokenGuard.php 内。长话短说,默认构造类传入的字段如下:

1
2
3
4
5
php复制代码UserProvider $provider,
Request $request,
$inputKey = 'api_token',
$storageKey = 'api_token',
$hash = false

简单说,就是使用 users 表的 api_token 字段用户鉴权。那么默认我们 users 表显然缺少一个这样的字段,现在使用迁移文件补上:

1
php复制代码php artisan make:migration add_api_token_field_to_users_table --table=users

首先是迁移方法 up 函数:

1
2
3
4
5
6
php复制代码public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('api_token', 60)->unique();
});
}

还有回滚使用的 down 方法:

1
2
3
4
5
6
php复制代码public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('api_token');
});
}

这些都是常规操作,我们在之前的章节,使用了N多次了。执行指令迁移数据库:

1
php复制代码php artisan migrate

看看效果

准备好了路由,而且路由内声明了一个get方法返回用户模型数据。也准备好了数据库表字段 api_token。我们在数据库表内找到一个用户数据,把api_token值设置为 1234,用于测试。

现在在浏览器内请求类似如下的url地址:

1
html复制代码http://www.example.com/api/user?api_token=1234

如无异常,顺利会输出一个 json 字符串,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
json复制代码{
"id":1,
"provider":null,
"provider_id":null,
"first_name":"Tom",
"last_name":"Hanks",
"email":"tom@admin.com",
"city":"",
"state_id":null,
"zip":"43016",
"lat":null,"lng":null,
"timezone":"America\/New_York",
"title":"Laravel Developer",
"created_at":"2020-10-14 17:46:19",
"updated_at":"2020-10-14 17:46:20",
"last_login_at":null,
"is_admin":0,
"api_token":"1234"
}

这个json格式的数据是怎么来的呢?是在路由内,$request->user() 方法返回的User模型,使用 toArray() 格式化方法获得的。为了演示,很多字段与实际可能有所出入。

特别需要注意的是,关键的密码字段,以及 token 字段,是默认隐藏的,这得益于 User 模型内 $hiden 属性的定义:

1
2
3
php复制代码protected $hidden = [
'password', 'remember_token',
];

这些字段都对对外不公开访问。

写在最后

本文介绍了如何声明api地址,已经解释了api从中间件到路由的由来,明白了api授权的方式,可以为我们更灵活地定制授权方式提供便利。这在laravel内都是可插拔的,替换为我们的逻辑代码就可以愉快工作了。

Happy coding :-)

我是@程序员小助手,专注编程知识,圈子动态的IT领域原创作者

本文转载自: 掘金

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

反思|官方也无力回天?Android SharedPrefe

发表于 2020-10-17

反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里 。

起源

就在前几日,有幸拜读到 HiDhl 的文章,继腾讯开源类似功能的MMKV之后,Google官方维护的 Jetpack DataStore 组件横空出世——这是否意味着无论是腾讯三方还是Google官方的角度,SharedPreferences都彻底告别了这个时代?

无论是MMKV的支持者还是DataStore的拥趸,SharedPreferences似乎都不值一提;值得深思的是,笔者通过面试或者其它方式,和一些同行交流时,却遇到了以下的情形:

在谈及SharedPreferences和MMKV,大多数人都能对前者的 缺陷,以及后者性能上若干 数量级的优势 娓娓道来;但是,在针对前者的短板进行细节化的讨论时,往往却得不到更深入性的结果,简单列举几个问题如下:

  • SharedPreferences是如何保证线程安全的,其内部的实现用到了哪些锁?
  • 进程不安全是否会导致数据丢失?
  • 数据丢失时,其最终的屏障——文件备份机制是如何实现的?
  • 如何实现进程安全的SharedPreferences?

除此之外,站在 设计者的角度 上,还有一些与架构相关,且同样值得思考的问题:

  • 为什么SharedPreferences会有这些缺陷,如何对这些缺陷做改进的尝试?
  • 为什么不惜推倒重来,推出新的DataStore组件来代替前者?
  • 令Google工程师掣肘,时隔今日,这些缺陷依然存在的最根本性原因是什么?

而想要解除这些潜藏在内心最深处的困惑,就不得不从SharedPreferences本身的设计与实现讲起了。

本文大纲如下:

一、SharedPreferences的前世今生

我们知道,就在不久前2019年的Google I/O大会上,官方推出了Jetpack Security组件,旨在保证文件和SharedPreferences的安全性,SharedPreferences的包装类,EncryptedSharedPreferences隆重登场。

不仅如此,Android 8.0前后的源码中,SharedPreferences内部的实现也略有不同。由此可见,Android官方一直在尽力“拯救”SharedPreferences。

因此,在毅然决然抛弃SharedPreferences投奔新的解决方案之前,我们有必要重新认识一下它。

1、设计与实现:建立基本结构

SharedPreferences是Android平台上 轻量级的存储类,用来保存App的各种配置信息,其本质是一个以 键值对(key-value)的方式保存数据的xml文件,其保存在/data/data/shared_prefs目录下。

对于21世纪初,那个Android系统诞生的时代而言,使用xml文件保存应用轻量级的数据绝对是一个不错的主意。那个时代的json才刚刚出生不久,虽然也渐渐成为了主流的 轻量级数据交换格式 ,但是其更多的优势还是在于 可读性,这也是笔者猜测没有使用json而使用xml保存的原因之一。

现在我们为这个 轻量级的存储类 建立了最基础的模型,通过xml中的键值对,将对应的数据保存到本地的文件中。这样,每次读取数据时,通过解析xml文件,得到指定key对应的value;每次更新数据,也通过文件中key更新对应的value。

2、读操作的优化

通过这样的方式,虽然我们建立了一个最简单的 文件存储系统,但是性能实在不敢恭维,每次读取一个key对应的值都要重新对文件进行一次读的操作?显然需要尽量避免笨重的I/O操作。

因此设计者针对读操作进行了简单的优化,当SharedPreferences对象第一次通过Context.getSharedPreferences()进行初始化时,对xml文件进行一次读取,并将文件内所有内容(即所有的键值对)缓到内存的一个Map中,这样,接下来所有的读操作,只需要从这个Map中取就可以了:

1
2
3
4
java复制代码final class SharedPreferencesImpl implements SharedPreferences {
private final File mFile; // 对应的xml文件
private Map<String, Object> mMap; // Map中缓存了xml文件中所有的键值对
}

读者不禁会有疑问,虽然节省了I/O的操作,但另一个视角分析,当xml中数据量过大时,这种 内存缓存机制 是否会产生 高内存占用 的风险?

这也正是很多开发者诟病SharedPreferences的原因之一,那么,从事物的两面性上来看,高内存占用 真的是设计者的问题吗?

不尽然,因为SharedPreferences的设计初衷是数据的 轻量级存储 ,对于类似应用的简单的配置项(比如一个boolean或者int类型),即使很多也并不会对内存有过高的占用;而对于复杂的数据(比如复杂对象序列化后的字符串),开发者更应该使用类似Room这样的解决方案,而非一股脑存储到SharedPreferences中。

因此,相对于「SharedPreferences会导致内存使用过高」的说法,笔者更倾向于更客观的进行总结:

虽然 内存缓存机制 表面上看起来好像是一种 空间换时间 的权衡,实际上规避了短时间内频繁的I/O操作对性能产生的影响,而通过良好的代码规范,也能够避免该机制可能会导致内存占用过高的副作用,所以这种设计是 值得肯定 的。

3、写操作的优化

针对写操作,设计者同样设计了一系列的接口,以达到优化性能的目的。

我们知道对键值对进行更新是通过mSharedPreferences.edit().putString().commit()进行操作的——edit()是什么,commit()又是什么,为什么不单纯的设计初mSharedPreferences.putString()这样的接口?

设计者希望,在复杂的业务中,有时候一次操作会导致多个键值对的更新,这时,与其多次更新文件,我们更倾向将这些更新 合并到一次写操作 中,以达到性能的优化。

因此,对于SharedPreferences的写操作,设计者抽象出了一个Editor类,不管某次操作通过若干次调用putXXX()方法,更新了几个xml中的键值对,只有调用了commit()方法,最终才会真正写入文件:

1
2
3
4
5
6
7
8
java复制代码// 简单的业务,一次更新一个键值对
sharedPreferences.edit().putString().commit();

// 复杂的业务,一次更新多个键值对,仍然只进行一次IO操作(文件的写入)
Editor editor = sharedPreferences.edit();
editor.putString();
editor.putBoolean().putInt();
editor.commit(); // commit()才会更新文件

了解到这一点,读者应该明白,通过简单粗暴的封装,以达到类似SPUtils.putXXX()这种所谓代码量的节省,从而忽略了Editor.commit()的设计理念和使用场景,往往是不可取的,从设计上来讲,这甚至是一种 倒退 。

另外一个值得思考的角度是,本质上文件的I/O是一个非常重的操作,直接放在主线程中的commit()方法某些场景下会导致ANR(比如数据量过大),因此更合理的方式是应该将其放入子线程执行。

因此设计者还为Editor提供了一个apply()方法,用于异步执行文件数据的同步,并推荐开发者使用apply()而非commit()。

看起来Editor+apply()方法对写操作做了很大的优化,但更多的问题随之而来,比如子线程更新文件,必然会引发 线程安全问题;此外,apply()方法真的能够像我们预期的一样,能够避免ANR吗?答案是并不能,这个我们后文再提。

4、数据的更新 & 文件数量的权衡

随着业务复杂度的上升,需要面对新的问题是,xml文件中的数据量愈发庞大,一次文件的写操作成本也愈发高昂。

xml中数据是如何更新的?读者可以简单理解为 全量更新 ——通过上文,我们知道xml文件中的数据会缓存到内存的mMap中,每次在调用editor.putXXX()时,实际上会将新的数据存入在mMap,当调用commit()或apply()时,最终会将mMap的所有数据全量更新到xml文件里。

由此可见,xml中数据量的大小,的确会对 写操作 的成本有一定的影响,因此,设计者更建议将 不同业务模块的数据分文件存储 ,即根据业务将数据存放在不同的xml文件中。

因此,不同的xml文件应该对应不同的SharedPreferences对象,如果想要对某个xml文件进行操作,就通过传不同的文件标识符,获取对应的SharedPreferences:

1
2
3
4
java复制代码@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// name参数就是文件名,通过不同文件名,获取指定的SharedPreferences对象
}

因此,当xml文件过大时,应该考虑根据业务,细分为若干个小的文件进行管理;但过多的小文件也会导致过多的SharedPreferences对象,不好管理且易混淆。实际开发中,开发者应根据业务的需要进行对应的平衡。

二、线程安全问题

SharedPreferences是线程安全的吗?

毫无疑问,SharedPreferences是线程安全的,但这只是对成品而言,对于我们目前的实现,显然还有一定的差距,如何保证线程安全呢?

——那,为了保证线程安全,怎么着不得加个锁吧。

加个锁?那是起步!3把锁,你还别嫌多。你得研究开发写代码时的心理,舍得往代码里吭哧吭哧加锁的开发,压根不在乎再加2把。

1、保证复杂流程代码的可读性

为了保证SharedPreferences是线程安全的,Google的设计者一共使用了3把锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码final class SharedPreferencesImpl implements SharedPreferences {
// 1、使用注释标记锁的顺序
// Lock ordering rules:
// - acquire SharedPreferencesImpl.mLock before EditorImpl.mLock
// - acquire mWritingToDiskLock before EditorImpl.mLock

// 2、通过注解标记持有的是哪把锁
@GuardedBy("mLock")
private Map<String, Object> mMap;

@GuardedBy("mWritingToDiskLock")
private long mDiskStateGeneration;

public final class EditorImpl implements Editor {
@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();
}
}

对于这样复杂的类而言,如何提高代码的可读性?SharedPreferencesImpl做了一个很好的示范:通过注释明确写明加锁的顺序,并为被加锁的成员使用@GuardedBy注解。

对于简单的 读操作 而言,我们知道其原理是读取内存中mMap的值并返回,那么为了保证线程安全,只需要加一把锁保证mMap的线程安全即可:

1
2
3
4
5
6
java复制代码public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}

那么,对于 写操作 而言,我们也能够通过一把锁达到线程安全的目的吗?

2、保证写操作的线程安全

对于写操作而言,每次putXXX()并不能立即更新在mMap中,这是理所当然的,如果开发者没有调用apply()方法,那么这些数据的更新理所当然应该被抛弃掉,但是如果直接更新在mMap中,那么数据就难以恢复。

因此,Editor本身也应该持有一个mEditorMap对象,用于存储数据的更新;只有当调用apply()时,才尝试将mEditorMap与mMap进行合并,以达到数据更新的目的。

因此,这里我们还需要另外一把锁保证mEditorMap的线程安全,笔者认为,不和mMap公用同一把锁的原因是,在apply()被调用之前,getXXX和putXXX理应是没有冲突的。

代码实现参考如下:

1
2
3
4
5
6
7
8
9
java复制代码public final class EditorImpl implements Editor {
@Override
public Editor putString(String key, String value) {
synchronized (mEditorLock) {
mEditorMap.put(key, value);
return this;
}
}
}

而当真正需要执行apply()进行写操作时,mEditorMap与mMap进行合并,这时必须通过2把锁保证mEditorMap与mMap的线程安全,保证mMap最终能够更新成功,最终向对应的xml文件中进行更新。

文件的更新理所当然也需要加一把锁:

1
2
3
4
java复制代码// SharedPreferencesImpl.EditorImpl.enqueueDiskWrite()
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}

最终,我们一共通过使用了3把锁,对整个写操作的线程安全进行了保证。

篇幅限制,本文不对源码进行详细引申,有兴趣的读者可参考 SharedPreferencesImpl.EditorImpl 类的apply()源码。

3、摆脱不掉的ANR

apply()方法设计的初衷是为了规避主线程的I/O操作导致ANR问题的产生,那么,ANR的问题真得到了有效的解决吗?

并没有,在 字节跳动技术团队 的 这篇文章 中,明确说明了线上环境中,相当一部分的ANR统计都来自于SharedPreference,由此可见,apply()并没有完全规避掉这个问题,那么导致ANR的原因又是什么呢。

经过我们的优化,SharedPreferences的确是线程安全的,apply()的内部实现也的确将I/O操作交给了子线程,可以说其本身是没有问题的,而其原因归根到底则是Android的另外一个机制。

在apply()方法中,首先会创建一个等待锁,根据源码版本的不同,最终更新文件的任务会交给QueuedWork.singleThreadExecutor()单个线程或者HandlerThread去执行,当文件更新完毕后会释放锁。

但当Activity.onStop()以及Service处理onStop等相关方法时,则会执行 QueuedWork.waitToFinish()等待所有的等待锁释放,因此如果SharedPreferences一直没有完成更新任务,有可能会导致卡在主线程,最终超时导致ANR。

什么情况下SharedPreferences会一直没有完成任务呢? 比如太频繁无节制的apply(),导致任务过多,这也侧面说明了SPUtils.putXXX()这种粗暴的设计的弊端。

Google为何这么设计呢?字节跳动技术团队的这篇文章中做出了如下猜测:

无论是 commit 还是 apply 都会产生 ANR,但从 Android 之初到目前 Android8.0,Google 一直没有修复此 bug,我们贸然处理会产生什么问题呢。Google 在 Activity 和 Service 调用 onStop 之前阻塞主线程来处理 SP,我们能猜到的唯一原因是尽可能的保证数据的持久化。因为如果在运行过程中产生了 crash,也会导致 SP 未持久化,持久化本身是 IO 操作,也会失败。

如此看来,导致这种缺陷的原因,其设计也的确是有自身的考量的,好在 这篇文章 末尾也提出了一个折衷的解决方案,有兴趣的读者可以了解一下,本文不赘述。

三、进程安全问题

1、如何保证进程安全

SharedPreferences是否进程安全呢?让我们打开SharedPreferences的源码,看一下最顶部类的注释:

1
2
3
4
5
6
7
8
java复制代码/**
* ...
* This class does not support use across multiple processes.
* ...
*/
public interface SharedPreferences {
// ...
}

由此,由于没有使用跨进程的锁,SharedPreferences是进程不安全的,在跨进程频繁读写会有数据丢失的可能,这显然不符合我们的期望。

那么,如何保证SharedPreferences进程的安全呢?

实现思路很多,比如使用文件锁,保证每次只有一个进程在访问这个文件;或者对于Android开发而言,ContentProvider作为官方倡导的跨进程组件,其它进程通过定制的ContentProvider用于访问SharedPreferences,同样可以保证SharedPreferences的进程安全;等等。

篇幅原因,对实现有兴趣的读者,可以参考 百度 或文章末尾的 参考资料。

2、文件损坏 & 备份机制

SharedPreferences再次迎来了新的挑战。

由于不可预知的原因(比如内核崩溃或者系统突然断电),xml文件的 写操作 异常中止,Android系统本身的文件系统虽然有很多保护措施,但依然会有数据丢失或者文件损坏的情况。

作为设计者,如何规避这样的问题呢?答案是对文件进行备份,SharedPreferences的写入操作正式执行之前,首先会对文件进行备份,将初始文件重命名为增加了一个.bak后缀的备份文件:

1
2
3
4
5
6
java复制代码// 尝试写入文件
private void writeToFile(...) {
if (!backupFileExists) {
!mFile.renameTo(mBackupFile);
}
}

这之后,尝试对文件进行写入操作,写入成功时,则将备份文件删除:

1
2
3
java复制代码// 写入成功,立即删除存在的备份文件
// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();

反之,若因异常情况(比如进程被杀)导致写入失败,进程再次启动后,若发现存在备份文件,则将备份文件重名为源文件,原本未完成写入的文件就直接丢弃:

1
2
3
4
5
6
7
8
9
java复制代码// 从磁盘初始化加载时执行
private void loadFromDisk() {
synchronized (mLock) {
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
}

现在,通过文件备份机制,我们能够保证数据只会丢失最后的更新,而之前成功保存的数据依然能够有效。

四、小结

综合来看,SharedPreferences那些一直被关注的问题,从设计的角度来看,都是有其自身考量的。

我们可以看到,虽然SharedPreferences其整体是比较完善的,但是为什么相比较MMKV和Jetpack DataStore,其性能依然有明显的落差呢?

这个原因更加综合且复杂,即使笔者也还是处于浅显的了解层面,比如后两者在其数据序列化方面都选用了更先进的protobuf协议,MMKV自身的数据的 增量更新 机制等等,有机会的话会另起新的一篇进行分享。

反过头来,相对于对组件之间单纯进行 好 和 不好 的定义,笔者更认为通过辩证的方式去看待和学习它们,相信即使是SharedPreferences,学习下来依然能够有所收获。

参考 & 感谢

细心的读者应该能够发现,关于 参考&感谢 一节,笔者着墨越来越多,原因无他,笔者 从不认为 一篇文章就能够讲一个知识体系讲解的面面俱到,本文亦如是。

因此,读者应该有选择性查看其它优质内容的权利,甚至是为其增加一些简洁的介绍(因为标题大多都很相似),而不是文章末尾甩一堆https开头的链接不知所云。

这也是对这些内容创作者的尊重,如果你喜欢本文,也同样希望你能够喜欢下面这些文章。

1、请不要滥用SharedPreference @Weishu

我们如何定义好的文章?深度 和 引人入胜,笔者觉得缺一不可,深度保证了文章能够经久不衰,引人入胜代表了流畅的 文字功底 和 文章结构,这篇文章将apply()导致的ANR原理通过浅显易懂的方式解构的非常透彻,我认为它是最适合进阶学习SharedPreferences的文章。

2、Android源码分析之SharedPreferences @xiaoweiz

对于一门技术,如何系统掌握其 理论 ,笔者的理解是,学习理解其设计思想,从零开始一步步完善整个系统结构,最终通过源码进行互相印证。

而对于SharedPreferences,学习设计思想,看本文;源码解析,看这篇。

3、Android 之不要滥用 SharedPreferences(下) @godliness

标题和1很相似,但内容更有深度,该文针对 多进程下的文件安全问题 和 文件备份机制 进行了源码级别的解析,值得收藏。

4、剖析 SharedPreference apply引起的ANR问题 @字节跳动技术团队

针对apply()方法导致的ANR的问题,进行了原因定位和解决方案,非常值得阅读。

5、再见 SharedPreferences 拥抱 Jetpack DataStore @HiDhl

最近笔者非常关注的博主,文章都很有深度,文章中根据SharedPreferences的缺陷都进行了系统性的阐述,也是因为该文,引发了笔者写本文缅怀SharedPreferences的想法。

6、通过ContentProvider实现SharedPreferences进程共享数据 @king龙123

7、Android使用读写锁实现多进程安全的SharedPreferences @痕迹丶

针对保证SharedPreferences多进程安全的实现方案,有兴趣的读者可以作为引申阅读。


关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。

如果您觉得文章还差了那么点东西,也请通过 关注 督促我写出更好的文章——万一哪天我进步了呢?

  • 我的Android学习体系
  • 关于文章纠错
  • 关于知识付费
  • 关于《反思》系列

本文转载自: 掘金

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

组件化:代码隔离也难不倒组件的按序初始化

发表于 2020-10-17

前言

时至今日,Android项目中的组件化大家都已经非常熟悉了,但在各个细节方面还是有一些门门道道的内容,如果没有趁手的中间件支持,推行组件化的过程中还是会遇到阻碍。继2017年逻辑思维得到项目团队开源其组件化方案思路和核心gradle构建插件后,笔者一直投身其中并致力于插件的功能升级和中间件生态完善。

其实在2018年就有部分同学提出了“增加按序初始化组件”的需求,限于个人精力以及需求的优先级,当时被搁置了,这个需求一拖也就拖了两年了。这次终于抽出时间完成了这个中间件,特此写了一篇博客,介绍其中的一些知识点和这个中间件Maat。Github仓库

大纲导图

方便阅读,导图附上。

注:即使你没有使用上面提到的得到组件化方案(DDComponentForAndroid)或者我们后续维护的JIMU,也不影响本篇文章的理解。

问题的根源

这里我们再花一点时间来了解下问题的根源:组件化的基础是模块化,在做到模块化的同时,模块与模块在编写、编译期间也就达成了完全代码隔离,组件间的交互依靠 底层接口+服务发现(或者服务注册) 或者更加抽象为 “基于协议、隐藏实现”。这带来了编写、编译期间激增的代码耦合*(注:此处语境遗漏,在达成编写、编译期间完全代码隔离的条件下,想要用比较原始的、直面问题的方式解决组件按序初始化问题,例如使用反射+无分支遗漏的逻辑涵盖所有组件组合情况,会导致耦合激增。2020年10月20日补)*。

我知道这样说实在是太晦涩了,一点也不接地气,我们以一个简单的例子来配合说明。

1
2
3
4
kotlin复制代码interface IComponent {
fun onCreate()
fun onDestroy()
}

我们定义这样的接口来代表一个组件模型。案例设定为:一个宿主H+两个互无关联的组件A、B

那么有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码class A : IComponent {
override fun onCreate() {
// A初始化逻辑
}

override fun onDestroy() {
}
}

class B : IComponent {
override fun onCreate() {
// B初始化逻辑
}

override fun onDestroy() {
}
}

另有

1
2
3
4
5
6
kotlin复制代码class H :Application {
override fun onCreate() {
A().onCreate()
B().onCreate()
}
}

我们以最简单的代码演示组件的加载和初始化环节。这里隐藏了一个问题:如果是手工编码,那么是存在代码边界的,编写、编译期间H无法直接访问A和B,我们只能通过反射去实现(否则编译不通过)。当然,也可以通过字节码技术实现

如果我们要让B先于A初始化,那么就调整其顺序,这对于手工编码方式而言,可能就是将编码变为:

1
2
arduino复制代码XXX.loadComponent("Bpackage.B") //"Bpackage.B"为B的类路径
XXX.loadComponent("Apackage.A")

而利用字节码技术的,则需要增加排序功能或者读取全量配置功能。

案例2:
此时A组件依赖于B,必须等B组件初始化成功并得到结果后才能初始化。

思路1:先加载和初始化B,利用代码同步的特性,再初始化A

思路2:先加载和初始化B,修改组件模型,增加callback作为入参,异步初始化A

思路1存在很大的限制,比如其初始化需要参与网络通信或者数据库操作;思路2对于手工编码来说,会产生回调地狱,而对于字节码技术实现而言,就是一个噩梦

而且,JIMU已经投入使用挺长一段时间了,如果不是毫无选择,对于基类或者接口做无法版本兼容的操作都不应该被采纳

思路2的改进版:增加上下文,使得回调嵌套扁平化。

既然我们决定增加一个上下文,那么将初始化的管理工作进行封装就成了顺理成章的事情

为什么不使用官方StartUp而选择造轮子

在思考这个问题时,我们必须要清楚Startup的设计意图

Startup 中文介绍

可在应用启动时简单、高效地初始化组件。
借助 App Startup 库,可在应用启动时简单、高效地初始化组件。库开发者和应用开发者都可以使用 App Startup 来简化启动序列并显式设置初始化顺序。

我们知道,在Startup发布之前,各大SDK采用的初始化方式一般为两种:

  • 显式API调用,需要Application实例
  • 内部提供一个ContentProvider,并在其中获取Application实例。因为其特性,会在应用启动时被自动加载,而不再需要使用者显式的API调用

一般为了方便开发者,在manifest文件中写入SDK参数配置并利用Context(为了不造成泄漏,使用Application是最好的选择)读取配置的做法更受推荐。所以第二种方式的使用越来越多。

这就带来了一个问题:引入越多的SDK就会引入更多的ContentProvider,他们并不会随着初始化工作完成而消亡,而且加重了应用启动时AMS的负担。

业内存在一个著名的编程范式:约定优于配置,既然使用ContentProvider作为初始化入口已经被广泛接受,那么Google作为生态维护者提供一个官方库,使用统一的初始化入口,使用者只需要按照约定暴露初始化逻辑,并且提供了前置依赖使得任务可排序的功能。

到这里我们就可以明白这样几件事情:

  • StartUp中使用异步和其排序加载之间存在“矛盾”
  • StartUp不提供依赖有向无环图校验

因为StartUp更主要的是面向SDK,提供统一标准。SDK库之间出现“存在性上的先后关系”的场景本身就非常小,如果有“依赖”,SDK生产者在库内部都处理好了,一般也不会出现代码边界。

所以,Maat并不是一个和StartUp一较长短的功能库,而是为了解决特定问题而编写的功能库。这些问题又恰恰是StartUp所不涉及的

设计思路

相信大家对“同步”和“异步”都有比较深的理解,我们先提出三个参与初始化的角色:

  • 任务: 初始化工作的最小单元,清晰的知道自己的所依赖的任务,只有依赖的任务都执行完毕后才能执行,我们以Task=Name[dependency1,dependency2,...]来表示任务,例如 B[] ==> 无前置依赖的任务B, A[B] ==> 任务A、依赖任务B
  • 任务集:所有任务的集合,可分析任务的所有前置依赖并判断是否存在循环依赖,对任务进行排序,记为 TaskGroup={Task1,Task2,...}
  • 任务调度器:从任务集中取出任务派发执行的调度器

回顾我们最开始给出的例子,组件之前有存在性先后关系,必须要让依赖的组件完成初始化后才能开始加载。 那么任务调度器的工作方式是“同步”的,在“被依赖的任务”执行完毕前,依赖他的任务都必须阻塞等待。

但是思考一个问题:两个互相独立的任务,必须阻塞等待吗?答案显然,不是必须的。

这里举一些例子:

有任务集:{A[],B[],C[A,B]},A和B是无依赖的,C依赖任务A和B,

那么任务调度器可以按照A、B、C的顺序进行调度,

也可以按照B、A、C的顺序进行调度每个任务执行中,任务调度器都阻塞等待,

也可以让AB两个任务并发(需要分配到不同线程)阻塞等待AB均完成后调度C。在第一个版本设计中,我还没有采用这个方案,目前让库保持足够轻量。当存在多组初始化路径时,其复杂程度远大于本处的例子

有向无环图(DAG)

接下来我们适当花一些篇幅来讨论DAG。在我们上面提到的任务集这一角色中,我们使用了DAG来处理拓扑排序和依赖无环校验。

我们将任务看做是图中的顶点,任务的依赖关系看做是边,方向和依赖方向相反,即A[B]意味着有从B到A的边。将所有的任务合并起来后我们将得到一份有向图,显然,成环的依赖是不被允许的。

为了更好的理解,我们人为的添加一个虚拟的顶点Start,作为初始化任务集的第一个任务,将所有无依赖的任务人为添加一个前置依赖:Start。

一个合法的任务集,必然没有成环的依赖,所以一定不是强连通图,在我们添加了虚拟顶点start后,其基图一定是连通图,故而合法的任务集(包含虚拟Start节点)是一个弱连通图

环校验

我们采用DFS方式递归遍历,受益于我们制定的虚拟顶点Start,我们可以直接从这个顶点开始。

定义深度集合 deepPathList,选定起始顶点S, 定义回环顶点列表 loopbackList,
定义路径列表 pathList

直接上代码 getEdgeContainsPoint(startPoint, Type.X) 代表取出所有以startPoint为起始点的边

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码fun recursive(startPoint: T, pathList: MutableList<T>) {
if (pathList.contains(startPoint)) {
loopbackList.add("${debugPathInfo(pathList)}->${startPoint.let(nameOf)}")
return
}
pathList.add(startPoint)
val edgesFromStartPoint = getEdgeContainsPoint(startPoint, Type.X)
if (edgesFromStartPoint.isEmpty()) {
val descList: ArrayList<T> = ArrayList(pathList.size)
pathList.forEach { path -> descList.add(path) }
deepPathList.add(descList)
}
edgesFromStartPoint.forEach {
recursive(it.to, pathList)
}

pathList.remove(startPoint)
}

如果loopbackList不为空,则代表存在回环,回环的信息就存放在loopbackList中

契合需求的排序方式

上面我们已经提到了深度优先遍历(DFS),但是这种方式作出的拓扑排序不适合我们的需求,他适合寻找最优或者最差路径。而广度优先遍历(BFS)才契合需求。

直接给出代码:

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
scss复制代码private fun DAG<JOB>.bfs(): JobChunk {

val zeroDeque = ArrayDeque<JOB>()
val inDegrees = HashMap<JOB, Int>().apply {
putAll(this@bfs.inDegreeCache)
}
inDegrees.forEach { (v, d) ->
if (d == 0)
zeroDeque.offer(v)
}

val head = JobChunk.head()
var currentChunk = head

val tmpDeque = ArrayDeque<JOB>()

while (zeroDeque.isNotEmpty() || tmpDeque.isNotEmpty()) {
if (zeroDeque.isEmpty()) {
currentChunk = currentChunk.append()
zeroDeque.addAll(tmpDeque)
tmpDeque.clear()
}
zeroDeque.poll()?.let { vertex ->
currentChunk.addJob(vertex)

this.getEdgeContainsPoint(vertex, Type.X).forEach { edge ->
inDegrees[edge.to] = (inDegrees[edge.to] ?: 0).minus(edge.weight).apply {
if (this == 0)
tmpDeque.offer(edge.to)
}
}
}
}
return head
}

其中JubChunk是一组无关联的Job 即前文提到的初始化任务,前面提到目前没有让任务的执行可并发,JobChunk是为了可支持并发做准备的

关于DAG的部分我们就不再花篇幅介绍了,有兴趣的同学可以自行查阅相关资料

任务的描述

先上代码:

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
kotlin复制代码abstract class JOB {
abstract val uniqueKey: String

abstract val dependsOn: List<String>

abstract val dispatcher: CoroutineDispatcher

internal fun runInit(maat: Maat) {
MainScope().launch {
flow {
init(maat)
emit(true)
}
.flowOn(dispatcher)
.catch {
maat.onJobFailed(this@JOB,it)
}.flowOn(Dispatchers.Main)
.collect {
maat.onJobSuccess(this@JOB)
}
}
}

abstract fun init(maat: Maat)
}

考虑到kotlin已经被官方推荐很长时间了,并且在去年Retrofit已经开始支持协程,姑且认为大部分项目中都已经开始使用协程了。所以很偷懒的直接使用了协程和Flow

  • uniqueKey 是当前任务名,需要人为确保唯一性
  • dependsOn 是当前任务所依赖的任务的uniqueKey的集合,虽然使用了List,但是顺序无关。
  • dispatcher 指定任务执行被分配到的线程类型
  • fun init(maat: Maat) 实际初始化逻辑,注意:按需求分析初始化代码块是否需要 “同步、阻塞”,如果部分代码是“异步、基于回调”且无法更改,这个实际场景(必须要异步获取结果,且该结果被另一个组件使用)想来很少见,第一个版本中我没有考虑,下个版本我会加上

示例代码模拟了4个初始化任务,有点长,具体的使用可以看一下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
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
kotlin复制代码val maat = Maat.init(application = this, printChunkMax = 6,
logger = object : Maat.Logger() {
override val enable: Boolean = true


override fun log(msg: String, throws: Throwable?) {
Log.d("maat", msg, throws)
}

}, callback = Maat.Callback(onSuccess = {}, onFailure = { maat, job, throwable ->

})
)

maat.append(object : JOB() {
override val uniqueKey: String = "a"
override val dependsOn: List<String> = emptyList()
override val dispatcher: CoroutineDispatcher = Dispatchers.IO

override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
//test exception
// throw NullPointerException("just a test")
}

override fun toString(): String {
return uniqueKey
}

}).append(object : JOB() {
override val uniqueKey: String = "b"
override val dependsOn: List<String> = arrayListOf("a")
override val dispatcher: CoroutineDispatcher = Dispatchers.Main /* + Job()*/

override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
}

override fun toString(): String {
return uniqueKey
}

}).append(object : JOB() {
override val uniqueKey: String = "c"
override val dependsOn: List<String> = arrayListOf("a")
override val dispatcher: CoroutineDispatcher = Dispatchers.IO /* + Job()*/

override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
}

override fun toString(): String {
return uniqueKey
}

}).append(object : JOB() {
override val uniqueKey: String = "d"
override val dependsOn: List<String> = arrayListOf("a", "b", "c")
override val dispatcher: CoroutineDispatcher = Dispatchers.Main

override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
}

override fun toString(): String {
return uniqueKey
}

}).start()

在JIMU中使用

JIMU是一种很彻底的组件化方案,意味着编写代码时存在代码边界,即使是空壳宿主和业务组件之间也存在。前面也提到了,JIMU是使用字节码技术织入的组件加载代码(设置为自动加载组件时),而织入的代码是在Application的onCreate最后执行。

这这一前提下,如果通过javasist实现Maat的任务设置部分,他的可维护性将很差。所以我建议将任务设置部分放在组件的初始化入口处,这样可读性和可维护性都相对好一点.

以原先的分享业务组件为例:

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
typescript复制代码public class ShareApplike implements IApplicationLike {

UIRouter uiRouter = UIRouter.getInstance();

@Override
public void onCreate() {
uiRouter.registerUI("share");
Log.e("share","share on create");
Maat.Companion.getDefault().append(new JOB() {
@NotNull
@Override
public String getUniqueKey() {
return "share";
}

@NotNull
@Override
public List<String> getDependsOn() {
return Collections.singletonList("reader");
}

@NotNull
@Override
public CoroutineDispatcher getDispatcher() {
return Dispatchers.getMain();
}

@Override
public void init(@NotNull Maat maat) {
Log.d("share", "模拟初始化share,context:" + maat.getApplication().getClass().getName());
}

@Override
public String toString() {
return getUniqueKey();
}
});
}

@Override
public void onStop() {
uiRouter.unregisterUI("share");
}
}

当然,务必不要忘记在Application的onCreate()中先初始化Maat:

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
typescript复制代码Maat.Companion.init(this, 8, new Maat.Logger() {
@Override
public boolean getEnable() {
return true;
}

@Override
public void log(@NotNull String s, @Nullable Throwable throwable) {
if (throwable != null) {
Log.e("maat",s,throwable);
} else {
Log.d("maat",s);
}
}
}, new Maat.Callback(new Function1<Maat, Unit>() {
@Override
public Unit invoke(Maat maat) {
Maat.Companion.release();
return null;
}
}, new Function3<Maat, JOB, Throwable, Unit>() {
@Override
public Unit invoke(Maat maat, JOB job, Throwable throwable) {
return null;
}
}));

而Maat的启动API调用,自然由javasist织入了。配合最新的gradle插件 build-gradle:1.3.4方可使用,启用开关为:

1
2
3
ini复制代码combuild {
useMaat = true/false
}

非常重要:

请务必分析项目的组件初始化场景,在Maat适用你的应用场景时再使用。

目前Maat保持了轻量化,如果您有一些合理的需求,欢迎留言或者提issue交流

本文转载自: 掘金

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

计算机网络 图解 DNS & HTTPDNS 原理

发表于 2020-10-16

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • DNS 往往是网络请求的第一步,在计算机网络面试中,DNS 也是除 HTTP、TCP 之外较重点考察的知识,其重要性可想而知。
  • 在这篇文章里,我将梳理图解 DNS & HTTPDNS 的原理知识。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

系列文章

  • 《计算机网络 | 图解 DNS & HTTPDNS 原理》

相关文章

  • 《密码学 | 高屋建瓴!摘要、签名与数字证书都是什么?》

目录


  1. DNS 原理

1.1 DNS 简介

域名(Domain Name,Domain) 是一个在互联网上标识主机或主机组的名称,相当于 IP 地址的别名,相对于晦涩难记的 IP 地址,域名更显得易于记忆。

域名系统(Domain Name System,DNS) 则是将域名解析 IP 地址的一项互联网基础服务,提供该服务的服务器称为 域名服务器(Domain Name Server)。

1.2 DNS 解析过程

互联网上的域名系统是一个分布式的系统,结构上是一个四层的树状层次结构,如下图所示:

DNS 服务器层次结构

  • 本地域名服务器(Local Name Server,local DNS):如果通过 DHCP 配置,local DNS 由互联网服务提供商(ISP,如联通、电信)提供;
  • 根域名服务器(Root Name Server):当 local DNS 查询不到解析结果时,第一步会向它进行查询,并获取顶级域名服务器的IP地址。全球一共有 13 个根域名服务器(除了它们的镜像),它们并不直接用于域名解析,仅用于指出可查询的顶级域名服务器。这个网站记录了现有的 13 个根域名服务器:www.internic.net/domain/name…;
  • 顶级域名服务器(Top-level Name Server):负责管理在该顶级域名服务器下注册的二级域名,例如**.com 顶级域名服务器**,而 baidu.com 权威服务器是注册在 .com 的权威域名服务器;
  • 权威域名服务器(Authoritative Name Server):在特定区域内具有唯一性,负责维护该区域内的域名与 IP 地址的映射关系。在 DNS 应答报文中,标志位 AA 标识本次 DNS 记录是否来自权威域名服务器,否则可能来自缓存。

DNS 解析分为 递归查询 和 迭代查询 两种方式。其中,客户端与 Local DNS 之间一般采用递归查询,而 DNS 服务器之间一般采用迭代查询。

**提示:**如果 DNS 服务器之间使用递归查询,对根域名服务器的负担就太重了,而如果客户端与本地域名服务器之间使用迭代查询,DNS 服务对于客户端就显得不透明了。

  • 递归查询:

所谓递归查询,与我们经常提及的递归函数的思想是一致的,即:**如果 DNS 服务器查不到该域名,那么它将重新以客户端的身份向其他 DNS 服务器发送查询请求报文,客户端只要等待最终结果即可。**用伪代码呈现可能比较好理解,类似这样:

1
2
3
4
5
6
7
kotlin复制代码fun dns(client: String, server: String, domain: String): String {
if (server 查询 domain 成功) {
return "ip"
}
// server 以客户端身份递归查询
return dns(server, "其他 DNS 服务器", domain)
}
  • 迭代查询:

所谓迭代查询,即:**如果 DNS 服务器查不到该域名,它不会替客户端完成后续的查询工作,而是回复下一步应当向哪一个域名服务器进行查询,随后客户端重新向这个新的 DNS 服务器发送查询请求。**用伪代码呈现可能比较好理解,类似这样:

1
2
3
4
5
6
7
8
9
10
kotlin复制代码fun dns(client: String, server: String, domain: String): String {
while (true) {
if (server 查询 domain 成功) {
return "ip"
} else {
// client 继续以客户端身份迭代查询
server = "其他 DNS 服务器"
}
}
}

下面以查询www.baidu.com为例,阐述一次 DNS 解析过程:

  • 0、首先检查 DNS 缓存,下一节我们会讲,如果缓存老化或未命中,客户端需要向 local DNS 发送查询请求报文
  • 1、客户端向 local DNS 发送查询报文 query www.baidu.com,local DNS 首先检查自身缓存,如果存在记录则直接返回结果,查询结束;如果缓存老化或未命中,则:
  • 2、local DNS 向根域名服务器发送查询报文 query www.baidu.com,返回 .com 顶级域名服务器的地址(如果查无记录)
  • 3、local DNS 向 .com 顶级域名服务器发送查询报文 query www.baidu.com,返回baidu.com所在的权威域名服务器的地址(如果查无记录)
  • 4、local DNS 向 baidu.com 权威域名服务器发送查询报文 query www.baidu.com,得到 ip 地址,存入自身缓存并返回给客户端

DNS 解析过程

1.3 DNS 报文

由于下一节我们将实战抓取 DNS 包,所以这一节我们先介绍 DNS 报文格式。DNS 协议定义了三种报文,查询报文 & 应答报文 & 更新报文,它们的总体上结构是一致的。

  • 报文首部(Header)
    • 1、事务 ID(Transaction ID):用来关联 DNS 查询与应答,DNS客户端每次发送查询请求都会使用不同的 ID,而服务器在响应中重复这个 ID
    • 2、标志(Flags):报文的标志字段,详见下图
    • 3、问题数(Question Count):指定问题部分条目数
    • 4、回答资源记录数(Answer Resource Record count):指定应答部分中回答资源条目数
    • 5、权威资源记录数(Authority Resource Record count):指定权威资源记录数
    • 6、附加资源记录数(Additional Resource Record count):指定附加资源记录数

DNS 报文首部

  • 问题(Question)

问题用于表达具体查询的问题,问题个数与报文首部中的 **问题数(Question Count)**字段一致。需要注意的是,按照 DNS 查询的目的,DNS 解析可以分为 正向解析 和 反向解析 两种,正向解析将域名解析为 IP 地址,而反向解析则恰恰相反,用于将 IP 地址解析域名。问题条目中 查询类型 是比较重要的字段,这里仅列出 5 个比较常用的类型:

QTYPE 描述
A(1) 将域名解析为 IPv4 地址
NS(2) 查询域名服务
CNAME(5) 规范名称
PTR(12) IP 地址解析为域名
AAAA(28) 将域名解析为 IPv6 地址

问题格式

NS 和 CNAME 不好理解,这里解释下:

CNAME(Canonical NAME) 是规范名称或别名,用于将一个域名指向另一个域名。具体方法是:将一个域名作为 A 记录 指向 IP 地址,而其他域名作为别名指向 A 记录的域名,此时如果需要更改 IP 地址,就不需要更改每个域名的映射了,只需要修改 A 记录 ,而 CNAME 记录将自动指向新的 IP 地址。在 第 1.4 节 DNS 解析实战 中你将直接看到 CNAME 的应用。

NS(Name Server) :域名服务器记录,用来指定该域名由哪个 DNS 服务器来进行解析

  • 资源记录(Resource Record)

回答资源记录、权威资源记录和附加资源记录的格式相同,其中 TTL(Time to Live,单位秒) 表示资源记录的生存时间,也就是允许缓存的时间。0 表示该记录只能用于当前响应,不允许被缓存。

资源记录格式

1.4 DNS 报文的传输协议

DNS 协议在传输层同时使用 TCP 和 UDP 协议,占用的是 53 端口,那么在什么情况下使用这两种协议?

  • 在区域传输时使用 TCP 协议

主辅域名服务器在进行区域传送时(主辅域名服务器用于平衡负载),需要传送的数据比简单的查询 & 应答报文的数据量要大得多了。使用 UDP 传输不可靠,所以采用应用于传输大量数据,可靠性更高的 TCP 协议。

  • 在域名解析时使用 UDP 协议

为了得到一个域名的 IP 地址,往往会向多个域名服务器查询,如果使用 TCP 协议,那么每次请求都会存在三次握手连接时延,这样使 DNS 服务变得很慢。

需要注意的是,DNS 协议规定 UDP 报文段的最大长度为 512 字节,如果 DNS 报文段过长时会被截断(此时 DNS 报文头中标志位 TC(Truncation)置为 1),多余的数据会直接丢弃。这是因为 UDP 是无连接的,无法确定哪几个 UDP 包是属于同一个 DNS 报文段的。

1.5 DNS 解析实战

计算机网络是在实践中发展起来的学科,仅停留在学习理论知识的层面是不够的,下面我们将在实践中学习 DNS 解析。在这里,我们是用 WireShark 抓取查询www.baidu.com的 DNS 请求,具体步骤如下:

  • 步骤一:设置 WireShark 过滤条件

在过滤条件栏输入条件:icmp || dns,如下图:

  • 步骤二:终端 ping www.baidu.com

在终端输入ping www.baidu.com,如下图:

  • 步骤三:查看 DNS 查询 & 应答报文

返回 WireShark,查看抓取的消息,可以看到两条 DNS 报文消息,一条为查询报文,另一条为应答报文,如下图:


现在我们具体查看这两条 DNS 报文消息,有了上一节的铺垫,相信阅读这两段报文已经很简单了。先看 DNS 协议的报文段部分,下层协议的报文段后面讲:

  • 查询报文:

在这个报文里提出了一个问题,即:查询 www.baidu.com 的 IPv4 地址(A 记录),标记位指出以下信息:这是一个查询报文;这是一次正向解析;报文未截断;要求服务器执行递归查询;

  • 应答报文:

  • 传输层 & 网络层:

从图中还验证了 DNS 在进行域名解析时使用 UDP 协议,端口号为 53,与上一小节的分析一致。另外,还可以看出 IP 包的第一跳是发送给局域网路由器,而不是直接发送给 local DNS 服务器,这也合理。

1.6 DNS 缓存

一次完整的 DNS 查询过程需要访问多台 DNS 服务器才能得到最终的结果,这肯定会带来一定的时延。为了改善时延,DNS 服务并不是每次请求都要去访问 DNS 服务器,而是访问过一次后将 DNS 记录缓存在本地。具体来说,DNS 服务是一个多级的缓存:

浏览器缓存 -> 操作系统缓存 -> 路由器缓存 -> local DNS 缓存 -> DNS 查询

缓存并不是永久有效的,前面提到过 DNS 应答报文中的 TTL(Time to Live)值,它决定了 DNS 记录在缓存中的有效时间。需要注意的是,TTL 只是一个参考值,实际使用的缓存有效时间不一定等于该值,甚至是固定值。这也引发 DNS 缓存也存在一些“副作用”,我后文再说。


  1. DNS 存在的问题

经过上一节的 DNS 理论知识学习和实践探索,相信大家对 DNS 已经建立起了一定的认识。那么,DNS 是一个完备的服务吗,在实践中它有存在什么问题呢?这一节我们来讨论这个问题。

2.1 DNS 查询时延

从第一节的分析可以看出,一次完整的 DNS 查询过程需要访问多台 DNS 服务器才能得到最终的结果,这肯定会带来一定的时延。从实践来看,这个时间还不容小觑。

提示: 有赞技术团队指出,DNS 解析时延的波动较大,好的情况几毫秒、十几毫秒就完成了,差的时候,可能需要花很多时间:《有赞webview加速平台探索与建设》 —— 有赞移动组

2.2 缓存一致性

DNS 缓存的存在虽然减少了时延,却是以牺牲一致性(consistency)为代价的。具体来说:Local DNS 是分地区、分运营商的,在域名解析缓存的处理上,实现策略就不统一了。有时候 Local DNS 的解析结果 可能不是最近、最优的节点,有的时候并不会遵从 TTL 的限制,而是设置一个固定时间。这就会导致域名指向新的 IP 地址后,一些客户端依然访问了缓存中 旧的 IP 地址。

除了运营商的缓存策略外,缓存投毒也是降低 DNS 可用性的原因。攻击者可以通过 DNS 劫持,利用 DNS 的缓存机制不对应答数据做检查的漏洞,诱骗 DNS 服务器缓存较大 TTL 的虚假 DNS 记录,从而长期欺骗客户端。

2.3 DNS 劫持(中间人攻击)

由于 DNS 缺乏 加密、认证、完整性保护的安全机制,容易引发网络完全问题。最常见的域名劫持攻击是针对 DNS 报文首部的 事务 ID 进行欺骗,由于事务 ID 在查询报文和应答报文中是匹配的,因此伪装 DNS 服务器可以提前将事务 ID 相同的伪造报文发送到客户端,以实现域名劫持(前提是合法的报文还未到达),把目标网站域名解析到错误的 IP 地址。

提示: 获取事务 ID 的方法主要采用 网络监听与序列号猜测,具体可翻阅《计算机网路安全原理》 (第 8 章)—— 吴礼发 著

2.4 调度不精准问题

由于存在缓存、转发、NAT 等问题,权威的 DNS 服务器可能会误判客户端所在的位置和运营商,从而导致解析出跨运营商访问的 IP 地址,用户的访问速度降低。


  1. HTTPDNS 原理

虽然 DNS 存在不少问题,但也不能因噎废食放弃整套域名系统,解决方案无非是不走寻常路,换一种方式获取 IP 地址 —— HTTPDNS。

3.1 HTTPDNS 简介

与传统的 DNS 解析不同,HTTPDNS 是自己搭建基于 HTTP 协议的服务器,当客户端需要 DNS 解析的时候,不再向 local 发送 DNS 查询报文,而是直接通过请求直接访问 HTTPDNS 接口。而服务端则根据客户端的位置和所属运营商,返回就近的 IP 地址。

当然了,基于容灾考虑,当出现 HTTPDNS 不可用时会触发降级策略,使用运营商 LocalDNS 进行域名解析。

HTTPDNS 原理

3.2 HTTPDNS 优势

相对与 DNS,HTTPDNS 的主要优点如下:

  • 降低时延
    缩短了查询链路,不像 DNS 查询那样需要访问多台 DNS 服务器才能得到最终的结果;
  • 域名防劫持
    域名解析请求直接发送至HTTPDNS服务器,绕过运营商 Local DNS,避免域名劫持问题;
  • 调度精准
    由于 DNS 服务器端获取的是真实客户端 IP 而非 Local DNS 的 IP,能够精确基于客户端位置、运营商信息,获得最精准的解析结果,让客户端就近接入业务节点
  • 快速生效
    域名解析结果变更时,HTTPDNS 服务没有传统DNS 服务多级缓存的影响,域名更新能够更快地覆盖到全量客户端。

3.3 HTTPDNS 正向效益

目前,腾讯、阿里和百度都有自己的 HTTPDNS 解决方案,笔者收集了他们公示的使用效益,具体如下:

  • 腾讯
+ [官方文档](https://cloud.tencent.com/document/product/379/3520) :覆盖超4亿+用户,减少了超过60%的由于域名劫持导致的用户访问失败,减少了22%的用户平均延迟;
+ [官方博客](https://mp.weixin.qq.com/s/u6-53Kp9Jb48dKWzaJOKig):用户平均访问延迟下降超过10%,访问失败率下降了超过五分之一;
  • 百度
+ [官方博客](https://mp.weixin.qq.com/s/iaPtSF-twWz-AN66UJUBDg):iOS劫持率由0.12%降低到0.0002%,Android劫持率由0.25%降低到0.05%,第二点的收益不明显,原因在于Feed业务主要目标群体在国内,百度在国内节点布局相对丰富,服务整体质量也较高;
  • 阿里
    未查及…

  1. 总结

应试方面,建议重点掌握四层 DNS 解析过程 & HTTPDNS 原理、理解知晓 DNS 存在的问题、DNS 报文格式重点理解 TTL、几种查询类型。


参考资料

  • 《域名系统》 —— 维基百科
  • 《How DNS Works》 —— Microsoft 文档
  • 《计算机网路安全原理》 (第 8 章)—— 吴礼发 著
  • 《TCP-IP 协议及其应用》 (第 8 章)—— 林成浴 著
  • 《全局精确流量调度新思路 - HttpDNS 服务详解》 —— 廖伟健 著
  • 《百度 App 网络深度优化系列《一》DNS优化》 —— 蔡锐 著
  • 《万字长文!搞定逃不脱的 DNS 面试题》 (第 2 节) —— 我是程序员小贱 著
  • 《为什么 DNS 使用 UDP 而不是 TCP?》 (车小胖的回答)—— 知乎问答
  • [《TCP/IP详解·卷1 —— 协议》] —— [美]Kevin R.Fall、W.Richard Stevens 著
  • 《What really happens when you navigate to a URL》 (第 2 节) —— Igor Ostrovsky 著

实用资源

  • 在线 DNS 查询工具
  • HTTPDNS · 阿里云
  • HTTPDNS · 腾讯云
  • HTTPDNS · 百度云

推荐阅读

  • 密码学 | Base64是加密算法吗?
  • 算法面试题 | 回溯算法解题框架
  • 算法面试题 | 链表问题总结
  • Java | 带你理解 ServiceLoader 的原理与设计思想
  • Android | 面试必问的 Handler,你确定不看看?
  • Android | 带你理解 NativeAllocationRegistry 的原理与设计思想
  • 计算机组成原理 | Unicode 和 UTF-8是什么关系?
  • 计算机组成原理 | 为什么浮点数运算不精确?(阿里笔试)

创作不易,你的「三连」是丑丑最大的动力,我们下次见!

本文转载自: 掘金

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

快速理解golang标准库net/http包(一)客户端篇

发表于 2020-10-16

标准库的net/http包提供了HTTP客户端和服务端的实现,本文通过几个主要的结构体来了解http包中与客户端相关的主要功能。

  1. Client

client 负责把请求发送给server端,通过一个http.Transport实例指定http请求的低级别配置,如果没有配置会用一个默认的DefualtTransport实例代替。

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
golang复制代码// A Client is an HTTP client. Its zero value (DefaultClient) is a usable client that uses DefaultTransport.
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}

// RoundTripper接口提供一个最终发送请求的方法,http.Transport实现了RoundTripper接口
type RoundTripper interface {
// RoundTrip执行单个HTTP事务,为所提供的请求返回响应。
RoundTrip(*Request) (*Response, error)
}

// DefaultTransport
var DefaultTransport RoundTripper = &Transport{
Proxy: ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}

Do()方法最终都会调用RoundTrip处理请求

1
2
3
4
5
6
7
8
9
10
11
golang复制代码// send issues an HTTP request.
func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {

// 为了节省空间,隐藏这部分代码

resp, err = rt.RoundTrip(req)

// 为了节省空间,隐藏这部分代码

return resp, nil, nil
}

为http.Client配置Transport实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
golang复制代码tr := &http.Transport{
Proxy: http.ProxyURL(w.ProxyAddr),
MaxIdleConnsPerHost: min(w.C, maxConn),
DisableCompression: w.DisableCompression,
DisableKeepAlives: w.DisableKeepAlives,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
ServerName: url.Host,
},
}
// 通过Transport实例为Http.Client增加配置
client := &http.Client{
Transport: tr,
Timeout: w.ConfigYaml.Http.Timeout * time.Second,
}

Client结构体提供的发送http请求的方法:

Do()接收一个http.Request参数,其他几个请求内部都是调用Do()方法

Get(),Head(),

Post(),可以自定义contentType,接收一个body数据

1
2
3
4
5
6
7
8
golang复制代码func (c *Client) Post(url, contentType string, body io.Reader) (resp *Response, err error) {
req, err := NewRequest("POST", url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
return c.Do(req)
}

PostForm(),也是发送一个post请求,区别在于PostForm的contentType固定为”application/x-www-form-urlencoded”,接收一个url.Values类型的数据,再将数据编码为”URL编码”形式(“bar=baz&foo=quux”),

1
2
3
golang复制代码func (c *Client) PostForm(url string, data url.Values) (resp *Response, err error) {
return c.Post(url, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
}

CloseIdleConnections(),关闭以前请求的但现在处于“keep-alive”状态的所有连接。它不会中断任何当前正在使用的连接。

  1. Transport

配置http请求的低级别信息的结构体

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
golang复制代码Package:
http

// A Transport is a low-level primitive for making HTTP and HTTPS requests.
// For high-level functionality, such as cookies and redirects, see Client.
type Transport struct {
idleMu sync.Mutex
closeIdle bool
idleConn map[connectMethodKey][]*persistConn
idleConnWait map[connectMethodKey]wantConnQueue
idleLRU connLRU
reqMu sync.Mutex
reqCanceler map[*Request]func(error)
altMu sync.Mutex
altProto atomic.Value
connsPerHostMu sync.Mutex
connsPerHost map[connectMethodKey]int
connsPerHostWait map[connectMethodKey]wantConnQueue
Proxy func(*Request) (*url.URL, error)
DialContext func(ctx context.Context, network string, addr string) (net.Conn, error)
Dial func(network string, addr string) (net.Conn, error)
DialTLSContext func(ctx context.Context, network string, addr string) (net.Conn, error)
DialTLS func(network string, addr string) (net.Conn, error)
TLSClientConfig *tls.Config
TLSHandshakeTimeout time.Duration
DisableKeepAlives bool
DisableCompression bool
MaxIdleConns int
MaxIdleConnsPerHost int
MaxConnsPerHost int
IdleConnTimeout time.Duration
ResponseHeaderTimeout time.Duration
ExpectContinueTimeout time.Duration
TLSNextProto map[string]func(authority string, c *tls.Conn) RoundTripper
ProxyConnectHeader Header
MaxResponseHeaderBytes int64
WriteBufferSize int
ReadBufferSize int
nextProtoOnce sync.Once
h2transport h2Transport
tlsNextProtoWasNil bool
ForceAttemptHTTP2 bool
}

http.Client实际执行请求时会带上Transport实例,如果没配置就用默认的DefaultTransport

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
golang复制代码func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
if c.Jar != nil {
for _, cookie := range c.Jar.Cookies(req.URL) {
req.AddCookie(cookie)
}
}

// 实际执行请求时会带上Transport实例
resp, didTimeout, err = send(req, c.transport(), deadline)
if err != nil {
return nil, didTimeout, err
}
if c.Jar != nil {
if rc := resp.Cookies(); len(rc) > 0 {
c.Jar.SetCookies(req.URL, rc)
}
}
return resp, nil, nil
}

// 如果没配置Http.Transport,会用一个DefaultTransport代替
func (c *Client) transport() RoundTripper {
if c.Transport != nil {
return c.Transport
}
return DefaultTransport
}
  1. Request

Request代表通过一个Server接收的请求,或者一个通过Client发送的请求。

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
golang复制代码//A Request represents an HTTP request received by a server or to be sent by a client.
type Request struct {
Method string
URL *url.URL
Proto string
ProtoMajor int
ProtoMinor int
Header Header
Body io.ReadCloser
GetBody func() (io.ReadCloser, error)
ContentLength int64
TransferEncoding []string
Close bool
Host string
Form url.Values
PostForm url.Values
MultipartForm *multipart.Form
Trailer Header
RemoteAddr string
RequestURI string
TLS *tls.ConnectionState
Cancel <-chan struct{}
Response *Response
ctx context.Context
}
  1. Cookie

Cookie表示在HTTP响应Header中的”Set-Cookie”,或HTTP请求header中的”Cookie”发送的HTTP Cookie。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
golang复制代码type Cookie struct {
Name string
Value string
Path string
Domain string
Expires time.Time
RawExpires string
MaxAge int
Secure bool
HttpOnly bool
SameSite SameSite
Raw string
Unparsed []string
}

Methods:
func (c *Cookie) String() string
func SetCookie(w ResponseWriter, cookie *Cookie)
  1. Response

Response表示来自HTTP请求的响应。
Http.Client和http.Transport 返回Response,一旦这个response header 被接收完,Response的body字段需要被当作流来处理。

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
golang复制代码type Response struct {
Status string
StatusCode int
Proto string
ProtoMajor int
ProtoMinor int
Header Header
Body io.ReadCloser
ContentLength int64
TransferEncoding []string
Close bool
Uncompressed bool
Trailer Header

// Request is the request that was sent to obtain this Response.
// Request's Body is nil (having already been consumed).
// This is only populated for Client requests.
Request *Request
TLS *tls.ConnectionState
}

Methods:
Cookies() []*Cookie // 从header中解析并返回所有Set-cookie
Location() (*url.URL, error)
ProtoAtLeast(major int, minor int) bool //确认响应中使用的HTTP协议是否至少是major.minor。
Write(w io.Writer) error // 把response的内容写到 w 中

本文转载自: 掘金

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

1…773774775…956

开发者博客

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