资讯详情

资讯详情

Java读取JAR资源报URI is not hierarchical:成因、方案与部署坑

上个月帮同事排查一个线上问题日志里只有一行java.lang.IllegalArgumentException: URI is not hierarchical堆栈指向一段读取配置文件的代码。这段代码在他本机 IDE 里跑得好好的打成 JAR 部署到服务器就炸了。这其实是个老得不能再老的问题但只要项目里还在用URL → URI → File这条链路去读 JAR 内部资源它就一定会再出现一次。URI is not hierarchical这个错误本质上是 JAR 里资源的定位方式和普通文件系统不一样而很多人下意识地用File去接接不住就抛异常。这篇内容会把报错成因、五种读取方案的选型逻辑、可以直接抄的完整代码、以及打包部署阶段那些隐蔽的坑一次性讲透。适合已经写过 Java 但没深究过类路径资源加载的开发者也适合正在排查“本地正常、上线报错”这类问题的同行。1. 先把报错现场还原清楚1.1 一行异常背后的三种典型触发姿势同样一行URI is not hierarchical代码写法其实有好几种触发点也不完全一样。第一种是最常见的getClass().getResource(/config/app.properties)拿到URL然后new File(url.toURI())。这时候url.toURI()得到的是jar:file:/opt/app/app.jar!/config/app.properties这样的 URInew File(URI)进去第一件事就是检查uri.isOpaque()结果返回true直接抛出这个异常。第二种稍微隐蔽一点是Paths.get(url.toURI())。这个不会抛URI is not hierarchical而是抛FileSystemNotFoundException因为 NIO 的Paths只认已注册的FileSystemProviderjar协议的 provider 默认没有注册。很多人看到这个新异常会以为是另一回事其实根子是同一个——JAR 里的资源不是一个“文件系统路径”。第三种是相对路径拼出来的 URI比如URI.create(file:./conf/a.txt)。这种 URI 的 scheme-specific part 不以/开头同样是 opaque 的喂给new File(URI)一样会炸。所以别以为只有 JAR 场景才有这个坑手写相对 URI 一样中招。注意new File(URI)的异常行号在不同 JDK 版本里不一样JDK 8 大概在File.java:418附近JDK 17 又换了个位置。盯着行号找原因没有意义直接看异常信息里的URI is not hierarchical就够了。还有一种情况容易被误判代码里写的是new File(url.getPath())这行不抛异常但拿到的路径是/opt/app/app.jar!/config/app.propertiesfile.exists()返回false读出来是空的或者FileNotFoundException。表面现象完全不同但病因一致。我在实际排查中遇到过好几次同事坚称“我没用new File(URI)啊”结果一看是getPath()的锅。1.2 JAR 内资源 URL 的真实长相要理解为什么接不住得先看清楚 JAR 内资源的 URL 到底长什么样。当资源在target/classes目录里时getResource返回的是file:/Users/demo/project/target/classes/config/app.propertiesscheme 是file后面是真实的文件系统路径。这种 URL 转URI再转File完全没问题所以 IDE 里从不报错。一旦打成 JAR同一个资源返回的 URL 变成了jar:file:/opt/app/app.jar!/config/app.properties注意三个关键点。一是 scheme 从file变成了jar二是路径中间多了!/这个分隔符它左边是 JAR 文件本身的位置右边是 JAR 内部的条目路径三是整个 URI 的 scheme-specific part 不以/开头jar:后面直接跟file:这就决定了它是 opaque 的。!/这个分隔符是java.util.jar.JarURLConnection里约定的不是标准 URI 规范的一部分。它带来的直接后果是任何按 POSIX 路径语义处理的工具都看不懂这个字符串。File看不懂Paths.get看不懂很多第三方库的路径解析也看不懂。1.3 opaque URIFile 构造函数拒绝它的真正理由URI里有个概念叫 opaque直译是“不透明的”。判定规则很简单如果 scheme-specific part 不以斜杠/开头这个 URI 就是 opaque 的。jar:file:/x.jar!/y.txt的 scheme-specific part 是file:/x.jar!/y.txt开头是f不是/所以 opaque。而file:/opt/app/x.txt的 scheme-specific part 是/opt/app/x.txt以/开头是 hierarchical 的。File这个类从头到尾只表达一件事本地文件系统里的一个节点。它内部持有的是一个规范化后的路径字符串所有方法exists、isDirectory、listFiles最终都落到操作系统的路径 API 上。一个不透明的 URI 无法拆解成“父目录 文件名”这种层级结构File自然接不住所以构造函数里直接拒绝。用个生活化的类比File像一个快递员只认“某条街某栋楼某层某户”这种地址。JAR 里的资源地址是“先找到某个集装箱再进去第几排第几号”快递员没有进集装箱的能力所以他拒收。要取里面的东西得换一个懂集装箱的人——要么直接从集装箱门口递出来流式读取要么把集装箱当一个小型仓库挂载起来FileSystem。理解了这层后面所有方案的选择就顺理成章了要么绕开File要么想办法把 JAR 挂载成一个临时的文件系统视图。2. 五条读取路径的选型逻辑2.1 横向对比什么场景该用哪一种我把实际项目中常用的五种方式列成一张表这张表基本能覆盖 95% 的场景。选型的时候先看“是否需要文件名列表”和“是否需要 File 对象”这两个维度答案基本就出来了。方案核心 API能读单文件能列目录是否需要 File 对象适用场景流式读取getResourceAsStream是否否读配置、模板、证书等已知路径资源ClassPathResource容器框架提供的 Resource 抽象是否否已有容器框架的项目需要统一资源抽象通配符扫描ResourcePatternResolver是是否批量加载映射文件、脚本、插件FileSystemFileSystems.newFileSystem是是可转需要按路径操作、需要文件属性JarFile 遍历JarFileJarEntry是是否纯 JDK 环境、需要精确控制条目表格里“是否需要 File 对象”这一列很关键。很多第三方组件比如某些解析库、某些模板引擎的旧版本只接受File或Path参数这种情况下前三种方案都用不了必须走 FileSystem 或者临时文件解压。我个人的默认选择顺序是能流式就读流式需要列目录就上 FileSystem纯 JDK 又不想引入 NIO 依赖就手工遍历JarFile实在绕不开File参数才落临时文件。临时文件是最后的兜底手段因为它引入了磁盘 IO、清理责任和并发写冲突三个新问题能不用就不用。2.2 为什么流式读取是默认答案getResourceAsStream之所以应该是默认答案原因有三层。第一层是它天然屏蔽了资源位置的差异——不管资源在target/classes目录里、在 JAR 包里、在嵌套 JAR 里甚至在一个自定义的ClassLoader里只要类加载器能找到流就能拿到。你在 IDE 里写的代码和线上跑的代码完全一致不需要任何分支判断。第二层是它不持有文件句柄的长期引用。File对象持有路径路径背后的文件可能被删除、被替换而流是一次性的快照读完就结束不存在“读的过程中文件被改”的问题。对于配置这类启动时读一次的资源这个特性很合适。第三层是资源释放的责任很清晰。InputStream用 try-with-resources 包起来就行不存在“忘记关”的隐患。相比之下FileSystem是重量级对象打开和关闭的成本都高还要考虑重复打开的异常。这里有个容易忽略的细节Class.getResourceAsStream和ClassLoader.getResourceAsStream的路径语义不一样。前者带前导斜杠时从根开始找不带斜杠时从当前类所在包开始找后者永远从根开始找而且不能带前导斜杠。这个差异导致很多“本地能读到、线上读不到”的诡异现象后面排查章节会展开讲。2.3 FileSystem 方案的适用边界与版本差异FileSystems.newFileSystem是 Java 7 引入的 NIO 能力它把 JAR 包挂载成一个虚拟文件系统之后Path、Files.walk、Files.list全都能用用起来跟操作普通目录几乎一样。这个方案的适用边界很明确你需要枚举目录内容或者需要Path对象传给某个只接受Path的 API。版本差异是最大的坑。Java 8 里只有FileSystems.newFileSystem(URI, Map)这个重载而且传进去的 URI 必须是jar:file:/path/app.jar这种形式。而getProtectionDomain().getCodeSource().getLocation()返回的是file:/path/app.jarscheme 是file直接传进去会报ProviderNotFoundException。必须手工拼一个jar:前缀代码如下URI location MyApp.class.getProtectionDomain() .getCodeSource().getLocation().toURI(); URI jarUri URI.create(jar: location.toString()); try (FileSystem fs FileSystems.newFileSystem(jarUri, Collections.emptyMap())) { Path root fs.getPath(/config); // 后续操作 }Java 13 之后新增了FileSystems.newFileSystem(Path, Map)重载可以直接传Path省掉了拼 URI 的步骤也不再依赖!/分隔符。如果你的项目基线是 JDK 17直接用新重载更省事Path jarPath Paths.get(MyApp.class.getProtectionDomain() .getCodeSource().getLocation().toURI()); try (FileSystem fs FileSystems.newFileSystem(jarPath, Map.of())) { // 后续操作 }还有一个必须记住的点同一个 JAR 的FileSystem在同一时刻只能存在一个实例重复调用newFileSystem会抛FileSystemAlreadyExistsException。在多线程环境下扫描资源的代码如果没做去重很容易撞上这个异常。3. 完整实操从单文件到目录扫描3.1 基础实现流式读取与编码处理先给一个可以直接抄的基础版本。这个工具方法解决了三个问题路径前导斜杠的兼容、资源不存在的显式报错、编码的强制指定。import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; public final class ClasspathReader { private ClasspathReader() { } public static String readText(String path) { String normalized path.startsWith(/) ? path.substring(1) : path; ClassLoader loader Thread.currentThread().getContextClassLoader(); if (loader null) { loader ClasspathReader.class.getClassLoader(); } try (InputStream in loader.getResourceAsStream(normalized)) { if (in null) { throw new IllegalStateException(类路径下找不到资源: normalized); } return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException(读取资源失败: normalized, e); } } }几个细节值得说明。统一走Thread.currentThread().getContextClassLoader()而不是Class.getClassLoader()是因为在线程池和自定义类加载器环境下上下文类加载器更能反映“当前业务视角”的资源位置这在多模块和插件化项目里差别很明显。当然如果你的项目就是普通单体应用两者效果一样。readAllBytes()是 JDK 9 才有的JDK 8 项目里要换成循环读或者ByteArrayOutputStream。我一般会封装一个readAll(InputStream)私有方法这样升级 JDK 时只改一处。编码必须显式指定UTF_8。new String(bytes)走的是平台默认编码在 Windows 开发机上是 GBK在 Linux 服务器上是 UTF-8同一份中文配置本地读出来正常、线上全是乱码。这个坑我见过太多次了。提示如果读的是.properties文件注意Properties.load(InputStream)按 ISO-8859-1 解析中文会乱。要么用load(Reader)并显式传InputStreamReader(in, StandardCharsets.UTF_8)要么在 JDK 9 用load配合 Unicode 转义。这是另一个独立于URI is not hierarchical的经典坑。3.2 目录感知FileSystem 遍历 JAR 内路径当需求从“读一个文件”变成“读某个目录下所有文件”时流式方案就不够了getResourceAsStream传目录名大概率返回null。这时候得上 FileSystem。下面这段代码兼容了“IDE 里跑资源是目录”和“JAR 里跑资源在包内”两种形态是我实际项目里用了很久的版本。import java.io.IOException; import java.net.URI; import java.nio.file.*; import java.util.*; import java.util.stream.Stream; public final class ResourceScanner { public static ListString listNames(String dirInJar) throws Exception { URI location ResourceScanner.class.getProtectionDomain() .getCodeSource().getLocation().toURI(); Path self Paths.get(location); if (Files.isDirectory(self)) { // IDE 或解压后的目录形态 Path dir self.resolve(dirInJar); if (!Files.isDirectory(dir)) { return Collections.emptyList(); } try (StreamPath s Files.list(dir)) { return s.filter(Files::isRegularFile) .map(p - p.getFileName().toString()) .sorted() .toList(); } } // JAR 形态 URI jarUri URI.create(jar: location.toString()); try (FileSystem fs FileSystems.newFileSystem(jarUri, Map.of())) { Path dir fs.getPath(/ dirInJar); if (!Files.isDirectory(dir)) { return Collections.emptyList(); } try (StreamPath s Files.list(dir)) { return s.filter(Files::isRegularFile) .map(p - p.getFileName().toString()) .sorted() .toList(); } } catch (FileSystemAlreadyExistsException e) { FileSystem fs FileSystems.getFileSystem(jarUri); Path dir fs.getPath(/ dirInJar); try (StreamPath s Files.list(dir)) { return s.filter(Files::isRegularFile) .map(p - p.getFileName().toString()) .sorted() .toList(); } } } }这段代码里有几个决策点值得展开。第一先判断location是目录还是文件这解决了“本地开发和线上部署不一致”的根本问题。很多团队的做法是写死一个isRunningInJar的开关靠系统属性或者环境变量传这种做法非常脆弱一旦部署方式变了就失效。用Files.isDirectory(self)判断是自适应的不需要任何外部配置。第二fs.getPath(/ dirInJar)里的前导斜杠不能少。FileSystem 的根是 JAR 包的根getPath(config)得到的是相对路径虽然在某些实现里也能用但语义不清晰容易在Files.walk时出问题。第三FileSystemAlreadyExistsException的兜底分支是必需的。如果你的应用里有多个地方扫描资源或者框架本身已经打开过这个 JAR 的 FileSystem直接newFileSystem就会炸。兜底里用FileSystems.getFileSystem(jarUri)拿已有实例注意不要在这个分支里关闭它因为不是你打开的。第四遍历时用Files.list而不是Files.walk。list只扫一层walk递归扫全部后者在资源目录很深或者 JAR 里条目很多的时候会明显变慢。如果确实需要递归记得用 try-with-resources 包住返回的 Stream否则会泄漏目录句柄。3.3 纯 JDK 兜底JarFile 加 JarEntry 手工遍历有些环境不允许你用 NIO FileSystem比如 Android 低版本、某些精简的运行时、或者你就是要兼容 JDK 7 以下的遗留系统。这时候最原始的方式反而最可靠直接用JarFile打开包遍历JarEntry。import java.io.InputStream; import java.util.jar.JarEntry; import java.util.jar.JarFile; public final class JarEntryWalker { public static void walk(String jarPath, String prefix) throws Exception { try (JarFile jar new JarFile(jarPath)) { var entries jar.entries(); while (entries.hasMoreElements()) { JarEntry entry entries.nextElement(); String name entry.getName(); if (!name.startsWith(prefix) || entry.isDirectory()) { continue; } try (InputStream in jar.getInputStream(entry)) { // 按需处理 } } } } }这里有三个必须知道的细节。JarEntry.getName()返回的路径永远用/分隔即使在 Windows 上打包也一样不要想着替换成\。entry.isDirectory()的判断依据是名字以/结尾而不是文件系统属性所以它只对规范打包的 JAR 有效。最关键的坑是很多打包工具根本不写目录条目。也就是说一个 JAR 里可能只有config/a.txt和config/b.txt两个条目没有config/这个目录条目。你如果先找config/这个 entry 再遍历会一无所获。所以判断“某路径下有没有文件”只能用startsWith前缀匹配不能依赖目录条目存在与否。这个特性也解释了为什么getResourceAsStream(config)在 JAR 里经常返回null——它找的是一个名为config的条目而实际存在的只有config/xxx这样的条目。目录在 JAR 里是个逻辑概念不是实体。3.4 一套兼容 IDE 与 JAR 运行的双模式封装把上面几种方案揉成一个统一的工具类是我在多个项目里反复重写的东西。核心思路是对外只暴露“读文本”“读字节”“列文件名”三个方法内部自动判断运行形态。这样业务代码里看不到任何jar:、!/、FileSystem的痕迹迁移和测试都方便。public final class ResourceKit { public static boolean isRunningInJar(Class? anchor) { try { URI uri anchor.getProtectionDomain().getCodeSource().getLocation().toURI(); return !Files.isDirectory(Paths.get(uri)); } catch (Exception e) { return false; } } public static byte[] readBytes(String path) { String normalized path.startsWith(/) ? path.substring(1) : path; try (InputStream in ResourceKit.class.getClassLoader() .getResourceAsStream(normalized)) { if (in null) { throw new IllegalStateException(资源不存在: normalized); } return in.readAllBytes(); } catch (IOException e) { throw new UncheckedIOException(e); } } }isRunningInJar这个方法看着简单作用是让上层代码在极少数必须分支的场景下有据可依。比如某些日志初始化逻辑需要在启动早期判断资源位置这时候类加载器可能还没完全就绪靠ProtectionDomain判断比靠getResource返回值的类型判断更稳。有一点要提醒getProtectionDomain().getCodeSource()在某些安全管理器配置下会返回null或者抛SecurityException。虽然现在很少有项目开安全管理器但如果你的代码要跑在受限环境里这个方法得加判空。我一般会再兜一层取不到就退化成“按目录处理”至少不会崩。4. 打包与环境差异带来的额外坑4.1 嵌套 JAR 下的路径变形现代 Java 应用很少打成一个扁平的 JAR 了更多是把依赖一起打进去形成“胖包”。这种包的结构跟普通 JAR 完全不同业务类在BOOT-INF/classes/下依赖 JAR 在BOOT-INF/lib/下。这时候资源 URL 会变成这样jar:file:/opt/app/app.jar!/BOOT-INF/classes!/config/app.properties注意出现了两个!/。第一个!/后面是胖包内部的虚拟根第二个!/表示进入了嵌套的classes目录。这种 URL 用前面说的URI.create(jar: location)拼出来FileSystems.newFileSystem是不认的因为标准的 zip 文件系统提供者只支持一层。容器框架对此做了专门处理它自己实现了一套Handler来解析这种多层嵌套的 URL所以用框架提供的Resource抽象读资源是没问题的用原生FileSystems就会失败。这就是为什么“同样一段代码放在普通 JAR 里能跑放在胖包里就炸”。处理办法有两个。一是改用框架提供的资源抽象不要自己拼 URI。二是如果一定要用 NIO就先把需要的资源在启动阶段解压到临时目录。第二种做法我在处理“需要把整个资源目录交给某个扫描器”的场景时用过效果稳定代价是启动多花几百毫秒。4.2 资源根本没进包的三类情况排查“读不到资源”的问题时有一半情况根本不是URI is not hierarchical而是资源压根没被打进 JAR。这个必须先排除否则你在代码里改半天也没用。第一类是构建工具的默认过滤规则。标准的目录布局里src/main/java下的非.java文件默认不会被复制到输出目录必须显式配置资源目录包含规则。很多人把.xml映射文件、.ftl模板放在 Java 包路径下本地 IDE 因为直接读源码目录所以能读到打包后就丢了。第二类是文件名大小写和特殊字符。Linux 文件系统大小写敏感Windows 不敏感本地写Config.xml引用config.xml在 Windows 上能跑到 Linux 上就找不到。文件名里带空格或者中文更麻烦URL 里会被编码成%20和%E4%B8%AD这种形式URLDecoder.decode之前拿到的字符串没法直接用。第三类是构建产物被清理或者覆盖。增量构建有时候不会清空旧的输出目录导致你看到的资源和实际打进包的资源不一致。遇到“明明改了配置但线上没生效”的情况先clean一次再打包能省掉大量无效排查。验证方法很简单用jar tf app.jar | grep 资源名直接看包里有没有。我现在排查任何资源问题第一步都是这条命令比看代码快得多。4.3 第三方组件只认 File 时的临时文件策略前面说过有些组件的方法签名就是setTemplate(File file)或者new Scanner(Path path)你没法把InputStream塞进去。这时候唯一的办法是把资源落到临时文件。落临时文件有几个讲究。文件要建在系统临时目录用Files.createTempFile而不是自己拼路径避免权限问题。文件名后缀要和原资源一致因为有些组件靠后缀判断格式。用完要删最稳妥的是 try-finally 或者注册deleteOnExit双保险。并发场景下每次调用都要建独立文件不能共用一个固定路径否则会读到别人写了一半的内容。Path tmp Files.createTempFile(resource-, .xlsx); try { try (InputStream in ResourceKit.class.getClassLoader() .getResourceAsStream(templates/report.xlsx)) { if (in null) { throw new IllegalStateException(模板缺失); } Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); } // 把 tmp 交给只认 File 的组件 } finally { Files.deleteIfExists(tmp); }这套写法的隐患是磁盘 IO 开销和临时目录空间。如果资源文件很大比如几十兆的模型文件每次调用都复制一遍会很慢。改进方式是加一层缓存按资源路径维护一个MapString, Path首次复制后复用配合一个关闭钩子统一清理。我在一个需要反复加载模板的项目里这么干过启动阶段的耗时有明显下降。5. 排查手册高频问题与定位顺序5.1 报错与现象对照表把实际遇到过的问题整理成一张速查表遇到类似现象先对号入座能省下不少时间。异常或现象根本原因处理方向URI is not hierarchicalnew File(URI)收到 opaque URI改用流式读取或走 FileSystemURI scheme is not fileJAR 内资源 URL 直接转 File同上先判断 schemeFileSystemNotFoundExceptionPaths.get(URI)未注册 jar provider用FileSystems.newFileSystem显式打开FileSystemAlreadyExistsException同一 JAR 重复打开文件系统改用getFileSystem取已有实例getResourceAsStream返回 null前导斜杠用错、资源未打包、大小写不符用jar tf验证包内条目读到乱码未指定编码走了平台默认显式传StandardCharsets.UTF_8本地正常上线失败IDE 读目录、线上读 JAR路径语义不同加运行形态判断分支目录遍历结果为空JAR 无目录条目或前缀匹配写错用startsWith前缀匹配而非找目录条目这张表里前四行都跟URI和File的转换有关本质是同一个问题的不同表现。后面四行是相关但独立的问题排查时容易混淆需要分开验证。5.2 四步定位法我排查这类问题的顺序基本固定四步走完九成情况能有结论。第一步确认资源到底在哪。用jar tf列出包内条目或者用getResource打印出实际的 URL 字符串。这一步的目的是排除“资源没打进去”和“路径写错”这两个最基础的原因。打印 URL 的代码如下加上之后一眼就能看出 scheme 是file还是jarURL url ResourceKit.class.getResource(/config/app.properties); System.out.println(scheme url.getProtocol() path url.getPath());第二步确认运行形态。看getProtectionDomain().getCodeSource().getLocation()返回的是目录还是文件。这一步决定了你的代码该走哪条分支也是很多“本地好线上坏”问题的分水岭。第三步确认 API 用法。如果确定在 JAR 里运行检查代码里有没有new File(uri)、Paths.get(uri)、new FileInputStream(path)这类只认本地文件系统的调用。有的话全部换掉。第四步确认依赖层次。如果用的是胖包检查有没有嵌套 JAR 导致的路径变形。这时候原生 NIO 方案基本都要放弃改用框架提供的抽象或者临时文件方案。这四步的顺序不能乱。我见过有人一上来就怀疑嵌套 JAR折腾半天最后发现是资源文件根本没被打包进去白白浪费两个小时。5.3 几条踩出来的经验第一条永远不要在业务代码里直接写new File(url.getPath())。这行代码不报错但会给你一个看起来很像路径、实际永远打不开的字符串。它的危害比直接抛异常大得多因为异常能立刻暴露问题而它会把问题拖到运行期才以“文件不存在”的形式出现。团队里做代码评审时这条应该列为必查项。第二条判断“是否在 JAR 中运行”不要靠猜。有些老代码用System.getProperty(java.class.path).contains(.jar)来判断这在多模块和容器环境下完全不可靠。用ProtectionDomain或者直接看getResource返回的 URL scheme都是更准确的方式。第三条资源读取工具类一定要在项目早期就统一。我参与过的一个项目同一个“读配置文件”的需求在七个地方有七种写法其中三种会在打 JAR 后失败但只有两种被触发过剩下一种埋了半年才炸。统一封装之后这类问题基本绝迹。第四条单元测试要覆盖“从 JAR 读”的场景。普通的Test跑在target/test-classes目录里永远测不出 JAR 相关的问题。比较实际的验证方式是在构建流程里加一个冒烟测试把包打出来用java -jar启动一次跑一个最小资源读取路径。这一步能提前拦住大部分部署阶段的问题。第五条遇到URI is not hierarchical不要急着搜解决方案先搞清楚你的资源 URL 是什么样。同一条报错在普通 JAR、胖包、自定义类加载器三种场景下的处理方式完全不同直接抄网上的代码经常会抄到一个只适用于普通 JAR 的版本然后在你的胖包里继续报错。我自己的做法是把ResourceKit这类工具类放在一个公共基础模块里所有业务模块依赖它读取资源只能走这三个方法。代码量不大但确实把一整类问题挡在了架构层面。踩过的坑多了之后会发现很多问题的根子不在“这段代码怎么写”而在“为什么这个项目里允许出现十种读文件的方式”。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →