2024年7月

在这里同步一篇本人的原创文章。原文发布于2023年发布在
知乎专栏
,转移过来时略有修改。全文共计3万余字,希望帮助到GEE小白快速进阶。

引言

这篇文章主要解答GEE中
.map()

.iterate()
函数的用法。

首先解答一个疑问,为什么需要自己写循环?确实,GEE 为各种数据类型提供了无数常用的内置函数,对这些方法做排列组合足以应对大多数使用场景,算法效率也颇佳。在有内置函数的情况下,我永远推荐内置函数。

然而,对策赶不上需求,总有时候需要部署个性化的算法。举个例子,数据聚合问题:现有一个小时级的气温数据集 hourlyTemp_imgcol,每小时整点提供一张近地表气温图像,要求借此生成一个每日平均均气温数据集 dailyTem_imgcol,每天提供一张日均近地表气温图像。那么,GEE 有没有这样的内置函数:

var dailyTemp_imgcol = hourlyTemp_imgcol.aggregate('daily');

让我们一行代码出结果呢?没有。

以上的例子是细分学科下的任务,听起来还是挺稀罕的。那我就再举一个编程初学者一定会接触的算法,数列递推:由一个初始值按照一定的递推公式,生成一个固定长度的列表。GEE有没有类似这样的递推函数:

var List = Number.RECURSIVE_SEQUENCE(function, list_length);

来一步到位实现这个简单任务呢?很遗憾,也是没有的。

因此,尽管 GEE 内置函数库丰富,学会自己部署循环仍然很有必要。这是跨过初学者阶段、独立开展项目的必经之路。

在第一个例子中,我们需要按时间循环,每个日期计算一幅平均气温图像。在第二个例子中,我们需要按列表索引位置循环,每个索引位置上计算一个递推值。

第一个例子代表了一类问题,处理函数作用在每个元素上,让每个元素被映射到一个新的东西上,这样的话一定是多个输入和多个输出,并且有一一对应的关系。关于这种循环,我们使用
.map()
操作来处理,请阅读本文第(一)节。

第二个例子代表了另一类问题,处理函数的任务是产生累积的效果,后面步骤的执行会依赖前面步骤的产出。关于这一种循环,我们使用
.iterate()
操作来处理,请阅读本文第(二)节。

有些初学者会说,哪里需要那么麻烦,我以不变应万变,老师教的
for
循环就是无脑易上手,再说GEE又不是不支持。我想说,这种习惯在技术上确实可行,但是如果是放在 GEE 上部署,会严重拖慢计算效率。在展示如何写 GEE 式循环之前,我专门开一个第(〇)节,探讨一下这个问题。非常建议初学者阅读,会帮助你更好地理解,GEE 如何处理用户提交的任务。

然而并不是说任何情况都不能使用
for
循环。 有些情况下,因为
.map()

.iterate()
的技术限制,循环
必须
在本地端进行。我会举一下这种应用场景的例子,放在第(三)节。

让我们开始吧。

(〇) 为什么GEE不推荐for、while循环?

GEE 常被描述为“地理云计算平台”。那么,之所以被称为云计算,就是因为操作和计算是在服务器上发生的,而不是在自己的电脑(本地)上完成的。向服务器告知计算任务时所用的“语言”,称为API。目前已有JavaScript API(即Code Editor)、Python API、Julia API。

-- 那么老哥,
for

while
是跟GEE服务器对话的语言吗?

-- 不是。

-- 难道说GEE服务器不看
for
语句?

-- 它不看。

-- 唉不对啊,我明明在Code Editor上面写了
for
循环却还能运行的,你说服务器不看
for
,难不成还能是浏览器给我跑的?

-- 恭喜你答对了,还真是浏览器给跑的。

要解释这个问题,需要对GEE的工作机制有一个大概的了解。

作为小白,我们在Code Editor上写出几行代码并点击Run的一刻,可能很少想过这之后发生了什么。实际上,你的代码其实并不是直接抄一份送给服务器去读,而是要先在本地端浏览器上去做解析,重写成一套服务器听得懂的请求,然后才把请求送上去。

本地翻译的过程,在于解析每一个由用户声明的变量,把它们各自整理成一套能指导服务器算出结果的结构——你可以理解成是每一个云端对象在本地端的代理,是影子。它们会跟云端的、承载实际数据和计算的对象相对应。

在《Deferred Execution(延迟运行)》的页面上
[1]
,谷歌解释了这一本地解析的过程:

When you write a script in Earth Engine (either JavaScript or Python), that code does NOT run directly on Earth Engine servers at Google. Instead, the
client library
encodes the script into a set of
JSON
objects, sends the objects to Google and waits for a response. Each object represents a set of operations required to get a particular output, an image to display in the client, for example.
当您在Earth Engine上编写脚本时(无论JavaScript还是Python),该代码不会直接在谷歌的Earth Engine服务器上运行。相反,客户端库将脚本编码为一组JSON对象,将对象发送给谷歌并等待响应。每个对象表示获得特定输出所需的一组操作,例如,要在客户端上显示的图像。

进一步解释一下:代码写好之后,只会在本地做解析。本地端解析之后,会生成一些JSON对象。这些JSON对象是做什么用的呢?是客户端写给服务器的字条:“如此如此算一遍,把结果发给我。”

由此可以看出,本地端解析代码所能做的,只是解析代码,帮服务器提前理解该怎么做事而已,不涉及任何实际数据。它编写的JSON文件,只是云端数据在本地端的影子。生成这个影子的目的,或者说这个影子里所容纳的信息,就是指挥Google服务器以某某运算组合依次操作那个云端上的对象。

这样就解释了为什么
for
循环只在本地端有意义。我们在
for
循环里写的一切,是一种过程而不是一种对象,只会平添本地端的解析负担。
for
循环对服务器上计算过程产生影响之前,是要本地客户端来徒手拆循环的。

说句题外话,这其实很像在编纂纪传体史书。历史的原始记录就像我们写的代码一样,关注的是事情从头到尾怎么进行,是一种过程。而纪传体史书则关注主要人物个体如何发展,这更像GEE服务器的关注点,是一种对象。把流水账般的事项记录(过程),编制成人物个体的发展历程(对象及其操作顺序),并对互相影响的人物各自立传、一起成书,这些就是史官(和GEE API)的工作。

理解了本地端和服务器端对象的区别和联系,让我们再来讨论以
for
为代表的本地端循环,为什么不被推荐。

举个例子,我们这里有一个重复1万次的
for
循环,里面的内容是让一个ee.Number(服务器端的数)每次加 i:

// 语法没错但跑不通的例子,只是因为用了 for 循环
var num = ee.Number(0);
for (var i=1; i<=10000; i++) {
  num = num.add(i);
}
print(num);

已知服务器不看也看不懂
for
循环,那么这个拆循环的工作,一定是在本地完成的。浏览器需要认真的把1万次迭代走完。然而,它在迭代时可不是替服务器执行实际计算,而是在字条里写一步请求:嘿服务器,这一步,你执行加 i 操作。于是它把这句话又写了9999次。在循环结束时,它出的活将是一个超长的、一万个处理步骤的字条。

这就是浏览器上的客户端API在面对
for
循环时负担的压力。浏览器表示写字条太苦逼了,求求你还是让我来算数吧。

看着是不是替自己的浏览器捏把汗呢?实际上,这个代码在我的电脑上根本跑不通,因为这个预计超长的JSON对象,是超出了我的浏览器写字条的能力的(报错:Maximum call stack size exceeded)。

一旦选择去写
for
循环,那么制约代码运行效率的因素,就不在于服务器的计算速度了,而是本地端的翻译能力。而这还不是最差的情况。最不推荐的做法是
for
循环中还带有
.getInfo()
函数,向服务器请求发回这一步计算结果,它让本该只是影子的本地变量取回它在服务器端对应的值。这个函数会打断异步计算的工作方式,在当前值被发送回本地前,一切其它步骤的计算都不能接着进行,同时服务器也闲置了。如果被套进很多层
for
循环中,用户实际的感受就是,本地等待时间非常长,浏览器无响应(甚至提示崩溃)。

反面案例:

// for 循环与 getInfo 合用则是更大的错误!
// 以下代码能跑通,但亲测可以让浏览器卡死数十分钟
// 不要运行!!!
var num = ee.Number(0);
for (var i=1; i<=10000; i++) {
  num = ee.Number(num).add(i).getInfo();
}
print(num);

那么,使用
.map()

.iterate()
就能绕过这个问题吗?是的。这是因为,它们两个都是服务器端的函数,是服务器可以直接读懂的东西。这样一来,调用它们也就无需让客户端去拆循环,只需一次解析,剩下的事情就交给服务器了。浏览器压力大减。

要是
for

while
循环哪怕尚有一成的用处,我的这篇文章也就没有什么必要了。但我可以拍着胸脯说,除了极少数情况(见第三节),都是可以寻找到对本地循环的替代方案的。这里推荐阅读GEE的官方教程文档《
Functional Programming Concepts

[2]
(函数式编程的概念),教你把习惯的过程式编程做法(
for
循环、
if/else
条件语句、累积迭代)重构为适合GEE的代码。

(一) 逐项映射用.map()

引例

有多个类型的 ee 对象都带有
.map()
操作:ee.List(列表),ee.Dictionary(字典),ee.ImageCollection(图像集),ee.FeatureCollection(要素集)。借用Python语言中的概念,它们都是可迭代对象iterables。ee.List和ee.Dictionary类似于Python的List和Dictionary。ee.ImageCollection和ee.FeatureCollection则非常类似于Python的集合,set。

巧的是,在Python中也有一个
map
函数处理可迭代对象,用法如下,是一个让数列逐项加1的脚本。

""" map(fun, iter) 
 \* fun - 处理函数,map将每个元素传递给它
 \* iter - 可迭代对象,map作用于它的每个元素上
"""
list1 = [1, 2, 3, 4]
list2 = list( map(lambda x: x+1, list1) )
list2     # [2, 3, 4, 5]

这个脚本,放在 GEE Code Editor 中的话,应该这样写:

/** ee.List.map(baseAlgorithm)
 * this: list - 输入对象,此例中为列表,map作用于它的每个元素上
 * baseAlgorithm - 处理函数,map将每个元素传递给它 
*/
var list1 = ee.List([1, 2, 3, 4]);
var list2 = list1.map(
  function(x){ 
    return ee.Number(x).add(1);
  }
);
print(list2); // [2, 3, 4, 5]

可以看到,
map

.map()
在两个平台上有着一样的本质功能,都是
逐个元素处理,逐一映射
。也就是说,把处理函数作用在输入对象的每个元素上,并且在输出对象中创建一一对应的新元素。

二者在编写结构上有一些细节上的差别:

  1. 尽管都是需要两个输入,Python的
    map
    是在它的括号里套起来处理函数和输入对象。而GEE中的
    .map()
    则只套处理函数,跟在输入对象后。
  2. Python的
    map
    可以同时传递进来多个可迭代对象(以上示例中没有展示),而GEE的
    .map()
    则只能处理一个对象。
  3. Python使用
    lambda
    语句创建匿名函数,而GEE Code Editor用
    function
    语句。由于采用
    function
    语句,GEE Code Editor用户可以在
    .map()
    的括号里写一个带更多步骤的匿名函数(以上示例中没有展示)。
  4. Python的
    map
    的直接输出是一个map object,需要用container套起来去赋予类型,比如写成 list(map(...)) 。而GEE中的
    .map()
    则返回一个与输入对象类型相同的对象,进来是什么类型出去就还是什么类型。

这种处理是并行化进行的。在上面的例子中,我们并不关心是数字1先被处理,还是数字4先,只在乎它们被处理完后有没有被摆在正确的位置。4个数字或许是同时被各自送入不同的 CPU 核,同时被输出,同时被填放。又或许是先处理3个,再处理1个,齐活之后再一起上菜。又或许是先处理2个再处理2个……具体怎么调度的,那是后台的事,对我们用户其实毫无影响。

但值得放在脑子中的信息是,这种处理不会是像
for
循环那样,一个一个依次操作,而是以并行化的方式提高效率。记住这一点,后面要考的。

简单的
.map()
操作常用于对图像集中的图像、要素集中的矢量要素作批量处理,编辑、处理后,生成对应的新数据。在更复杂的情况中,通常是自定义的特殊处理任务,我们需要以给定的时间、空间、或关键字等信息为依据,对其它数据作聚合,如统计日平均气温,即气温数据在时间维度的聚合。对于这两种
.map()
操作的应用场景,这里给出例1和例2两个假想的数据处理任务以供参考。

例1 GIS数据的批量编辑

这里给出的例子是对于图像集(ee.ImageCollection)进行批量编辑。对要素集(ee.FeatureCollection,或者称矢量集)的批量编辑虽然在任务性质上会有差异,但在对
.map()
的使用方式上同理,所以就不予赘述了。

