首页 / 资讯中心 / 文章详情

GEE模块化开发:用exports和require打造可复用脚本库

GEE模块化开发:用exports和require打造可复用脚本库 ★ FEATURED ARTICLE
使用GEEGoogle Earth Engine做开发的人大概都经历过脚本从“能跑”到“失控”的过程一开始只有一个测试脚本后来功能多了、团队人多了同样的函数在十几个脚本里复制粘贴改一处漏一处最后连自己都分不清哪个版本是新的。GEE的代码编辑器其实内置了一套模块化机制核心就是两个关键字exports和require。用它们可以把公共逻辑抽到单独的脚本文件里按需加载像搭积木一样组织项目。这篇文章我把这套机制的用法、原理和踩过的坑完整讲一遍适合脚本已经开始变多、想提升复用水平的开发者也适合准备给团队搭建公共工具库的人。1. 模块化背后的思路没有它脚本会变成什么样1.1 不模块化的三个典型痛点我先说说自己早期在GEE上写代码的经历。最开始我习惯把所有东西放进一个脚本变量越来越多函数越写越长。为了在另一个项目里复用一段Landsat云掩膜代码我直接把整个函数体复制过去改个变量名接着用。这种方式的麻烦是渐进式的一是一致性极差。同一个函数在A脚本里加了新参数B脚本还是旧版本跑出来的结果对不上。二是文件体积失控。一个脚本上千行编辑器卡顿不说滚动找函数都费劲。三是团队协作基本靠“口头同步”。同事想用我的工具函数得先找我要脚本再手动导入流程繁琐。这些问题本质上都是因为GEE脚本缺少隔离和引用机制所有代码在一个命名空间里互相干扰又无法直接调用另一个脚本的内容。exports和require就是为了解决这些痛点设计的。1.2 GEE模块化的核心原理GEE的代码编辑器基于一个特殊的脚本解释环境。你可以把每个脚本文件想象成一个“工作间”exports是工作间门口贴的“对外供应清单”只有列在清单上的东西别人才能取走require则是拿着清单去指定工作间取货。具体来说当你编写一个模块脚本时脚本内部默认有私有作用域。普通用var定义的变量、用function定义的函数都不会自动对外可见。想要暴露给其他脚本就得挂到exports对象上例如exports.myFunction function() {...}。而加载方使用require(用户路径/脚本路径)时引擎会去找到那个脚本完整执行一遍收集所有exports挂载的内容返回成一个对象。这里要特别强调GEE里的require是同步加载主脚本执行到require那一行会先等模块脚本执行完毕再继续往下走。这意味着你在主脚本的任意位置写require都能拿到结果。不过我还是建议把所有require放在文件头部一是符合阅读习惯二是避免模块加载失败时后边代码白跑。1.3 模块化带来的直接影响用上这套机制之后我最大的感受是“脚本终于可以被拆开思考了”。公共算法单独放一个文件参数配置单独放一个文件主流程只负责串联。改动范围被隔离在模块内部调用方只要函数签名不变就完全不用动。同时因为模块天然就是一个脚本文件Git历史、代码审查、版本回退这些常规工程化手段都能用上了。这也是我认为GEE脚本值得在早期就引入模块化的核心理由。2. 核心语法exports怎么用、require怎么用、路径怎么写2.1 导出内容的正确姿势exports对象先看最简单的模块导出。假设你有一个脚本文件路径是users/yourname/tools/calc注意这个路径不带.js后缀它是代码编辑器里显示完整路径时去掉扩展名的形式。在这个文件里你想提供两个数学函数// 文件名calc exports.sum function(a, b) { return a b; }; exports.double function(x) { return x * 2; };把这段代码保存到代码编辑器里exports.sum就是给外部提供的接口。调用脚本里这样使用var calc require(users/yourname/tools/calc); print(calc.sum(3, 4)); // 7 print(calc.double(10)); // 20这里有几个细节要特别注意exports是GEE模块环境提供的全局对象不要自己声明也不要用this代替。我在早期某个项目里试过this.sum ...结果加载后返回的对象是空的。你可以在模块里写很多私有函数比如function helper() {...}但如果没有通过exports挂载外部永远拿不到。这不是坏事它能强制你设计清晰的公共API。exports除了挂函数还能挂常量、配置对象。比如exports.MY_CONST 42;调用方直接calc.MY_CONST就能用。2.2 加载模块require的路径规则与执行顺序require的完整语法就一行var 别名 require(users/你的用户名/文件夹/脚本名);路径的规则是必须以users/你的用户名/开头。后续是脚本在代码编辑器中的文件夹路径和文件名不要带.js后缀。路径区分大小写空格和特殊符号尽量避免。如果你要引用别人分享给你的脚本前缀就是对方的用户名例如users/othername/repo/landsat_utils。执行顺序上模块脚本在第一次被require时会完整执行一遍之后同一运行周期内再require同一个路径直接返回缓存的exports对象不会重新执行。这个缓存机制能提高性能但也带来一个副作用模块中的顶层代码如果带有print、Map.addLayer这些副作用即使你只是想require里面的函数这些副作用也会在每次运行主脚本时执行一次。所以好模块的顶层应该只做定义和导出所有实际的操作都放进函数里。2.3 一个最小可运行示例我整理一个完整的最小示例方便你立刻上手。首先在代码编辑器左侧Scripts面板里新建一个文件夹叫tools再新建一个脚本叫hello。注意GEE里新建脚本默认会带一行注释不用管。tools/hello脚本内容// 模块hello exports.greet function(name) { return Hello, name !; }; exports.PI 3.14159;然后在另一个脚本main中// 主脚本 var hello require(users/yourname/tools/hello); print(hello.greet(GEE)); // Hello, GEE! print(hello.PI); // 3.14159点击Run控制台输出两条内容就说明模块化调用成功了。如果报错最可能是用户名填错或者文件名没保存。3. 实操案例把Landsat预处理封装成模块3.1 模块设计哪些函数应该抽出来光看语法还不够我以一个典型的Landsat 8预处理场景带你完整走一遍模块设计和调用流程。假设你经常处理Landsat 8集合需要做三件事云掩膜、NDVI计算、以及把DN值缩放到反射率。这三步几乎每个项目都要用但不同项目的细节略有差异。我选择把它们设计成一个模块landsat_utils对外提供三个函数和一个配置常量。设计原则是单一职责每个函数只做一件事方便单独测试。参数灵活预处理函数允许调用方控制是否乘缩放因子避免硬编码。返回类型统一函数返回的都是ee.Image方便在主脚本中继续链式调用。3.2 模块代码实现先新建脚本路径假设为users/yourname/gis/landsat_utils。完整代码如下// 模块landsat_utils // 提供Landsat 8 SR数据的常用预处理函数 // 使用像元质量波段BQA做云掩膜 exports.maskL8Clouds function(image) { var qa image.select(BQA); // Landsat 8 BQA中bit 4和bit 8分别表示云和卷云 var cloudBitMask (1 4); var cirrusBitMask (1 8); var mask qa.bitwiseAnd(cloudBitMask).eq(0) .and(qa.bitwiseAnd(cirrusBitMask).eq(0)); return image.updateMask(mask); }; // 计算NDVI并作为波段加到影像上 exports.computeNDVI function(image) { var ndvi image.normalizedDifference([B5, B4]).rename(NDVI); return image.addBands(ndvi); }; // 一键预处理云掩膜 缩放 NDVI exports.preprocess function(image, scaleFlag) { var masked exports.maskL8Clouds(image); var scaled masked; if (scaleFlag ! false) { scaled scaled.multiply(0.0001).copyProperties(masked, [system:time_start]); } return exports.computeNDVI(scaled); }; // 常用参数配置 exports.NDVI_PALETTE [blue, white, green]; exports.SCALE_FACTOR 0.0001;这个模块有几个值得注意的点exports.maskL8Clouds里用BQA波段判断云和卷云这是史前Landsat 8 Collection 1 SR数据的常见做法。如果你用Collection 2SR数据自带QA_PIXEL波段判断逻辑不同但模块化的思路完全一致。preprocess函数内部直接调用同一个模块里的maskL8Clouds和computeNDVI。因为这些都是挂载在exports上的属性所以内部可以用exports.maskL8Clouds来调用也可以先把函数提取成私有函数再导出。我更推荐定义独立函数然后赋值给exports这样内部调用和使用私有变量一样自然。缩放流程里我用了copyProperties(masked, [system:time_start])如果你调用了multiply()影像属性会丢失所以需要把关键的时间戳属性拷贝回来。这个坑很多人踩过。3.3 主脚本怎么调用这个模块在另一个脚本里你需要先require这个模块然后就能用模块里的函数处理影像集合了。示例var utils require(users/yourname/gis/landsat_utils); var roi /* 定义你的研究区域 */ ee.Geometry.Point([-122.41, 37.77]); var collection ee.ImageCollection(LANDSAT/LC08/C01/T1_SR) .filterBounds(roi) .filterDate(2020-01-01, 2020-12-31) .map(function(img) { return utils.preprocess(img, true); }); var medianNdvi collection.select(NDVI).median().clip(roi); Map.addLayer(medianNdvi, { min: -0.2, max: 0.8, palette: utils.NDVI_PALETTE }, Landsat8 Median NDVI);这里我特别说明一下map函数内部的调用GEE的collection.map会把你提供的客户端函数序列化发送到服务器端在服务器上每个影像对象都会执行一次。因为utils.preprocess是在客户端定义的函数它内部包含的ee.Image操作会被转换为服务端指令。这是GEE支持的一种模式使用模块函数在map里处理每一景影像只要函数返回的是ee.Image即可。3.4 同时导出多个函数和常量的场景实际使用中一个模块往往会同时导出函数和常量。比如我们上面的utils.NDVI_PALETTE就是导出字符串数组。你还可以导出环境配置、波段列表例如exports.L8_OPTICAL_BANDS [B1,B2,B3,B4,B5,B6,B7]; exports.L8_THERMAL_BANDS [B10,B11];调用方可以直接引用这些常量避免魔法数字。如果常量很多你甚至可以写一个config模块专门放所有项目的公共参数。这种方式特别适合团队统一标准。4. 常见问题与排查从“Module not found”到循环依赖4.1 找不到模块路径、权限和命名问题使用require最常见的报错是Exception: Module not found: users/xxx/tools/hello我排查这类问题的顺序一般是先看代码编辑器左侧的脚本列表点击你想引用的脚本确认编辑区顶部显示的正确路径。注意GEE会显示完整路径通常是users/你的用户名/文件夹/文件名复制的时候去掉最前面的users/不require里要保留users/但复制时不要带users/前面的任何前缀。实际上编辑器顶部显示的是完整用户路径直接复制即可。检查文件是否已经保存。如果你新建了一个脚本但还没保存路径可能不生效。检查路径里是否带有.js后缀。很多人习惯写require(.../hello.js)GEE会报找不到必须去掉后缀。检查权限。默认情况下你只能require自己账户下并且你有权限访问的脚本。如果你尝试引用别人的脚本对方必须把脚本共享给你或设为公开否则即使路径正确也会报权限错误。一个快速验证权限的方法在浏览器地址栏输入https://developers.google.com/earth-engine/datasets/...不对测试GEE脚本路径的正确方法是直接尝试打开完整脚本链接。你可以把路径改成https://code.earthengine.google.com/?scriptPathusers/xxx/tools/hello来验证能不能访问。但更稳妥的方法是直接在代码编辑器里让对方把脚本分享链接发给你。4.2 共享与发布让别人也能用你的模块GEE的脚本默认只有你自己可见。如果你要把模块分享给同事需要做两步在代码编辑器左侧的脚本列表中右键点击脚本或文件夹选择“Share...”进入分享对话框。添加对方邮箱或选择“Anyone with this link can view”点击保存。最省事的是设为“Public on the internet”公开这样所有人只要知道路径就能require。如果只需要内部用就只给特定邮箱加权限。分享后对方在脚本里就可以正常require你的模块。这里有个经验不要随意把包含敏感逻辑的脚本设为公开。GEE脚本一旦公开任何知道路径的人都能调用你的函数虽然不能直接看到源码实际上代码编辑器里如果脚本公开用户可以通过脚本链接看到源码。所以请根据内容决定公开范围。4.3 隐藏陷阱缓存、副作用和循环依赖先说缓存。GEE在一次执行周期内对同一个require路径只执行一次模块脚本。这个设计通常很好但如果你在调试中修改了模块需要重新点击“Run”而不是只在主脚本里再次require因为重新Run会重新加载所有模块。再说副作用。我见过有人把Map.addLayer写在模块顶层结果每次require任何函数地图上都会多一层。模块应该像纯函数库一样顶层不执行任何输出和可视化。如果你真想测试模块可以在模块脚本里单独写一个“仅当直接运行该模块时”的测试段。GEE没有if (module main)这样的概念所以最安全的方式是另建一个测试脚本专门require模块并打印结果。最后是循环依赖。比如A脚本require了B脚本B脚本又require了A脚本。GEE可能直接报解析错误或者导致其中一个对象为空。我在团队协作中遇到过一次两个开发者各自封装了工具然后互相引用对方最后谁也加载不出来。解决方法是打破环把公共功能下沉到第三层让A和B都去require那个公共模块。环的存在往往意味着模块边界划分不合理这时候重新梳理模块粒度比强行用“懒加载”更重要。4.4 调试技巧确认模块里到底导出了什么有时候你出于习惯复制了一个模块路径但不确定里面有哪些导出项。可以在任何一个临时脚本里这样打印var mod require(users/yourname/tools/hello); print(Object.keys(mod));然后把模块变量打印出来查看它的键列表。如果打印出来是空的说明模块里没有用exports挂载任何内容。这个方法能快速判断是路径问题还是导出语法问题。另外如果模块内部报错GEE控制台会显示出错的模块路径和行号但有时行号对应的是压缩后的代码。建议在模块内部添加关键位置的print或throw缩小问题范围调试完删除。5. 进阶经验面向团队的模块组织与小技巧5.1 用文件夹搭建工具库当模块数量超过五六个就要开始规划目录结构了。我的习惯是顶层建一个modules文件夹专门放可复用的工具脚本。下面按主题分子文件夹比如modules/landsat、modules/sentinel2、modules/geometry。每个模块文件控制在100行以内如果超过考虑是否该拆成更细粒度的模块。例如modules/ landsat/ cloudmask // Landsat云掩膜函数 indices // NDVI、NDWI等指数 sentinel2/ cloudmask utils/ dateRange clipToRegion主脚本只负责流程编排和业务逻辑所有具体算法都从require获取。这样文件树就是模块地图新同事接手项目时看目录就能明白项目里有哪些能力。5.2 为模块写“测试脚本”GEE没有内置的单测框架但不妨碍我们给自己写一个轻量的测试脚本。做法如下建一个tests文件夹里面每个测试脚本对应一个模块。测试脚本里require模块输入已知数据打印输出并用assert风格表达式判断是否符合预期。举一个简单例子为calc模块写测试var calc require(users/yourname/tools/calc); var assert { isTrue: function(condition, msg) { if (!condition) { throw new Error(msg || Assertion failed); } } }; assert.isTrue(calc.sum(2, 3) 5, sum failed); assert.isTrue(calc.double(21) 42, double failed); print(All tests passed);手动点击“Run”这个测试脚本如果控制台出现“All tests passed”就说明模块通过测试。以后修改模块代码后再跑一遍能有效防止回归问题。5.3 版本化与变更日志GEE的代码编辑器支持通过Git仓库同步脚本。如果你用的是标准仓库模式每次修改模块都能提交commit回滚也有据可查。我强烈建议在模块文件头部写一个变更记录注释// 模块landsat_utils // 变更记录 // 2024-05-01增加preprocess函数统一缩放逻辑 // 2024-04-20修复BQA云掩膜判断这样即使多人维护也能快速定位改动历史。当然更严谨的方法是配合Git commit message记录但脚本内注释对阅读者更友好。5.4 多项目复用把模块仓库当“内部软件包”当模块库稳定后你会在不同项目里反复require同一批脚本。这时可以把脚本库看成你自己维护的“软件包”只通过exports暴露稳定的接口内部实现随便改。只要接口不破坏所有调用方都不用迁代码。一旦接口要变更先通知所有使用者或者使用带版本后缀的路径例如users/yourname/modules/landsat_utils_v2老项目继续用v1新项目用v2。这种粗糙但有效的版本方案确实避免过不少意外事故。结尾一点个人经验我实际用下来exports和require最舒服的使用方式不是把所有代码塞进一个模块而是很果断地拆分。刚开始时我把自己常用的十几个函数全部塞在utils脚本里结果这个文件越来越大最后连找一个函数都要滚动好几屏。后来我把它们拆成按主题的小模块每个文件只做一件事再配合测试脚本维护成本显著下降。如果你已经发现脚本里重复代码越来越多或者团队里总有人问“这个工具函数在你哪个脚本里”那就是把模块化真正用起来的时候。从一个小工具模块开始很快就回不去了。
阅读完成 · 觉得有帮助?
咨询建站