我们设想这样一种任务,对图像集里的每一个图像,都进行一次掩膜操作,把不想要的像元筛除掉。这样的任务常见于定量遥感,常常是数据准备阶段中的一步预处理。掩膜图层常常是特定的土地覆盖类型(如森林、水体、农田),或气候条件阈值(如温度、降水量),或有云/无云的位置。我这里举两个非常最常用的Landsat预处理操作:1. 使用质量控制波段,对Landsat 8地表反射率数据集进行掩膜。2. 使用官方给定的放缩乘数(scale factor)和偏置(offset),将数据集里的源数据转换成能够使用的地表反射率。

代码链接

首先,取得Landsat 8地表反射率数据集(注意是Collection 2)。这里仅取同一个地点上2个月的图像,一共4张,因为取多了处理时间会拉长。4张图像的云量分别为:89.8%,0.05%,21.39%,75.44%。

/////////////////////////////////////////////////////////////
// 获取Landsat 8 Collection 2 Tier 1地表反射率(Level 2)图像集
// 获取到的图像ID及云量:
// LANDSAT/LC08/C02/T1_L2/LC08_012031_20210711 - 89.8%
// LANDSAT/LC08/C02/T1_L2/LC08_012031_20210727 - 0.05%
// LANDSAT/LC08/C02/T1_L2/LC08_012031_20210812 - 21.39%
// LANDSAT/LC08/C02/T1_L2/LC08_012031_20210828 - 75.44%
var landsat8 = ee.ImageCollection("LANDSAT/LC08/C02/T1_L2")
                .filter(ee.Filter.and(
                  ee.Filter.eq('WRS_PATH', 12),
                  ee.Filter.eq('WRS_ROW', 31)))
                .filterDate('2021-07-01', '2021-09-01');

其次,写一个函数来实现:对一幅输入进来的Landsat 8图像,利用它自身的质量控制波段,去除云和云影。质量控制波段是一幅数值图像。在二进制化后,它的每一个(或2个)数位指示像元质量的某一个属性。我们需要第1至第4数位,分别是dilated cloud(检测到的云范围的放大范围)、cirrus cloud(卷云)、cloud(明显的云)、cloud shadow(云影)。

/////////////////////////////////////////////////////////////
////////////// 云掩模函数 //////////////
function prep_l8 (l8_image) {
  // 转换成图像,以保证数据类型正确
  l8_image = ee.Image(l8_image);
  // 使用QA数位1-4,构建掩模图层(取1是不受云影响的像元,取0是受云影响的像元)
  // Bit 0: fill (0)
  // Bit 1: dilated cloud 
  // Bit 2: cirrus cloud
  // Bit 3: cloud
  // Bit 4: cloud shadow
  var l8_qa = l8_image.select("QA_PIXEL"); 
  var dilated_cloud_mask = l8_qa.bitwiseAnd(1 << 1).eq(0);
  var cirrus_cloud_mask = l8_qa.bitwiseAnd(1 << 2).eq(0);
  var cloud_mask = l8_qa.bitwiseAnd(1 << 3).eq(0);
  var cloud_shadow_mask = l8_qa.bitwiseAnd(1 << 4).eq(0);
  var mask = dilated_cloud_mask
              .and(cirrus_cloud_mask)
              .and(cloud_mask)
              .and(cloud_shadow_mask);
  // 放缩和偏置,并恢复在乘法加法中丢失的元数据(metadata)
  var scale = 0.0000275;
  var offset = -0.2;
  var l8_image_scaled = ee.Image(l8_image.multiply(scale).add(offset)
                                  .copyProperties(l8_image))
                            .set(
                              "system:id", l8_image.get("system:id"),
                              "system:version", l8_image.get("system:version")
                              );
  // 掩模并返回图像
  var l8_image_scaled_masked = l8_image_scaled.updateMask(mask);
  return l8_image_scaled_masked;
}

接下来执行批量编辑,也就是让上面的处理函数作用在图像集的每个图像身上。具体的写法,是让
.map()
括起来处理函数的名字(或者可以在
.map()
里写函数),再跟随在待处理的图像集后面即可。

/////////////////////////////////////////////////////////////
// 批量预处理,显示结果
landsat8 = landsat8.map(prep_l8);

var visualization = {
  bands: ['SR_B4', 'SR_B3', 'SR_B2'],
  min: 0.0,
  max: 0.1,
};
Map.addLayer( ee.Image(landsat8.toList(10).get(0)), visualization, "2021-07-11" );
Map.addLayer( ee.Image(landsat8.toList(10).get(1)), visualization, "2021-07-27" );
Map.addLayer( ee.Image(landsat8.toList(10).get(2)), visualization, "2021-08-12" );
Map.addLayer( ee.Image(landsat8.toList(10).get(3)), visualization, "2021-08-28" );

显示这4张经过预处理后的图像。可以发现,我们用一步操作为4张图像去除掉了受云影响的像元,可以说
.map()
的老本行是GIS数据批量处理实在不为过。

例2 时间维度上的数据聚合

GEE 上能实现的聚合不仅限于求和。做乘积、求平均、求方差、找极值等等,只要用得上就都能做。我们这里以求平均为例子说明做数据聚合的技巧。

在时间维度上做数据聚合,输入到算法中信息有两个:时间集、数据集。不同于在批量编辑操作中的做法,我们这回不能再让
.map()
当数据集的“跟屁虫”,而是要转而去跟随时间集。

原因很好理解。
.map()
操作,输入输出的维度必须是相同的。我们如果想实现一个时间出一张图,那就必须让时间成为
.map()
的输入。(至于数据集,它可以去当处理函数的输入。这里有个非常好用的技巧我接下来会在处理函数代码处细说)

做法是把时间维度放到一个 ee.List 列表对象中。因为 ee.List 也带有
.map()
操作,我们的编程灵活性有了保证。

考虑到
ee.List.map()
的输出变量是另一个 ee.List,我们有必要对结果进行变换——重建所需的数据类型,比如转换成 ee.ImageCollection 或 ee.FeatureCollection。

这里展示的代码将实现我们在引言中提到的,将逐小时气温数据转换为每日平均气温。可以
点击此链接
在 GEE Code Editor 中查看。

首先,定义输入量:

  1. 逐小时近地表气温数据,由实时中尺度分析(the Real-Time Mesoscale Analysis,RTMA)数据集
    [3]
    来提供。这是由美国国家气象局(National Weather Service,NWS)提供的气象再分析产品,覆盖美国大陆48州,空间分辨率2500米,时间分辨率1小时。
  2. 时间范围,2022年1月1日至1月3日。初始化一个 ee.List 存放所有日期的起始时刻,以美国东部时间为时区。
var hourly_temp = ee.ImageCollection("NOAA/NWS/RTMA").select("TMP");
var dates = ee.List([
  ee.Date('2022-01-01T00:00:00', 'EST5EDT'),
  ee.Date('2022-01-02T00:00:00', 'EST5EDT'),
  ee.Date('2022-01-03T00:00:00', 'EST5EDT')
  ]);

然后,定义处理函数。在每一个日期上,这个函数要实现三个步骤:首先对 hourly_temp 图像集做筛选,然后对筛选后的图像集作聚合生成一张均值图像,最后重命名均值图像。

/** 
 * 本函数构造一个内函数并返回
 * 使内函数满足ee.List.map()对处理函数格式的要求 
 */
function calc_daily_mean(imgcol) { 
  /** 在内函数中完成处理 */
  return function(idate) {
    idate = ee.Date(idate);
    /** 第一步:对图像集按日期做筛选 */
    var filtered_imgcol = imgcol.filterDate(idate);
    /** 第二步:对筛选后的图像集作聚合,生成一张均值图像 */
    var mean_img = filtered_imgcol.mean();
    /** 第三步01:提取原图像集的波段名称 */
    var band_names = imgcol.first().bandNames();
    /** 第三步02:用“原波段名称+年月日”重命名均值图像的波段,如:"TMP20220101" \*/
    band_names = band_names.map(function(name){return ee.String(name).cat(idate.format('YYYYMMdd','EST5EDT'))});
    return mean_img.rename(band_names);
  };
}

终于可以把前面卖的关子揭开了。可以看到,处理函数的结构是,一个外函数返回一个内函数。为什么要这样设计呢?我们在引例中提到,GEE 的
.map()
则只能处理一个输入变量。这样就产生了矛盾,因为我们的数据聚合任务必须含有两个变量:时间集和数据集。当我们已经把时间集指定为
.map()
的输入时,数据集似乎就无处可去了。然而,假如我们稍微改造一下处理函数,写成这样的外函数套内函数、并返回内函数的结构,就可以做到让数据集输入到外函数,时间集里的日期输入到内函数。这样一来,可以同时满足两方面看似矛盾的要求。

使用这一处理函数的代码如下。第一输入——时间集 dates ——放在
.map()
的左侧。第二输入——数据集hourly_temp ——就让外函数括起来。然后,在整个语句的最外层,用ee.ImageCollection()套起来,把
.map()
的输出结果(原本是ee.List)转换成图像集。

var daily_temp = ee.ImageCollection(dates.map(calc_daily_mean(hourly_temp)));

最后,让我们在地图视图中查看一下算好的平均气温。另外,再生成一个颜色条方便阅读数据。

var vis = {
  min: -30,
  max: 20,
  palette: ['001137','01abab','e7eb05','620500']
};
Map.addLayer(daily_temp.first(), vis, "2022-01-01 mean temperature");
Map.addLayer(ee.Image(daily_temp.toList(3).get(1)), vis, "2022-01-02 mean temperature");
Map.addLayer(ee.Image(daily_temp.toList(3).get(2)), vis, "2022-01-03 mean temperature");

var legendTitle = ui.Label({
  value: 'Mean temperature (C)',
  style: {fontWeight: 'bold'}
});
var legendLabels = ui.Panel({
  widgets: [
    ui.Label(vis.min, {margin: '4px 8px'}),
    ui.Label(
        ((vis.max-vis.min) / 2+vis.min),
        {margin: '4px 8px', textAlign: 'center', stretch: 'horizontal'}),
    ui.Label(vis.max, {margin: '4px 8px'})
  ],
  layout: ui.Panel.Layout.flow('horizontal')
});
var colorBar = ui.Thumbnail({
  image: ee.Image.pixelLonLat().select(0),
  params: {
    min: 0,
    max: 1,
    palette: vis.palette,
    bbox: [0, 0, 1, 0.1],
  },
  style: {stretch: 'horizontal', margin: '0px 8px', maxHeight: '24px'},
});
Map.add(ui.Panel([legendTitle, colorBar, legendLabels]));


美国大陆部分2022年1月1日平均气温地图。


2022年1月2日


2022年1月3日

.map()易错点

在GEE上使用
.map()
时,有必要牢记一些易错点。
.map()
里运行出的错,报错反馈可能不会指出问题出在了代码第几行,也许也无法提供足够精确的诊断信息(这可能是 GEE 最难用的地方,不过 GEE 似乎正在改进这一点)。把易错点烂熟于心,至少对于debug是很有帮助的。

首先,输入对象和元素类别的问题。
.map()
的输入变量类型只能是 ee.List、ee.Dictionary、ee.ImageCollection、ee.FeatureCollection 四者之一

。除了这些ee对象以外,不能对其它类型使用
.map()
。并且,输入对象中的元素经由
.map()
送入处理函数中时,一律都会被服务器理解成一个“没有类型的”云端对象 ee.ComputedObject,而不是原本的类型,所以
在处理函数中需要为元素重新定义对象类别

比如,引例中 ee.List 的元素,在使用
.add()
进行处理之前,需要被定义成 ee.Number。即使我们把输入列表中的对象都写成 ee.Number,在被送入处理函数后也仍然会“失去”类型——见下方的错误程序及其报错。

/** 这是一个错误示例 */
/** 说明数值元素在送入处理函数时没有被理解为ee.Number,而是需要重新定义 */
var list1 = ee.List([ee.Number(1), ee.Number(2), ee.Number(3), ee.Number(4)]);
var list2 = list1.map(
  function(x){ 
    return x.add(1); // 正确写法:return ee.Number(x).add(1)
  }
);
print(list2); // 报错:"x.add is not a function"

其次,本地端操作的问题。由于
.map()
循环是完全交给服务器运行的,它的
处理函数里面不能写
print

if

for

这些程序员喜闻乐见的语句。一旦写上了就会弹出报错"A mapped function's arguments cannot be used in client-side operations"(映射函数的参数不能在客户端操作中使用)。
print
的问题实在解决不了,但是
if

for
还是有替代品的。例如
ee.Algorithms.If
可以实现
if
,不过更推荐的做法是用
.filter()
操作提前筛选好数据。要是手痒痒想写
for
的话,那就叠一个
.map()

.iterate()
循环好咯。

.map()小结

  1. .map()
    适用于逐个元素处理、逐一映射的任务。
  2. .map()
    在 GEE 后台采用并行化的执行方式。
  3. .map()
    的两种常用场景:GIS数据的批量编辑,以及在时间、空间、或关键字等维度上进行信息聚合。
  4. 使用时应注意变量的数据类型,以及避免在处理函数中使用本地端操作。

(二) 累积迭代用.iterate()

前一小节中,我们讨论了
.map()
操作的适用范围——映射。细心的同学们可能已经发现了,
.map()
对元素的处理是一种各自独立的访问方式,顺序无所谓谁先谁后,只要处理好后放置顺序正确即可。因此,它的处理函数没有“步”的概念,也就不能产生按步累积的效果。

但是我们学习
for
循环时,还有一个很重要应用就是按步求累积啊,这可怎么办?

这就是
.iterate()
的功能了。
.iterate()
操作适用于那些需要考虑
状态逐步积累、逐步改变
的计算任务。

这里提供一个简单的判断标准,决定你该不该用
.iterate()
。如果你的问题必须拆解成子步骤逐步计算,且后步必须依赖前步的计算结果,那么它就是
.iterate()
的应对范围。反之,则优先尝试使用
.map()

适合用
.map()
去解决的问题较多,而非得用
.iterate()
的问题较少。
.iterate()
执行起来是完全按照规定好的1234步来做的。能用
.map()
去并行执行的任务非要写成
.iterate()
,倒也不是出不了结果,但属实是有点一核有难多核围观了。

带有
.iterate()
操作的对象类型有3个:ee.List,ee.ImageCollection,ee.FeatureCollection,比
.map()
少一个 ee.Dictionary。

调用
.iterate()
的代码格式比
.map()
繁琐一些。
.iterate()
需要两个输入

:处理函数 + 第一步循环开始前的初始状态,二者都是必须的输入量,后者是
.map()
没有的。同样繁琐的还有
.iterate()
对处理函数的要求

:循环控制变量 + 前一轮的迭代状态(作为本轮迭代的基准)。

下文中给出用
.iterate()
实现做累积迭代的一些任务。例3是对存储在 ee.FeatureCollection 中的要素属性进行求和的例子。例4汇流算法是一个相当复杂的循环迭代过程,套在大一些的研究区上很容易超出GEE计算限制,因此不建议在GEE上部署。

例3 简单的迭代求和

这里举一个官方文档
[4]
中的例子。构造一个要素集(ee.FeatureCollection),其中每个要素以感兴趣区ROI为几何形状,以一个月份的月降水量为字段,共12个要素。
.iterate()
承担的任务是以迭代的方式对12个月降水量求和,得到感兴趣区内的年降水量。

https://developers.google.com/earth-engine/apidocs/ee-featurecollection-iterate

首先,数据源和感兴趣区。数据源选择了一个按月更新的气候数据集(图像集ee.ImageCollection),取一年期。感兴趣区选在美国新墨西哥州的一片区域。

 /**
 * CAUTION: ee.FeatureCollection.iterate can be less efficient than alternative
 * solutions implemented using ee.FeatureCollection.map or by converting feature
 * properties to an ee.Array object and using ee.Array.slice and
 * ee.Array.arrayAccum methods. Avoid ee.FeatureCollection.iterate if possible.
 */

// Monthly precipitation accumulation for 2020.
var climate = ee.ImageCollection('IDAHO_EPSCOR/TERRACLIMATE')
                  .filterDate('2020-01-01', '2021-01-01')
                  .select('pr');

// Region of interest: north central New Mexico, USA.
var roi = ee.Geometry.BBox(-107.19, 35.27, -104.56, 36.83);

然后,使用分区统计的方式逐一构造矢量要素来存储月降水量,合成一个要素集。这里使用了
.map()
操作来实现对每一张月降水量图像出产当月的矢量要素。

// A FeatureCollection of mean monthly precipitation accumulation for the
// region of interest.
var meanPrecipTs = climate.map(function(image) {
  var meanPrecip = image.reduceRegion(
      {reducer: ee.Reducer.mean(), geometry: roi, scale: 5000});
  return ee.Feature(roi, meanPrecip)
      .set('system:time_start', image.get('system:time_start'));
});

以上都是准备阶段,下面进入正题。首先建立一个处理函数,名为
cumsum
,以当前要素(变量名currentFeature)为循环控制变量,以已经参与过月降水量加和的要素所组成的列表(变量名featureList,类型为ee.List)为前一轮迭代状态。处理函数所做的事情,是把已有的月降水量的和(featureList最尾端要素的“pr_cumsum”字段)和当前月份的降水量(currentFeature的“pr”字段)分别取出并求和,再新建一个要素赋予这个求和值,并把新建的要素添加到featureList列表中。

// A cumulative sum function to apply to each feature in the
// precipitation FeatureCollection. The first input is the current feature and
// the second is a list of features that accumulates at each step of the
// iteration. The function fetches the last feature in the feature list, gets
// the cumulative precipitation sum value from it, and adds it to the current
// feature's precipitation value. The new cumulative precipitation sum is set
// as a property of the current feature, which is appended to the feature list
// that is passed onto the next step of the iteration.
var cumsum = function(currentFeature, featureList) {
  featureList = ee.List(featureList);
  var previousSum = ee.Feature(featureList.get(-1)).getNumber('pr_cumsum');
  var currentVal = ee.Feature(currentFeature).getNumber('pr');
  var currentSum = previousSum.add(currentVal);
  return featureList.add(currentFeature.set('pr_cumsum', currentSum));
};

为了使用这一处理函数,我们以上文中构造好的月降水量要素集为输入,并且再构造一个循环起始时的要素列表(变量名first,将“pr_cumsum”字段设置为0)。在循环结束后,卸磨杀驴,去除掉这个占位用的要素。

// Use "iterate" to cumulatively sum monthly precipitation over the year with
// the above defined "cumsum" function. Note that the feature list used in the
// "cumsum" function is initialized as the "first" variable. It includes a
// temporary feature with the "pr_cumsum" property set to 0; this feature is
// filtered out of the final FeatureCollection.
var first = ee.List([ee.Feature(null, {pr_cumsum: 0, first: true})]);
var precipCumSum =
    ee.FeatureCollection(ee.List(meanPrecipTs.iterate(cumsum, first)))
        .filter(ee.Filter.notNull(['pr']));

由此我们可以画出这样的年内降水量曲线。蓝线表示月降水量,红线表示累积降水量。这样一来,我们借助
.iterate()
,给
for
循环的迭代求和功能找到了替代。

在以上例子中我们利用
.iterate()
以一种迭代的方式去对所有要素按一个字段求和,然而这并不是唯一的实现方式。实际上,如果只想要对字段求和(但不要求同时给出逐时累积曲线)的话,GEE有内置函数
.aggregate_sum(property)
提供相同的功能,不必要自己编写迭代代码。

实际上,很多我们直觉上认为需要写累积循环的情况,GEE都贴心的提供了内置函数。内置函数不光调用更方便,而且往往运行更快。

例4 汇流问题

注:GEE上已入库全球90米级别的MERIT Hydro数据集
[5]
。如果不追求更高分辨率,建议直接使用现有产品,无需亲自运行汇流算法。

汇流问题地形分析中的经典老问题,但也是一个需要巨量迭代、狂吃内存和时间的老大难技术性问题。

汇流问题通常是这样的:已知一片感兴趣区的DEM图像,要生成一张新图像,使得每个像元的值取该位置所对应的
集水区
(catchment area,或catchment basin)的面积。集水区的定义:对于地表上任何一个地点而言,凡是落在它邻近某个区域内的雨水,经过不断汇聚和流动都会流到这个地点,这个雨水降落和汇流的区域就称为该地点的“集水区”。落在这个集水区边界以外的雨水,不论怎样流动,都不会经过这个地点
[6]


集水区的概念图(图源:Nazhad et al., 2017
[7]
)。任意一个地点(图上小红点和大红点)都可以有各自对应的集水区(黄圈和绿圈)。一般来说,上游地点拥有的集水区面积较小,下游地点拥有的集水区面积更大。分析集水区有助于推断河流的形成。

那么这个汇流问题,有什么难点呢?

思考一下简化版的问题,为
一个
像元,我们称为A,圈出它的集水区,不能圈多不能圈少。这等同于需要标记全场其它像元BCDEFG…,哪些能给像元A送水,哪些不能。

似乎,是不是要遍历全场像元呀?是的,而且不止一遍。首先,对任意像元BCDEFG…,要规划水落在该位置上可能的流向。为了简化,我们假设水只会选择向8-邻接像元流动,并且只会选择其中的1个像元为流动方向,也就是下坡坡度最大方向的那个像元。所以在第一次遍历中,我们需要在全图范围上,逐像元按照邻域地形确定下坡坡向。这张坡向图告诉我们的是,水落下来后紧接着的下一步,只有一步哦,的流动方向。

有了坡向图/流向图,能顺水推舟圈集水区了吗?可以了。思路是,在A的邻域里逆向查找流向指向A的像元,标记下来。再对新标记的所有像元,执行同样的查找过程,再标记新一批像元,以此类推。直到标出范围外,或者标无可标(延伸到了流域边界),则停止。停止这个逆流追溯过程后,把已标记的全部像元转换成像元总数(或总面积),赋值给A。

所以,为解决
一个像元
相应集水区的简化版问题,我们要开动一次追溯。在这个过程中,全场像元被遍历了几遍,取决于客观上有多少个像元给A送水。只要上一步的追溯还能发现新像元,哪怕一个,卷积核就得给我再接着滑。


汇流问题的示意图,DEM -> 流向图 -> 集水区。此例子中为所有像元都统计集水区。图源:
[8]

上文所述,是为一个像元圈出集水区的思路,它只是为了方便向大家说明算法需要考虑的基本问题。事实上,为全场像元都统计集水区,并不需要逐个像元跑追溯,那样会产生太多重复步骤,浪费计算资源。优化的方式,是把下游向上游“追溯”的思路,切换成上游向下游“传播”的思路。第一次迭代中,每个像元向处在自己流向/坡向的唯一邻居像元,告知自己的存在(或自己的面积),而接收了信号的像元需要对信号求和。第二步及之后的每次迭代中,还是向同样的下游邻居像元通信,但告知内容变成,自己在上一步(且仅在上一步)中收集到的、来自更上游像元的通信总数(或总面积)。这样,来自上游的“信号”逐渐向下游传递且被累加起来,每个像元都能在一个迭代步中更新当前已经发现的上游像元总数(或总面积),且不会出现重复计数。直到所有最上游像元的信号都传递到各自相应的最下游,结束循环。

但是,技术上减少计算量的手段,并不能改变汇流问题需要反复执行迭代操作的本质。科学家针对这一问题提出过很多种算法以求优化这一问题的解决方案,但该问题上仍然缺乏简单高效的对策。在对范围广大、分辨率高的DEM数据做处理时,汇流算法的处理时间能以十小时甚至百小时为单位。

这里姑且把“传播”算法的实现方式给贴出来。纯粹是为了技术展示,并不是真的号召大家在GEE上写这种东西。

汇流算法的GEE代码来自GIS Stack Exchange论坛上的问答
[9]
[10]
,作者为用户Bert Coerver,转载代码请注明原始出处。

首先,作者手动添加了一个感兴趣区,位于印度。

var geometry1 = ee.Geometry.Polygon(
        [[[81.32080078125, 19.114029215761995],
          [80.958251953125, 18.999802829053262],
          [80.947265625, 18.672267305093758],
          [81.71630859375, 18.396230138028827],
          [82.09808349609375, 18.747708976598744],
          [82.02667236328125, 19.072501451715087]]]);

这里我需要注明一下,汇流算法的作用区域,一般要至少覆盖一整个流域,否则集水区面积的计算结果是会偏小的。这里代码作者随手画的这个范围应该是没有覆盖流域,因此计算结果仅供教学,可能不具备现实中的指示作用。


感兴趣区示意图,由代码作者Bert Coerver标画

其次,定义输入量:

  1. 迭代次数预设为120,这个值要不小于区域内最长的那条汇水路径的长度(像元数),以保证所有传播都能完成。
  2. 集水区的计量方式采用了像元数(单位:个),读者可以自行采用代码注释内提供的替代方式,把计量方式改成面积、单位改成平方千米。
  3. 初始化了一张名为
    data
    的图像,用作上文所述的自上而下传播算法的初始迭代量。
  4. 坡向图采用GEE Data Catalog中现成的HydroSHEDS水流流向数据集(30弧秒分辨率,约900米)
    [11]
    ,像元值标示水流在该位置的流向:1=东,2=东南,4=南,8=西南,16=西,32=西北,64=北,128=东北。
// Set the number of routing iterations:
var iteration_steps = ee.Number(120);

// Give an image with some data to rout:
var data = ee.Image(1);
// or E.G.: data = ee.Image.pixelArea().divide(1e6);

// Give the bandname from 'data' to rout:
var bandname = ee.String("constant");
// or E.G.: var bandname = ee.String("area");

// Give unit of the data:
var unit = "pixels";
// or E.G.: unit = "km2";

// Give image with flow directions:
var dir = ee.Image("WWF/HydroSHEDS/30DIR").select(["b1"]);

// Set Area-Of-Interest:
var geom = geometry1;

// Set output data type:
var typ = "uint32";

接下来,重命名迭代量
data
图像的波段为“rout”(可能是单词route/routed的简写),该波段的初值为1,即1个像元,用户可以自行在上方代码中改用像元面积为计量。复制rout波段,创建一个新波段赋给
data
图像,重命名为“summed”,这样summed波段初值也为1(或自身像元面积)。

rout波段可以理解成每个像元在当前步骤持有的“信号”,它或来自自身初始化或来自上游传入,并且这个信号将在后续步骤中传递出去。summed波段可以理解为,每个像元已发现的汇流像元数量或面积(自身也算,如果不想算自身的话,summed可以赋初值为0)。

// Rename the band.
data = data.select([bandname],["rout"]);

// Add a band to store the summation of the routs.
data = data.addBands(data.select(["rout"],["summed"]));

// Set property indicating the current iteration step.
data = data.set("iter_idx", 0);

借用ee.FeatureCollection创建表格(矢量数据的属性表)的能力,创建一个集合来定义方向。这里面每个矢量要素都不含地理参考信息(geometry字段为null),因此是“伪要素”,我们专注于属性表里的信息即可。

// Create a feature-collection that describes to what directions (e.g. 128 is NE).
var col = ee.FeatureCollection(ee.List([
          ee.Feature(null, {"weight": ee.List([[0,0,0],[1,0,0],[0,0,0]]),
                            "direction": "E",
                            "dir_id": 1,}),
          ee.Feature(null, {"weight": ee.List([[1,0,0],[0,0,0],[0,0,0]]),
                            "direction": "SE",
                            "dir_id": 2,}),
          ee.Feature(null, {"weight": ee.List([[0,1,0],[0,0,0],[0,0,0]]),
                            "direction": "S",
                            "dir_id": 4,}),
          ee.Feature(null, {"weight": ee.List([[0,0,1],[0,0,0],[0,0,0]]),
                            "direction": "SW",
                            "dir_id": 8,}),
          ee.Feature(null, {"weight": ee.List([[0,0,0],[0,0,1],[0,0,0]]),
                            "direction": "W",
                            "dir_id": 16,}),
          ee.Feature(null, {"weight": ee.List([[0,0,0],[0,0,0],[0,0,1]]),
                            "direction": "NW",
                            "dir_id": 32,}),
          ee.Feature(null, {"weight": ee.List([[0,0,0],[0,0,0],[0,1,0]]),
                            "direction": "N",
                            "dir_id": 64,}),
          ee.Feature(null, {"weight": ee.List([[0,0,0],[0,0,0],[1,0,0]]),
                            "direction": "NE",
                            "dir_id": 128,})]));

⬆可以看到,“direction”和“dir_id”字段,正是对照着HydroSHEDS水流流向数据集对于流向/坡向的定义,但“weight”字段中的矩阵则是反过来的。比如,[[0,0,0], [1,0,0], [0,0,0]]应该指向西,却对应于“东”。这样设计是因为,上游像元向下游像元传播信号的过程,实际上是靠下游像元的主动收集来实现的(图像卷积操作)。上游像元向东发送的信号,需要下游像元从西面去接收。“direction”和“dir_id”字段所定义的是信号发送的去向,而“weight”字段定义的则是信号接收的来向。有必要说明一下这个问题以免产生疑惑。

最内核的部分来了:传播算法的执行函数。每一步迭代时,正是这个函数来把“信号”向下游移动一步。

// The iteration algorithm.
var iterate_route = function(iter_step, data){
    
    // The function to do one routing iteration for one direction.
    var route = function(ft){
  
        // Create the kernel for the current flow direction.
        var kernel = ee.Kernel.fixed({width: 3, 
                                      height: 3, 
                                      weights: ft.get("weight")
                                     });
    
        // Get the number corresponding to the flow direction map.
        var dir_id = ee.Number(ft.get("dir_id"));
        
        // Mask irrelevent pixels.
        var routed = ee.Image(data).select("rout").updateMask(dir.eq(dir_id));
    
        // Move all the pixels one step in the direction currently under consideration.
        var routed_dir = routed.reduceNeighborhood({reducer: ee.Reducer.sum(), 
                                                    kernel: kernel,
                                                    skipMasked: false
                                                   });
                                                     
        return routed_dir;
        };
        
    // Loop over the eight flow directions and sum all the routed pixels together.
    var step = ee.ImageCollection(col.map(route)).sum().rename("rout")
                                                       .reproject(dir.projection())
                                                       .set("iter_idx", iter_step)
                                                       .clip(geom);
    
    // Sum the newest routed pixels with previous ones.
    var summed = step.select("rout").add(ee.Image(data).select("summed")).rename("summed");
    
    // Add the 'rout' and 'summed' bands together in one image.
    var data_next_step = step.addBands(summed);
    
    return data_next_step;
    };

⬆可以看到,这个大函数
iterate_route
里嵌套了一个小函数
route
。小函数的输入是八方向之一,作用是实现所有像元集体朝一个方向收听一次,判断该方向上是否是一个上游像元,收集上游像元带多少信号。让小函数对我们的八方向集合中的每个方向逐一处理(用
.map()
实现),并对结果求和,也就实现了信号的一次从上游到下游的传播。由传播的结果重建rout波段。传播完成之后,把rout波段和summed波段求和,用来更新summed波段。

推演一下,对于 i 取任意值,第 i 步迭代之后,来自信号都向下游流动一步,汇集到了下游像元的rout和summed波段中。而没有信号流入的像元(可能是因为自身处在最上游,或是因为自身的上游邻居在第 i-1 步没有更上游的信号流入),则在第 i 步之后rout值为0(这意味着第 i+1 步迭代时无法向下游传递信号),summed值不变。

定义好执行函数后,就只需调用
.iterate()
执行即可。创建一个名为
steps
的数列,取值1至120。以
steps
规定的步骤,按步运行执行函数
iterate_route
。将输出图像的数据类型转换成32位整型。

// Create dictionary to cast output to the chosen datatype.
var cast = ee.Dictionary({"rout" : typ,
                          "summed": typ});

// Create the list with iteration step numbers.
var steps = ee.List.sequence(1, iteration_steps);

// Do the actual iterations.
var full = ee.Image(steps.iterate(iterate_route, data)).cast(cast);

最后,在地图窗口上展示汇流算法的输出结果。

// Add Layer to Map
Map.addLayer(full.select("summed"), {min: 0, max: 1000}, "summed");
Map.centerObject(full.select("summed"))


汇流算法给出的各像元集水区,计量方式:像元个数。黑色像元约为1-100,灰色像元值数百,亮白色像元1000以上。

后续:在GEE上运行汇流算法,尤其需要注意预设一个合适的迭代次数。因为GEE的
.iterate()
函数不存在类似
break

continue
那样跳过或提前中断循环的语句,所以迭代次数就成为了关乎任务规模的唯一控制量。如果预设的太少,传播没有完成就结束了循环,集水区计算就会偏小。如果预设的太大,传播完成后的那些迭代次数就成了没有必要的空转,严重浪费时间。建议提前估算出全流域内最长的一条汇水路径的长度,并将迭代次数设置为略大于这条路径上的像元数量。并且,在算法的输出里,建议加上标记传播当前状态的rout波段,作为算法结束后校验传播是否已经客观完成的参考依据。

另外,需要提醒注意,运行时间过长、内存占用过大的任务可能无法在GEE上顺利运行。运行汇流算法时,流域范围不宜过大、空间分辨率不宜过高。虽然GEE没有公布过给每个用户的内存配额,或是运行时间的上限(经验值是12小时),但GEE官方开设的开发者讨论区经常有汇报相关报错信息的帖子。对于那些需要运行大流域、高分辨率汇流算法的朋友们来说,GEE也许不是非常稳妥的选择。如果90米分辨率可以接受的话,GEE已经入库的MERIT Hydro数据集
[5]
可以直接拿来使用,该数据提供像元数量和面积两种集水区计量方式,并且覆盖全球。

.iterate()小结:

  1. .iterate()
    适用于需要考虑状态逐步积累、逐步改变的问题。在不需要按步执行的循环问题上,应该优先使用
    .map()
    去实现。
  2. 在很多常见的应用场景下,GEE上都存在对
    .iterate()
    的替代函数。优先使用GEE内置函数以提高计算效率。
  3. .iterate()
    的输入量有两个:执行函数 + 第一步循环开始前的初始状态。
  4. .iterate()
    要求执行函数的输入量为:循环控制变量 + 前一轮迭代状态。输出量为本轮迭代状态。
  5. 小心选择任务规模。提交过于庞大的迭代任务可能会导致超出GEE的内存配额或运行时间限制。可以说GEE的这些限制对计算量庞大的循环迭代很不友好。如有这方面需求,请谨慎考虑使用GEE。
  6. .iterate()
    不支持跳过或中断循环,预估任务所需的迭代次数并设计输入变量的元素数量很重要。
  7. .map()
    的易错点,比如在处理函数中需要为元素重新定义对象类别、不支持本地端操作,对
    .iterate()
    同样适用。

(三) 使用本地循环的情况

在第(〇)节中,我曾苦口婆心地劝大家不要用
for
循环,甚至为此不惜冒着被大家左滑退出的风险谈了些枯燥的、技术性的东西。然而,我必须承认,有的时候本地循环就是不得不用。

我们在第(一)节说过,
.map()
不支持
print

if

for
这些语句,这些限制对于
.iteration()
也存在。这里再补充两条,它们俩内部同样不支持
Export
,也不支持
Map.addLayer()
。前者是将计算结果输出到Google Drive、GEE Assets、以及Google Cloud Storage,后者是在地图面板上添加数据图层。我个人觉得,在循环里用
Map.addLayer()
以便添加图层,以及用
Export
以便批量输出,还是比较常见的需求。既然GEE的循环函数不支持,我们就不得不转而寻求本地循环。

在例5中,用一个类似
for
循环的语句
forEach
来实现本地循环,使每个循环执行一套独立的计算,将结果表格输出到Google Drive中。

例5 批量输出

本例来自于GIS Stack Exchange上的问答
[12]
,转载代码请注明原始出处。本文对部分原始代码进行了一点小修改以避免文件名格式报错。

先介绍一下这段代码的大意。有2个空间位置(83.1499°E, 23.14985°N 和 79.477034°E, 11.015846°N),以及2个时段(1980-2006 和 2024-2060)。任务是对每个空间位置、每个时段,从某个气候数据库(NASA NEX-GDDP)中整理出来每日时间序列,各生成1个表格输出。共计2x2=4个表格。

2个空间位置的坐标存放在sites列表中,2个时段的始末年份存放在yearRanges列表中,这些列表都是本地列表(而非ee.List)。

构造了一个自定义函数
exportTimeseries
来按坐标和时段提取数据记录,函数输入为一组坐标和时段,函数内调用
Export.table.toDrive
来输出表格。

使用
forEach
搭配匿名函数,编写了一个二重循环,以便对sites和yearRanges实现循环遍历。在二重循环内部运行自定义的
exportTimeseries
函数。

var sites = [
  [83.1499, 23.14985],
  [79.477034, 11.015846],
];

var yearRanges = [
  [1980, 2006],
  [2040, 2060]
];

sites.forEach(function (site) {
  yearRanges.forEach(function (yearRange) {
    exportTimeseries(site, yearRange);
  });
});

function exportTimeseries(site, yearRange) {
  var startDate = ee.Date.fromYMD(yearRange[0], 1, 1);
  var endDate = ee.Date.fromYMD(yearRange[1], 1, 1);
  
  // get the dataset between date range and extract band on interest
  var dataset = ee.ImageCollection('NASA/NEX-GDDP')
  .filter(ee.Filter.date(startDate,endDate));
  var maximumAirTemperature = dataset.select('tasmax');
  
  // get projection information
  var proj = maximumAirTemperature.first().projection();
  
  var point = ee.Geometry.Point(site);
  
  // calculate number of days to map and extract data for
  var n = endDate.difference(startDate,'day').subtract(1);
  
  // map over each date and extract all climate model values
  var timeseries = ee.FeatureCollection(
    ee.List.sequence(0,n).map(function(i){
      var t1 = startDate.advance(i,'day');
      var t2 = t1.advance(1,'day');
      var feature = ee.Feature(point);
      var dailyColl = maximumAirTemperature.filterDate(t1, t2);
      var dailyImg = dailyColl.toBands();
      // rename bands to handle different names by date
      var bands = dailyImg.bandNames();
      var renamed = bands.map(function(b){
        var split = ee.String(b).split('_');
        return ee.String(split.get(0)).cat('_').cat(ee.String(split.get(1)));
      });
      // extract the data for the day and add time information
      var dict = dailyImg.rename(renamed).reduceRegion({
        reducer: ee.Reducer.mean(),
        geometry: point,
        scale: proj.nominalScale()
      }).combine(
        ee.Dictionary({'system:time_start':t1.millis(),'isodate':t1.format('YYYY-MM-dd')})
      );
      return ee.Feature(point,dict);
    })
  );

  var name = yearRange.join('-') + '_' + [site[0].toPrecision(2),site[1].toPrecision(2)].join('-');
  Map.addLayer(point, null, name);
  Map.centerObject(point,6);
  
  
  // export feature collection to CSV
  Export.table.toDrive({
    collection: timeseries,
    description: 'a_hist_tmax_' + name,
    fileFormat: 'CSV',
  });
}

(四)总结

我用5个实例展示了一些GEE上常见的循环的用法。
.map()
2个,
.iterate()
2个,本地循环1个。它们当然不可能覆盖全部的场景,不过我想也足够提供一定的技术性参考了。写这些主要是为了方便新手查阅,毕竟初学GEE时实在是有着相当陡峭的学习曲线。次要一点的动力就是,希望我的技术分享能给这个逐渐走向内容凋敝的中文互联网环境一点新鲜血液吧。

写循环可能是衡量GEE用户是否是熟练工的标准。祝GEE初学者们尽快走过新手期,掌握这个必要技巧。不过,我有必要在结尾重复一下引言中的那句话,
在有内置函数的情况下,我永远推荐内置函数

欢迎与我交流探讨!

参考

[1]
How Earth Engine Works: Deferred Execution | GEE Guides
https://developers.google.com/earth-engine/guides/deferred_execution

[2]
Functional Programming Concepts - Google Earth Engine
https://developers.google.com/earth-engine/tutorials/tutorial_js_03

[3]
RTMA: Real-Time Mesoscale Analysis - Earth Engine Data Catalog
https://developers.google.com/earth-engine/datasets/catalog/NOAA_NWS_RTMA

[4]
Google Earth Engine - ee.FeatureCollection.iterate
https://developers.google.com/earth-engine/apidocs/ee-featurecollection-iterate

[5]
MERIT Hydro: Global Hydrography Datasets - Earth Engine Data Catalog
https://developers.google.com/earth-engine/datasets/catalog/MERIT_Hydro_v1_0_1

[6]
集水区 - 维基百科中文
https://zh.wikipedia.org/zh-hans/集水区

[7]
Nezhad, S. G., Mokhtari, A. R., & Rodsari, P. R. (2017). The true sample catchment basin approach in the analysis of stream sediment geochemical data. Ore Geology Reviews, 83, 127–134.
https://doi.org/10.1016/j.oregeorev.2016.12.008

[8]
汇流问题示意图
http://spatial-analyst.net/ILWIS/htm/ilwisapp/flow_accumulation_functionality.htm

[9]
使用Google Earth Engine计算汇流 - GIS Stack Exchange
https://gis.stackexchange.com/questions/279793/calculating-flow-accumulation-using-google-earth-engine

[10]
汇流算法的GEE分享链接
https://code.earthengine.google.com/ba976e61fe8cc75062494750d99edb10

[11]
WWF HydroSHEDS Drainage Direction, 30 Arc-Seconds - Earth Engine Data Catalog
https://developers.google.com/earth-engine/datasets/catalog/WWF_HydroSHEDS_30DIR

[12]
批量输出GEE结果的模板 - GIS Stack Exchange
https://gis.stackexchange.com/questions/342461/writing-a-loop-in-google-earth-engine-and-save-all-files-as-unique

1. Java 网络编程(TCP编程 和 UDP编程)

@


2. 网络编程的概念

什么是网络编程 ?

网络编程是指利用计算机网络实现程序之间通信的一种编程方式。在网络编程中,程序需要通过网络协议(如 TCP/IP)来进行通信,以实现不同计算机之间的数据传输和共享。

在网络编程中,通常有三个基本要素:

  1. IP 地址:
    定位网络中某台计算机
  2. 端口号 port
    :定位计算机上的某个进程(某个应用)
  3. 通信协议:
    通过IP地址和端口号定位后,如何保证数据可靠高效的传输,这就需要依靠通信协议了。

3. IP 地址

IP 地址用于唯一标识网络中的每一台计算机。在 Internet 上,使用 IPv4 或 IPv6 地址来表示 IP 地址。通常 IPv4 地址格式为 xxx.xxx.xxx.xxx,其中每个 xxx 都表示一个 8 位的二进制数(每一个xxx的取值范围是0-255),组合起来可以表示 2^32 个不同的 IP 地址。

IPv4 地址的总数量是4294967296 个,但并不是所有的 IPv4 地址都可以使用。IPv4 地址被分为网络地址和主机地址两部分,前3个字节用于表示网络(省市区),最后1个字节用于表示主机(家门牌)。而一些 IP 地址被保留或者被私有机构使用,不能用于公网的地址分配。另外,一些 IP 地址被用作多播地址,仅用于特定的应用场景。因此实际上可供使用的 IPv4 地址数量要少于总数量,而且随着 IPv4 地址的逐渐枯竭,IPv6 地址已经开始逐渐普及,IPv6 地址数量更是相当巨大。

IPv6使用16个字节表示IP地址(128位),这样就解决了网络地址资源数量不够的问题。IPv6 地址由 8 组 16 位十六进制数表示,每组之间用冒号分隔,如:3ffe:3201:1401:1280:c8ff:fe4d:db39:1984

本机地址:127.0.0.1,主机名:localhost。

192.168.0.0-192.168.255.255为私有地址,属于非注册地址,专门为组织机构内部使用。

3.1 IP地址相关的:域名与DNS

域名:

IP地址毕竟是数字标识,使用时不好记忆和书写,因此在IP地址的基础上又发展出一种符号化的地址方案,来代替数字型的IP地址。每一个符号化的地址都与特定的IP地址对应。这个与网络上的数字型IP地址相对应的字符型地址,就被称为域名。

目前域名已经成为互联网品牌、网上商标保护必备的要素之一,除了识别功能外,还有引导、宣传等作用。如:www.baidu.com

DNS:

在Internet上域名与IP地址之间是一对一(或者多对一)的,域名虽然便于人们记忆,但机器之间只能互相认识IP地址,它们之间的转换工作称为域名解析,域名解析需要由专门的域名解析服务器来完成,DNS(Domain Name System域名系统)就是进行域名解析的服务器,域名的最终指向是IP。

4. 端口号(port)

在这里插入图片描述

在计算机中,不同的应用程序是通过端口号区分的。

端口号是用两个字节(无符号)表示的,它的取值范围是0~65535,而这些计算机端口可分为3大类:

  1. 公认端口:0~1023。被预先定义的服务通信占用(如:HTTP占用端口80,FTP占用端口21,Telnet占用端口23等)
  2. 注册端口:1024~49151。分配给用户进程或应用程序。(如:Tomcat占用端口8080,MySQL占用端口3306,Oracle占用端口1521等)。
  3. 动态/私有端口:49152~65535。

通常情况下,服务器程序使用固定的端口号来监听客户端的请求,而客户端则使用随机端口连接服务器。

IP地址好比每个人的地址(门牌号),端口好比是房间号。必须同时指定IP地址和端口号才能够正确的发送数据。接下来通过一个图例来描述IP地址和端口号的作用。

5. 通信协议

通过计算机网络可以使多台计算机实现连接,位于同一个网络中的计算机在进行连接和通信时需要遵守一定的规则。就像两个人想要顺利沟通就必须使用同一种语言一样,如果一个人只懂英语而另外一个人只懂中文,这样就会造成没有共同语言而无法沟通。

在计算机网络中,这些连接和通信的规则被称为网络通信协议,它对数据的传输格式、传输速率、传输步骤等做了统一规定,通信双方必须同时遵守才能完成数据交换。

在计算机网络中,常用的协议有 TCP、UDP、HTTP、FTP 等。这些协议规定了数据传输的格式、传输方式和传输顺序等细节。其中,TCP(传输控制协议)是一种可靠的面向连接的协议,它提供数据传输的完整性保证;而 UDP(用户数据报协议)则是一种无连接的协议,传输效率高。在网络编程中,需要选取合适的协议类型来实现数据传输。

5.1 通信协议相关的:OSI 参考模型

在这里插入图片描述

世界上第一个网络体系结构由IBM公司提出(1974年,SNA),以后其他公司也相继提出自己的网络体系结构如:Digital公司的DNA,美国国防部的TCP/IP等,多种网络体系结构并存,其结果是若采用IBM的结构,只能选用IBM的产品,只能与同种结构的网络互联。

为了促进计算机网络的发展,国际标准化组织ISO(International Organization for Standardization)于1977年成立了一个委员会,在现有网络的基础上,提出了不基于具体机型、操作系统或公司的网络体系结构,称为开放系统互连参考模型,即OSI/RM (Open System Interconnection Reference Model)。OSI模型把网络通信的工作分为7层,分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

5.2 通信协议相关的:TCP / IP 参考模型

OSI 参考模型的初衷是提供全世界范围的计算机网络都要遵循的统一标准,但是由于存在模型和协议自身的缺陷,迟迟没有成熟的产品推出。TCP/IP协议在实践中不断完善和发展取得成功,作为网络的基础,Internet的语言,可以说没有TCP/IP参考模型就没有互联网的今天。

TCP/IP,即Transmission Control Protocol/Internet Protocol的简写,中译名为传输控制协议/因特网互联协议,是Internet最基本的协议、Internet国际互联网络的基础。

TCP/IP协议是一个开放的网络协议簇,它的名字主要取自最重要的网络层IP协议和传输层TCP协议。TCP/IP协议定义了电子设备如何连入因特网,以及数据如何在它们之间传输的标准。TCP/IP参考模型采用4层的层级结构,每一层都呼叫它的下一层所提供的协议来完成自己的需求,这4个层次分别是:网络接口层、互联网层(IP层)、传输层(TCP层)、应用层。

OSI模型与TCP/IP模型的对应关系如图所示:

在这里插入图片描述

5.3 补充:OSI 参考模型 与 TCP / IP 参考模型 区别

  1. OSI 参考模型是理论上的,而 TCP/IP 参考模型是实践上的。TCP/IP 参考模型被许多实际的协议(如 IP、TCP、HTTP 等)所支持和实现,而 OSI 参考模型则主要是作为理论框架和标准进行研究和讨论。
  2. OSI 参考模型是由国际标准化组织提出的网络通信协议框架,其中分为 7 层,各层之间明确了功能的划分,从物理层到应用层,逐层向上升,每层只对自己下一层提供服务,并依次封装和解封数据。OSI 参考模型是一种理论上的协议框架,用于描述计算机系统间的通信原理和规范。
  3. TCP/IP 参考模型(也称互联网参考模型)是实际应用中最广泛的协议框架。它将网络协议划分为 4 层:网络接口层、网络层、传输层和应用层。TCP/IP 参考模型与 OSI 参考模型之间有着相对应的层次结构,但是其中的每一层都是实际存在的协议,而不是纯粹的框架。TCP/IP 参考模型被广泛应用于互联网上,是计算机系统间进行通信的重要基础。

6. 网络编程基础类

6.1 InetAddress类

在这里插入图片描述

在这里插入图片描述

java.net.IntAddress
类用来封装计算机的IP地址和DNS(没有端口信息),它包括一个主机名和一个IP地址,是j ava对IP地址的高层表示。大多数其它网络类都要用到这个类,包括Socket、ServerSocket、URL、DatagramSocket、DatagramPacket等

常用静态方法:

  • static InetAddress getLocalHost()
    得到本机的InetAddress对象,其中封装了IP地址和主机名
  • static InetAddress getByName(String host)
    传入目标主机的名字或IP地址得到对应的InetAddress对象,其中封装了IP地址和主机名(底层会自动连接DNS服务器进行域名解析)

常用实例方法:

  • public String getHostAddress()
    获取IP地址
  • public String getHostName()
    获取主机名/域名

编写运行测试:

在这里插入图片描述

package day34.com.rainbowsea.javase.net;


import java.net.InetAddress;
import java.net.UnknownHostException;

/**
 * java.net.IntAddress类用来封装设计计算机IP地址和DNS(没有端口信息)
 * 它包括一个主机名和一个地址,是Java对IP地址的高层表示,大多数其它
 * 网络类都要用到这个类,包括 Socket,ServerSocket,URL.DatagramSocket,DatagramPacket等
 */
public class InetAddressTest {
    public static void main(String[] args) throws UnknownHostException {
        // 获取本机的IP地址和主机名的封装对象: InetAddress
        InetAddress inetAddress = InetAddress.getLocalHost();

        // 获取本机的IP地址
        String hostAddress = inetAddress.getHostAddress();
        System.out.println("本机IP地址: " + hostAddress);

        // 获取本机的主机名
        String hostName = inetAddress.getHostName();
        System.out.println("本机的主机名: " + hostName);

        // 通过域名来获取InetAddress 对象
        InetAddress inetAddress2 = InetAddress.getByName("www.baidu.com");
        System.out.println(inetAddress2.getHostName());  // www.baidu.com
        System.out.println(inetAddress2.getHostAddress());  // 36.155.132.3


    }
}

在这里插入图片描述

6.2 URL 类

URL 是统一资源定位符
,对可以从互联网上得到的资源的位置和访问方法的一种简洁的表示,是互联网上标准资源的地址。互联网上的每个文件都有一个唯一的URL,它包含的信息指出文件的位置以及浏览器应该怎么处理它。

URL由4部分组成:协议、存放资源的主机域名、端口号、资源文件名。如果未指定该端口号,则使用协议默认的端口。例如HTTP协议的默认端口为80。在浏览器中访问网页时,地址栏显示的地址就是URL。

URL标准格式为:<协议>://<域名或IP>:<端口>/<路径>
。其中,<协议>://<域名或IP>是必需的,<端口>/<路径>有时可省略。如:
https://www.baidu.com

为了方便程序员编程,JDK中提供了URL类,该类的全名是java.net.URL,该类封装了大量复杂的涉及从远程站点获取信息的细节,可以使用它的各种方法来对URL对象进行分割、合并等处理。

URL类的构造方法:URL url = new URL(“
http://127.0.0.1:8080/oa/index.html?name=zhangsan#tip”
);

URL类的常用方法:

  • 获取协议:url.getProtocol()
  • 获取域名:url.getHost()
  • 获取默认端口:url.getDefaultPort()
  • 获取端口:url.getPort()
  • 获取路径:url.getPath()
  • 获取资源:url.getFile()
  • 获取数据:url.getQuery()
  • 获取锚点:url.getRef()

编写运行测试:

在这里插入图片描述

package day34.com.rainbowsea.javase.net;


import java.net.MalformedURLException;
import java.net.URL;

/**
 * URL包括四部分:协议:IP地址:端口:资源名称
 * URL是网络中某个资源的地址,某个资源的唯一地址
 * 通过URL是可以真实的定位到资源的
 * 在Java中,Java类库提供了一个URL类,来提供对URL的支持
 * URL的类的构造方法:
 *  URL url = new URL("url");
 *  URL类的常用方法
 */
public class URLTest {

    public static void main(String[] args) throws MalformedURLException {
        URL url = new URL("http://www.baidu.com/oa/index.html?name=lihua&passwrod=123#tip");


        // 获取URL中的信息
        String protocol = url.getProtocol();
        System.out.println("协议: " + protocol);

        // 获取资源路径
        String path = url.getPath();
        System.out.println("资源路径: " + path);

        // 获取默认端口(HTTP协议的默认端口是80,HTTPS的协议端口是:443)
        int defaultPort = url.getDefaultPort();
        System.out.println("默认端口: " + defaultPort);


        // 获取当前的端口
        int port = url.getPort();
        System.out.println("当前端口号: " + port);

        // 获取URL中的IP地址
        String host = url.getHost();
        System.out.println("主机地址: " + host);

        // 获取URL准备传送的数据
        String query = url.getQuery();
        System.out.println("需要提交给服务器的数据: " + query);

        String ref = url.getRef();
        System.out.println("获取锚点: " + ref);

        // 获取 资源路径 + 数据
        String file = url.getFile();
        System.out.println("获取数据资源文件路径: " + file);
    }
}

使用URL类的
openStream()
方法可以打开到此URL的连接并返回一个用于从该连接读入的InputStream,实现最简单的网络爬虫。

编写运行测试:

在这里插入图片描述

package day34.com.rainbowsea.javase.net;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;

public class URLTest2 {

    public static void main(String[] args) throws IOException {
        // 使用URL类的openStream()方法可以打开到此URL的连接并返回一个用于从该连接读入的InputStream,实现最简单的网络爬虫
        URL url = new URL("https://tianqi.qq.com/");
        InputStream inputStream = url.openStream();
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));

        String s = null;

        while ((s = bufferedReader.readLine()) != null) {
            System.out.println(s);
        }

        bufferedReader.close();
    }
}

7. TCP 与 UDP协议

7.1 Socket 套接字概述

在这里插入图片描述

我们开发的网络应用程序位于应用层,TCP和UDP属于传输层协议,在应用层如何使用传输层的服务呢?在应用层和传输层之间,则是使用套接Socket来进行分离。

套接字就像是传输层为应用层开的一个小口,应用程序通过这个小口向远程发送数据,或者接收远程发来的数据。而这个小口以内,也就是数据进入这个口之后,或者数据从这个口出来之前,是不知道也不需要知道的,也不会关心它如何传输,这属于网络其它层次工作。

Socket实际是传输层供给应用层的编程接口。Socket就是应用层与传输层之间的桥梁。使用Socket编程可以开发客户机和服务器应用程序,可以在本地网络上进行通信,也可通过Internet在全球范围内通信。

TCP协议和UDP协议是传输层的两种协议。Socket是传输层供给应用层的编程接口,所以Socket编程就分为TCP编程和UDP编程两类。

7.2 TCP 与 UDP协议的区别

TCP协议:

  1. 使用TCP协议,须先建立TCP连接,形成传输数据通道,似于拨打电话
  2. 传输前,采用“三次握手”方式,属于点对点通信,是面向连接的,效率低。
  3. 仅支持单播传输,每条TCP传输连接只能有两个端点(客户端、服务端)。
  4. 两个端点的数据传输,采用的是“字节流”来传输,属于可靠的数据传输。
  5. 传输完毕,需释放已建立的连接,开销大,速度慢,适用于文件传输、邮件等。

UDP协议:

  1. 采用数据报(数据、源、目的)的方式来传输,无需建立连接,类似于发短信。
  2. 每个数据报的大小限制在64K内,超出64k可以分为多个数据报来发送。
  3. 发送不管对方是否准备好,接收方即使收到也不确认,因此属于不可靠的。
  4. 可以广播发送,也就是属于一对一、一对多和多对一连接的通信协议。
  5. 发送数据结束时无需释放资源,开销小,速度快,适用于视频会议、直播等。

在这里插入图片描述

7.3 补充:TCP协议的三次握手(通道建立)

TCP(传输控制协议)是一种面向连接的、可靠的传输层协议。它使用三次握手来建立连接,以确保数据在两个设备之间可靠地传输。

三次握手的过程如下:

  1. 客户端发送 SYN(同步)数据包。这个数据包包含客户端的初始序列号(ISN)。
  2. 服务器收到 SYN 数据包后,发送 SYN-ACK(同步确认)数据包。这个数据包包含服务器的初始序列号(ISN)和对客户端 ISN 的确认号(ACK)。
  3. 客户端收到 SYN-ACK 数据包后,发送 ACK(确认)数据包。这个数据包包含对服务器 ISN 的确认号(ACK)。
    三次握手完成后,客户端和服务器就可以开始交换数据了。

可以四次,五次握手都可以,握手的目的就是为了,确保连接的建立。至于为什么是三次,因为三次握手就足够可以确保连接的成功建立了。多握几次,也是可以,但是会增加时间,效率上的开销。

三次握手的意义:

三次握手可以确保数据在两个设备之间可靠地传输。它可以防止以下情况的发生:

  1. 不会丢失:如果没有三次握手,客户端和服务器可能会同时发送数据,导致数据丢失。
  2. 不会重复:如果没有三次握手,客户端和服务器可能会重复发送数据,导致数据重复。
  3. 不会乱序:如果没有三次握手,客户端和服务器可能会乱序发送数据,导致数据乱序。

在这里插入图片描述

7.4 补充:TCP协议的四次挥手(通道关闭)

使用四次挥手来关闭连接,以确保数据在两个设备之间可靠地传输。

四次挥手的过程如下:

  1. 客户端发送 FIN(结束)数据包。这个数据包表示客户端已经完成数据传输,并希望关闭连接。
  2. 服务器收到 FIN 数据包后,发送 ACK(确认)数据包。这个数据包表示服务器已经收到客户端的 FIN 数据包,并同意关闭连接。
  3. 服务器发送 FIN 数据包。这个数据包表示服务器已经完成数据传输,并希望关闭连接。
  4. 客户端收到 FIN 数据包后,发送 ACK(确认)数据包。这个数据包表示客户端已经收到服务器的 FIN 数据包,并同意关闭连接。
    四次挥手完成后,客户端和服务器之间的连接就关闭了。

同理,五次,六次...等等挥手都可以,挥手的:目的就是为了,确保连接的关闭,不丢失数据。至于为什么是四次,因为四次挥手就足够可以确保所有的连接关闭了,数据不丢失。多挥几次,也是可以,但是会增加时间,效率上的开销。

四次挥手的意义:

四次挥手可以确保数据在两个设备之间可靠地传输。它可以防止以下情况的发生:

  1. 如果没有四次挥手,客户端和服务器可能会同时关闭连接,导致数据丢失。
  2. 如果没有四次挥手,客户端和服务器可能会重复发送数据,导致数据重复。
  3. 如果没有四次挥手,客户端和服务器可能会乱序发送数据,导致数据乱序。

8. TCP协议编程

在这里插入图片描述

套接字是一种进程间的数据交换机制,利用套接字(Socket)开发网络应用程序早已被广泛的采用,以至于成为事实上的标准。

在网络通讯中,第一次主动发起通讯的程序被称作客户端(Client),而在第一次通讯中等待连接的程序被称作服务端(Server)。一旦通讯建立,则客户端和服务器端完全一样,没有本质的区别。

套接字与主机地址和端口号相关联,主机地址就是客户端或服务器程序所在的主机的IP地址,端口地址是指客户端或服务器程序使用的主机的通信端口。在客户端和服务器中,分别创建独立的Socket,并通过Socket的属性,将两个Socket进行连接,这样客户端和服务器通过套接字所建立连接并使用IO流进行通信。

8.1 Socket类概述

在这里插入图片描述

在这里插入图片描述

Socket类实现客户端套接字(Client),套接字是两台机器间通信的端点

Socket类构造方法:

public Socket(InetAddress a, int p)  // 创建套接字并连接到指定IP地址的指定端口号

Socket类实例方法:

public InetAddress getInetAddress()		// 返回此套接字连接到的远程 IP 地址。
public InputStream getInputStream()		// 返回此套接字的输入流(接收网络消息)。
public OutputStream getOutputStream()		// 返回此套接字的输出流(发送网络消息)。
public void shutdownInput()				// 禁用此套接字的输入流
public void shutdownOutput()				// 禁用此套接字的输出流。
public synchronized void close()			// 关闭此套接字(默认会关闭IO流)。

8.2 ServerSocket 类概述

在这里插入图片描述

在这里插入图片描述

ServerSocket 类用于实现服务器套接字(Server服务端)。服务器套接字等待请求通过网络传入。它基于该请求执行某些操作,然后可能向请求者返回结果。

ServerSocket构造方法:

public ServerSocket(int port)

ServerSocket实例方法:

public Socket accept()				// 侦听要连接到此套接字并接受它。
public InetAddress getInetAddress()	// 返回此服务器套接字的本地地址。
public void close()				// 关闭此套接字。

9. 基于TCP协议的编程

9.1基于 TCP协议的单向通讯的实现

Java语言的基于套接字编程分为服务端编程和客户端编程,其通信模型如图所示

在这里插入图片描述

服务器端实现步骤:

  1. 创建ServerSocket对象,绑定并监听端口;
  2. 通过accept监听客户端的请求;
  3. 建立连接后,通过输出输入流进行读写操作;
  4. 调用close()方法关闭资源。

服务器端的代码编写如下:

在这里插入图片描述

package day34.com.rainbowsea.javase.net.onewaycommunication;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

public class Server {
    public static void main(String[] args) {
        ServerSocket serverSocket = null;
        Socket clientSocket = null;
        BufferedReader bufferedReader = null;

        try {
            // 先启动服务端,启动服务器端后,这个应用肯定要对应一个端口
            // 创建服务器端套接字对象
            int port = 8888;  // 指明端口
            serverSocket = new ServerSocket(port);
            System.out.println("服务器端正在启动,请稍后...");
            System.out.println("服务器端启动成功,端口号: " + port + ",等待客户端的请求");


            // 开始接收客户端的请求
            clientSocket = serverSocket.accept();

            // 后续代码怎么写一会再说?
            // 服务端接收消息,所以服务端应该获取输入流
            bufferedReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

            // 开始读
            String s = null;
            while ((s = bufferedReader.readLine()) != null) {
                System.out.println(s);
            }


        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            // 关闭服务端套接字
            try {
                serverSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            try {
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            try {
                bufferedReader.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

        }
    }
}

客户端实现步骤:

  1. 创建Socket对象,指定服务端的地址和端口号;
  2. 建立连接后,通过输入输出流进行读写操作;
  3. 通过输出输入流获取服务器返回信息;
  4. 调用close()方法关闭资源。

客户端的代码编写如下:

在这里插入图片描述

package day34.com.rainbowsea.javase.net.onewaycommunication;


import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;

/**
 * 现在使用Java中的 Socket实现单向通信,基于 TCP协议,属于TCP编程
 */
public class Client {
    public static void main(String[] args) {
        Socket clientSocket = null;
        BufferedWriter bufferedWriter = null;
        Scanner scanner = new Scanner(System.in);

        // 创建客户端套接字对象
        // 需要指定服务器的IP地址,和端口号
        try {
            InetAddress localHost = InetAddress.getLocalHost();
            int port = 8888;
            clientSocket = new Socket(localHost, port);

            // 客户端给服务器端发送信息
            // 客户端你是输出流
            bufferedWriter = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));

            // 发送信息
        /*    bufferedWriter.write("你好,最近怎么样");
            bufferedWriter.write("\n");
            bufferedWriter.write("你收到消息了吗");*/

            // 循环发送信息
        /*    while (true) {
                bufferedWriter.write("你好,最近怎么样");
                bufferedWriter.write("\n");
                bufferedWriter.write("你收到消息了吗");
                // 因为使用了缓存机制,需要记得刷新
                bufferedWriter.flush();

                // 延迟效果
                Thread.sleep(1000);
            }*/


            // 键盘中输入信息,发送给服务器端
            while (true) {
                System.out.println("请输入您要发送的信息: ");
                // 从键盘上接收的消息
                String msg = scanner.next();

                // 把消息发送给服务器端
                bufferedWriter.write(msg);

                bufferedWriter.write("\n"); // 换行

                // 刷新
                bufferedWriter.flush();

            }

            // 因为使用了缓存机制,需要记得刷新
            //bufferedWriter.flush();

        } catch (UnknownHostException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            try {
                bufferedWriter.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            scanner.close();
        }


    }
}

运行测试:

注意:一定是先启动服务器程序,然后再启动客户端程序,先后顺序千万别弄混了!

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

9.2 基于 TCP协议的双向通讯的实现

在双向通讯的案例中,客户端需要向服务端发送一张图片,服务端收到客户端发送的图片后,则需要向客户端回复收到图片的反馈。在客户端给服务端发送图片的时候,图片发送完毕必须调用shutdownOutput()方法来关闭socket输出流,否则服务端读取数据就会一直阻塞。

服务器端实现步骤:

  1. 创建ServerSocket对象,绑定监听端口;
  2. 通过accept()方法监听客户端请求;
  3. 使用输入流接收客户端发送的图片,然后通过输出流保存图片
  4. 通过输出流返回客户端图片收到。
  5. 调用close()方法关闭资源

服务端的代码编写:

在这里插入图片描述

package day34.com.rainbowsea.javase.net.twowaycommunication;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedWriter;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * 双向通信
 */
public class TwoWayServer {

    public static void main(String[] args) {
        ServerSocket serverSocket = null;
        Socket clientSocket = null;
        BufferedInputStream bufferedInputStream = null;
        BufferedOutputStream bufferedOutputStream = null;
        BufferedWriter bufferedWriter = null;

        try {
            // 创建服务器套接字对象
            int port = 8888;  // 端口号
            serverSocket = new ServerSocket(port);

            System.out.println("服务器启动成功,正在接收客户端的请求");

            // 开始接收客户端的请求
            clientSocket = serverSocket.accept();

            // 获取输入流
            bufferedInputStream = new BufferedInputStream(clientSocket.getInputStream());

            // 新建输出流,输出读取到的信息,到硬盘当中
            //new BufferedOutputStream(new FileOutputStream("本地服务器硬盘地址"))
            bufferedOutputStream = new BufferedOutputStream(new FileOutputStream("./test.jpg"));

            // 开始读
            byte[] bytes = new byte[1024];
            int readCount = 0;
            while ((readCount = bufferedInputStream.read(bytes)) != -1) {
                // 把客户端发送过来的图片,保存到本地服务器中
                bufferedOutputStream.write(bytes, 0, readCount);
            }

            // 刷新
            bufferedOutputStream.flush();

            // 服务器接收完图片之后给客户端回个话
             bufferedWriter = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));
            bufferedWriter.write("你发的图片我已经收到了");

            // 刷新
            bufferedWriter.flush();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                serverSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            try {
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            try {
                bufferedInputStream.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            try {
                bufferedOutputStream.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            try {
                bufferedWriter.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

    }
}

客户端实现步骤:

  1. 创建socket对象,指明需要连接的服务器地址和端口号;
  2. 建立连接后,通过输出流向服务器端发送图片;
  3. 通过输入流获取服务器的响应信息;
  4. 调用close()方法关闭资源

客户端的代码编写:

在这里插入图片描述

所在图片的路径如下:

在这里插入图片描述

运行测试:

同样注意:
注意:一定是先启动服务器程序,然后再启动客户端程序,先后顺序千万别弄混了!

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

10. 基于 UDP 协议的编程

10.1 UDP 协议编程概述

在UDP通信协议下,两台计算机之间进行数据交互,并不需要先建立连接,发送端直接往指定的IP和端口号上发送数据即可,但是它并不能保证数据一定能让对方收到,也不能确定什么时候可以送达。

java.net.DatagramSocket类

java.net.DatagramPacket类
是使用UDP编程中需要使用的两个类,并且
发送端

接收端
都需要使用这个俩类,并且 发送端与接收端是
两个独立
的运行程序。

  1. DatagramSocket:负责接收和发送数据,创建接收端时需要指定端口号。
  2. DatagramPacket:负责把数据打包,创建发送端时需指定接收端的IP地址和端口。

10.2 DatagramSocket 类的概述

在这里插入图片描述

在这里插入图片描述

DatagramSocket类作为基于UDP协议的Socket,使用DatagramSocket类可以用于接收和发送数据,同时创建接收端时还需指定端口号。

DatagramSocket的构造方法:

public DatagramSocket()			// 创建发送端的数据报套接字
public DatagramSocket(int port)		// 创建接收端的数据报套接字,并指定端口号

DatagramSocket的实例方法:

public void send(DatagramPacket p)	// 发送数据报。
public void receive(DatagramPacket p)	// 接收数据报。
public void close()				// 关闭数据报套接字。

10.3 DatagramPacket 类的概述

在这里插入图片描述

在这里插入图片描述

DatagramPacket类负责把发送的数据打包(打包的数据为byte类型的数组),并且创建发送端时需指定接收端的IP地址和端口。

DatagramPacket的构造方法:

public DatagramPacket(byte buf[], int offset, int length) // 创建接收端的数据报。
public DatagramPacket(byte buf[], int offset, int length, InetAddress address, int port) // 创建发送端的数据报,并指定接收端的IP地址和端口号。

DatagramPacket的实例方法:

public synchronized byte[] getData() // 返回数据报中存储的数据
public synchronized int getLength()  // 获得发送或接收数据报中的长度

11. 基于UDP协议的编程通信实现

接收端实现步骤:

  1. 创建DatagramSocket对象(接收端),并指定端口号;
  2. 创建DatagramPacket对象(数据报);
  3. 调用receive()方法,用于接收数据报;
  4. 调用close()方法关闭资源

接收端的代码编写:
在这里插入图片描述

package day34.com.rainbowsea.javase.net.udpcommunication;




import java.net.DatagramPacket;
import java.net.DatagramSocket;

/**
 * UDP编程,接收端
 */
public class Receive {
    public static void main(String[] args) throws Exception {
        // 创建 UDP的 Socket 套接字
        DatagramSocket datagramSocket = new DatagramSocket(8888);

        byte[] bytes = new byte[1024 * 64];
        // 准备一个包,这个包接收发送方的信息
        DatagramPacket datagramPacket = new DatagramPacket(bytes, bytes.length);

        // 程序执行到这里,停下来,等待发送方的发送
        datagramSocket.receive(datagramPacket);

        // 程序执行到这里说明,已经完全将发送方发送的数据接收到了
        // 从包中取出来数据

        String msg = new String(bytes, 0, datagramPacket.getLength());

        System.out.println("接收到发送方发过来的消息: " + msg);

        datagramSocket.close();

    }
}

发送端的代码编写:

在这里插入图片描述

package day34.com.rainbowsea.javase.net.udpcommunication;


import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

/**
 * UDP编程, 发送端
 */
public class Send {
    public static void main(String[] args) throws Exception {
        // 创建一个 UDP的Socket 套接字
        DatagramSocket datagramSocket = new DatagramSocket();

        //  创建包
        byte[] bytes = "RainbowSea".getBytes();
        DatagramPacket datagramPacket = new DatagramPacket(bytes, 0, bytes.length, InetAddress.getLocalHost(), 8888);

        // 发送消息,将封装到包(datagramPacket) 中的信息发送过去
        datagramSocket.send(datagramPacket);

        datagramSocket.close();
    }
}

运行测试:
注意:先启动接收端,再启动发送端

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

12. 总结:

Java SE 中的网络编程主要还是理解:网络协议:TCP协议和UDP协议,特别是 TCP协议的三次握手,和四次挥手。

而Java SE 的网络编程,这些我们后续的 Tomcat Web 框架当中都是封装好了的,并不需要我们真的自己重写这些底层的方法。我们只需要调用就好了。所以关于这部分的内容大家了解即可。

13. 最后:

限于自身水平,其中存在的错误,希望大家给予指教,韩信点兵——多多益善,谢谢大家,江湖再见,后会有期 !!!

在这里插入图片描述

定义

适配器模式是一种结构型设计模式,它允许
将一个类的接口转换为客户端希望的另一个接口
。适配器使得原本由于接口不兼容而不能一起工作的类可以协同工作。通过创建适配器类,可以将现有类的接口转换成目标接口,从而使这些类能够在一起工作。

为什么使用适配器模式

  1. 兼容性

  • 适配器模式能够解决由于接口不兼容而无法直接协作的问题,使得现有的类能够在新系统中复用。
  • 代码重用

    • 适配器模式允许在不修改现有代码的情况下,将其整合到新的代码结构中,实现代码的重用。
  • 灵活性

    • 通过适配器,可以在运行时动态地转换接口,增强了系统的灵活性和扩展性。

    适配器模式的实现步骤

    1. 目标接口

    • 定义客户端所期望的接口,即目标接口。
  • 现有接口

    • 定义一个已经存在的类,它的接口与目标接口不兼容。
  • 适配器类

    • 对象适配器

      • 继承目标接口,通过组合持有现有类的实例,并在实现目标接口的方法中调用现有类的方法,实现接口转换。
    • 类适配器

      • 继承目标接口并同时继承现有类,通过覆盖现有类的方法来实现接口转换。

    优缺点和适用场景

    优点

    1. 兼容性

    • 可以使得不兼容的接口一起工作,解决了接口不兼容的问题。
  • 代码重用

    • 可以在不修改现有类的情况下使用这些类,实现代码重用。
  • 灵活性

    • 可以动态地改变接口的实现,增强系统的灵活性和扩展性。

    缺点

    1. 复杂性增加

    • 需要额外编写适配器类,增加了系统的复杂性。
  • 性能开销

    • 适配器模式会增加一个额外的层次,可能会带来一定的性能开销。

    适用场景

    1. 接口转换

    • 当现有类的接口与目标接口不兼容时,可以使用适配器模式进行接口转换。
  • 遗留系统整合

    • 在整合遗留系统时,可以使用适配器模式将现有系统的接口转换为新系统所需的接口。
  • 第三方库整合

    • 当需要使用第三方库的类,而这些类的接口与系统不兼容时,可以使用适配器模式。

    例子:使用适配器模式将旧系统的接口转换为新系统的接口

    #include <iostream>#include<memory>#include<string>
    
    
    //目标接口:新的日志接口
    classLogger {public:virtual ~Logger() {}virtual void logMessage(const std::string& message) const = 0;
    };
    //现有接口:旧的日志系统 classOldLogger {public:void writeLog(const std::string& msg) const{
    std::cout
    << "Old Logger:" << msg <<std::endl;
    }
    };
    //对象适配器类:将旧的日志系统适配为新的日志接口 class LoggerAdapter : publicLogger {private:
    std::shared_ptr
    <OldLogger>oldLogger;public:
    LoggerAdapter(std::shared_ptr
    <OldLogger>oldLogger) : oldLogger(oldLogger) {}void logMessage(const std::string& message) const override{
    oldLogger
    ->writeLog(message);
    }
    };
    //类适配器类:将旧的日志系统适配为新的日志接口 class ClassLoggerAdapter : public Logger, privateOldLogger {public:void logMessage(const std::string& message) const override{
    writeLog(message);
    }
    };
    intmain() {//使用旧的日志系统 std::shared_ptr<OldLogger> oldLogger = std::make_shared<OldLogger>();
    oldLogger
    ->writeLog("Logging with the old logger");//使用对象适配器将旧的日志系统适配为新的日志接口 std::shared_ptr<Logger> logger = std::make_shared<LoggerAdapter>(oldLogger);
    logger
    ->logMessage("Logging with the object adapter");//使用类适配器将旧的日志系统适配为新的日志接口 std::shared_ptr<Logger> classLogger = std::make_shared<ClassLoggerAdapter>();
    classLogger
    ->logMessage("Logging with the class adapter");return 0;
    }

    前言

    在WPF中使用导航功能可以使用Frame控件,这是比较基础的一种方法。前几天分享了wpfui中NavigationView的基本用法,但是如果真正在项目中使用起来,基础的用法是无法满足的。今天通过wpfui中的mvvm例子来说明在wpfui中如何通过依赖注入与MVVM模式使用导航功能。实践起来,我个人觉得这个例子中实现导航功能还是有点麻烦的,但我也不知道怎么能更优雅,也是学到了一些东西吧。

    wpfui中MVVM例子的地址在:
    https://github.com/lepoco/wpfui/tree/main/src/Wpf.Ui.Demo.Mvvm

    实现效果如下所示:

    如果你对此感兴趣,可以继续阅读。

    实践

    使用依赖注入

    将主窗体与主窗体的ViewModel与每个页面与每个页面的ViewModel都存入依赖注入容器中:

    image-20240718141334286

    当然不只是窗体页面与ViewModel,也需要注册一些服务。

    为了实现导航功能,使用了两个服务分别是NavigationService与PageService。

    NavigationService在wpfui库中已经自带了,直接使用即可:

    image-20240718141645305

    具体代码可自行研究,这里就不放了。

    而PageService在wpfui中没有自带,需要自己定义,MVVM例子中的定义如下所示:

     public class PageService : IPageService
     {
         /// <summary>
         /// Service which provides the instances of pages.
         /// </summary>
         private readonly IServiceProvider _serviceProvider;
    
         /// <summary>
         /// Initializes a new instance of the <see cref="PageService"/> class and attaches the <see cref="IServiceProvider"/>.
         /// </summary>
         public PageService(IServiceProvider serviceProvider)
         {
             _serviceProvider = serviceProvider;
         }
    
         /// <inheritdoc />
         public T? GetPage<T>()
             where T : class
         {
             if (!typeof(FrameworkElement).IsAssignableFrom(typeof(T)))
             {
                 throw new InvalidOperationException("The page should be a WPF control.");
             }
    
             return (T?)_serviceProvider.GetService(typeof(T));
         }
    
         /// <inheritdoc />
         public FrameworkElement? GetPage(Type pageType)
         {
             if (!typeof(FrameworkElement).IsAssignableFrom(pageType))
             {
                 throw new InvalidOperationException("The page should be a WPF control.");
             }
    
             return _serviceProvider.GetService(pageType) as FrameworkElement;
         }
     }
    

    现在已经将所有窗体、页面、ViewModels与相关服务都注册到容器中了。

    ViewModel

    在MainWindowViewModel中将页面存入一个属性中:

    image-20240718142334814

    在非首页的ViewModel中实现INavigationAware接口:

    image-20240718142456377

    View

    MainWindow.cs如下所示:

     public partial class MainWindow : INavigationWindow
     {
         public ViewModels.MainWindowViewModel ViewModel { get; }
    
         public MainWindow(
             ViewModels.MainWindowViewModel viewModel,
             IPageService pageService,
             INavigationService navigationService
         )
         {
             ViewModel = viewModel;
             DataContext = this;
    
             Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
    
             InitializeComponent();
             SetPageService(pageService);
    
             navigationService.SetNavigationControl(RootNavigation);
         }
    
         public INavigationView GetNavigation() => RootNavigation;
    
         public bool Navigate(Type pageType) => RootNavigation.Navigate(pageType);
    
         public void SetPageService(IPageService pageService) => RootNavigation.SetPageService(pageService);
    
         public void ShowWindow() => Show();
    
         public void CloseWindow() => Close();
    
         /// <summary>
         /// Raises the closed event.
         /// </summary>
         protected override void OnClosed(EventArgs e)
         {
             base.OnClosed(e);
    
             // Make sure that closing this window will begin the process of closing the application.
             Application.Current.Shutdown();
         }
    
         INavigationView INavigationWindow.GetNavigation()
         {
             throw new NotImplementedException();
         }
    
         public void SetServiceProvider(IServiceProvider serviceProvider)
         {
             throw new NotImplementedException();
         }
     }
    

    首先实现了INavigationWindow接口。在构造函数中注入所需的依赖类。注意这里的RootNavigation其实就是页面中NavigationView的名称:

    image-20240718142925133

    刚开始看这里没注意到,卡壳了很久。

    因为你在代码中查看定义,它会转到这个地方:

    image-20240718143106472

    没经验不知道是什么,但是这次过后,知道这是在Xaml中定义,由工具自动生成的代码了。

    其他的页面改成了这样的写法:

     public partial class DashboardPage : INavigableView<DashboardViewModel>
     {
         public DashboardViewModel ViewModel { get; }
         public DashboardPage(DashboardViewModel  viewModel)
         {
             ViewModel = viewModel;
             this.DataContext = this;
             InitializeComponent();          
         }
     }
    

    都实现了
    INavigableView<out T>
    接口:

    image-20240718143558501

    显示主窗体与主页面

    现在准备工作都做好了,下一步就是显示主窗体与主页面了。

    在容器中我们也注入了这个:

    image-20240718144029024

    ApplicationHostService如下所示:

        /// <summary>
        /// Managed host of the application.
        /// </summary>
        public class ApplicationHostService : IHostedService
        {
            private readonly IServiceProvider _serviceProvider;
            private INavigationWindow? _navigationWindow;
    
            public ApplicationHostService(IServiceProvider serviceProvider)
            {
                _serviceProvider = serviceProvider;
            }
    
            /// <summary>
            /// Triggered when the application host is ready to start the service.
            /// </summary>
            /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
            public async Task StartAsync(CancellationToken cancellationToken)
            {
                await HandleActivationAsync();
            }
    
            /// <summary>
            /// Triggered when the application host is performing a graceful shutdown.
            /// </summary>
            /// <param name="cancellationToken">Indicates that the shutdown process should no longer be graceful.</param>
            public async Task StopAsync(CancellationToken cancellationToken)
            {
                await Task.CompletedTask;
            }
    
            /// <summary>
            /// Creates main window during activation.
            /// </summary>
            private async Task HandleActivationAsync()
            {
                await Task.CompletedTask;
    
                if (!System.Windows.Application.Current.Windows.OfType<MainWindow>().Any())
                {
                    _navigationWindow = (
                        _serviceProvider.GetService(typeof(INavigationWindow)) as INavigationWindow
                    )!;
                    _navigationWindow!.ShowWindow();
    
                    _ = _navigationWindow.Navigate(typeof(DashboardPage));
                }
    
                await Task.CompletedTask;
            }
        }
    }
    

    在app.xaml中定义了程序启动与退出事件的处理程序:

    image-20240718144223862

     /// <summary>
     /// Occurs when the application is loading.
     /// </summary>
     private async void OnStartup(object sender, StartupEventArgs e)
     {
         await _host.StartAsync();
     }
    
     /// <summary>
     /// Occurs when the application is closing.
     /// </summary>
     private async void OnExit(object sender, ExitEventArgs e)
     {
         await _host.StopAsync();
    
         _host.Dispose();
     }
    

    整个过程回顾

    在OnStartup方法中打个断点,理解这个过程:

    image-20240718144509901

    点击下一步:

    image-20240718144922482

    到ApplicationHostService中了,一步一步调试,注意这个地方:

    image-20240718145229906

    因为主窗体实现了
    INavigationWindow
    接口,这里获取了主窗体并将主窗体显示,然后调用主窗体中的Navigate方法,导航到DashPage页面,之后点继续,结果如下所示:

    image-20240718145523282

    最后

    以上就是自己最近学习wpfui中导航功能实现的笔记,在自己的项目中也成功使用,对于可能会经常修改代码增加功能的程序这样做感觉挺好的,但是如果你只是使用WPF做一个简单的小工具,感觉这样做增加了复杂度,不用依赖注入,不用做这么复杂的导航,甚至不使用MVVM模式都可以。

    Kolors_00012_

    在软件开发和测试过程中,我们经常需要对应用程序的网络请求进行录制和回放,以便进行性能分析、压力测试或者模拟复杂的网络环境。今天,我要向大家推荐一款简单易用的 HTTP 请求流量录制回放工具:
    Goreplay

    1、简介

    Goreplay
    是一款用 Go 语言编写的 HTTP 请求流量录制回放工具。它可以帮助开发者轻松地捕获、查看和修改 HTTP 请求和响应,同时支持多种协议,如 HTTP/1、HTTP/2 和 WebSocket。Goreplay 具有以下特点:

    • 简单易用
      :Goreplay 提供了简洁的命令行界面,用户只需通过简单的命令即可完成请求的录制和回放。
    • 高性能
      :由于使用 Go 语言编写,Goreplay 具有出色的性能表现,可以快速处理大量的请求数据。
    • 灵活的配置
      :Goreplay 支持丰富的配置选项,用户可以根据需求定制录制和回放的行为。
    • 跨平台
      :Goreplay 支持 Windows、macOS 和 Linux 等多种操作系统,方便用户在不同平台上使用。

    2、Goreplay实现原理

    Goreplay 的录制原理
    是基于网络接口的监听和流量捕获来实现的

    Goreplay 工具的核心功能是对服务器的网络接口进行实时监听,这样它就能够捕获所有进出服务器的 HTTP 流量。当流量被捕获后,Goreplay 可以选择性地将这些请求重新发送到另一个服务器,或者保存下来用于后续的分析和回放。

    1、其中,Goreplay 首先通过一个名为 listener server 的组件来捕获网络流量。这个组件能够监听指定的网络接口,并实时捕获经过该接口的 HTTP 请求和响应。

    2、捕获到的流量可以被发送到 replay server,也可以被保存到文件中,或者发送到 Kafka 等消息队列中。

    3、在回放阶段,replay server 会从保存的文件中读取之前捕获的流量,并将其重新发送到配置的目标地址。这样,就可以模拟原始的请求和响应,对系统进行压力测试或功能验证。

    一句话小结
    :Goreplay 的工作原理是通过监听网络接口捕获流量,然后根据用户的配置选择将流量保存、转发或回放,以此来满足不同的测试和分析需求。

    这种设计使得 Goreplay 成为一个非常灵活且功能丰富的工具,适用于多种测试场景。具体来说,Goreplay 通常被应用于以下方面:

    • 性能测试:通过回放真实的用户请求来模拟高负载情况,测试服务器的性能极限。
    • 故障排查:记录出现问题时的流量,以便开发人员可以详细分析并定位问题。
    • 功能或接口测试:确保应用程序在特定的网络请求下能够正确执行预期的操作。
    • 安全测试:检查应用程序在处理网络请求时是否存在安全漏洞。

    3、如何配置和使用 Goreplay

    1、下载和安装:
    首先,从 Goreplay 的官方网站或 GitHub 仓库下载最新版本的 Goreplay 工具,并解压到本地目录。

    https://github.com/buger/goreplay/releases
    

    2、录制网络请求:使用 Goreplay 命令行工具启动录制模式,监听指定端口并将录制的请求保存到文件中。示例命令如下:

    ./gor --input-raw :8080 --output-file requests.gor
    

    3、停止录制:在需要时停止录制,并保存录制的网络请求文件。

    4、回放网络请求:使用 Goreplay 回放模式,将录制的网络请求文件进行回放。示例命令如下:

    ./gor --input-file requests.gor --output-http "http://target-server:8080"
    

    调整配置:通过编辑配置文件或命令行参数,可以调整回放的速度、过滤规则等参数。

    4、更多高阶用法

    除了基本的录制和回放功能外,Goreplay 还支持一些高级用法和命令,以下是一些示例:

    1、使用过滤器:

    可以使用 -http-allow-url 和 -http-deny-url 参数来过滤特定的 URL 请求。例如,只录制或回放包含 /api 的请求:

    ./gor --input-raw :8080 --output-stdout -http-allow-url "/api"
    

    2、修改请求头:

    可以使用 -http-set-header 参数来修改请求头信息。例如,添加一个自定义的 X-My-Header 头信息:

    ./gor --input-file requests.gor --output-http "http://target-server:8080" -http-set-header "X-My-Header: Value"
    

    3、重放速度控制:

    可以使用 -replay-connection-rate 参数来控制回放的速度。例如,设置每秒回放 100 个连接:

    ./gor --input-file requests.gor --output-http "http://target-server:8080" -replay-connection-rate 100
    

    4、多个输入输出:

    可以同时监听多个端口或从多个文件中读取请求,并将请求输出到多个目标。例如:

    ./gor --input-raw :8080 --input-raw :8081 --output-http "http://target-server1:8080" --output-http "http://target-server2:8080"
    

    5、使用正则表达式过滤:

    可以使用正则表达式来过滤请求。例如,只录制或回放包含特定关键字的请求:

    ./gor --input-raw :8080 --output-stdout -http-allow-url-regex ".*keyword.*"
    

    这些是一些高级用法和命令示例,希望能帮助你更灵活地使用 Goreplay 进行网络请求的录制和回放。

    5、小结

    Goreplay
    是一款功能强大且易用的流量录制回放工具,可以帮助我们轻松地实现对 HTTP 请求的录制和回放。通过使用
    Goreplay
    ,我们可以更好地进行压力测试、性能分析等工作,提高软件质量和开发效率。强烈推荐大家尝试使用
    Goreplay
    ,相信它会给你带来不一样的体验。