news 2026/6/22 20:38:00

Java调用Google搜索的轻量级HTTP实现方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java调用Google搜索的轻量级HTTP实现方案

1. 项目概述:为什么要在Java里调用Google搜索?这真不是“重复造轮子”

“Google Search from Java Program Example”——看到这个标题,很多刚接触网络编程的Java新手第一反应是:“浏览器点几下就出来的结果,为啥非得写代码去拿?”我带过不少实习生,也审过上百份校招简历,发现这个问题背后藏着一个关键认知偏差:大家把“调用Google搜索”简单等同于“模拟人工点开网页”,而忽略了它在真实工程场景中承担的底层角色。它从来不是为了替代浏览器,而是作为数据采集链路的第一环竞品监控系统的触发器知识图谱构建的原始入口,甚至在某些合规场景下,是唯一被允许的轻量级外部信息接入方式。核心关键词“Google Search”“Java”“HTTP GET”“Jsoup”“User-Agent”已经勾勒出技术栈轮廓:这不是一个Spring Boot微服务,而是一段极简、可控、可嵌入任意Java进程的HTTP客户端逻辑。它解决的不是“能不能搜”,而是“如何在不触发反爬、不依赖浏览器渲染、不引入重量级框架的前提下,稳定获取结构化搜索结果片段”。适合谁?Java后端工程师做内部工具时快速验证数据源;爬虫初学者理解HTTP协议与HTML解析的最小闭环;面试官考察候选人对基础网络IO、异常处理、请求头设计的真实掌握程度——注意,这里说的“面试题”不是八股文背诵,而是现场手写一段能跑通、能debug、能解释每行意图的代码。我去年帮一家做跨境电商选品的团队重构搜索采集模块,就是从这段50行以内的Java示例出发,最终支撑起日均30万次关键词扫描。它小,但它是整个数据管道的水龙头。

2. 整体设计思路:为什么放弃Selenium,死磕HTTP+Jsoup?

2.1 核心矛盾:功能需求 vs 工程约束

先说结论:这个项目必须绕开所有基于浏览器自动化的方案(如Selenium、Puppeteer),直接走纯HTTP协议层。原因很现实——不是技术不行,而是成本太高。我试过用Selenium驱动Chrome去抓Google首页,启动一个浏览器实例平均耗时2.3秒,内存占用峰值480MB,而同等条件下,一个HttpURLConnection对象创建加发起GET请求,耗时37ms,内存占用不到2MB。更致命的是稳定性:Selenium在无头模式下遇到Google的JS挑战(比如reCAPTCHA v3的隐式检测)会直接卡死,而纯HTTP请求只要User-Agent和请求头设计得当,95%以上的常规搜索词都能拿到有效HTML。这背后是Google搜索接口的分层设计逻辑:它对外暴露的/search路径本质是一个服务端渲染(SSR)接口,前端JS只负责增强交互(如联想词、无限滚动),核心搜索结果DOM在首次HTTP响应中已完整存在。所以我们的策略是——精准截取服务端渲染的原始HTML,跳过所有客户端JS执行环节

2.2 技术选型三原则:轻、稳、可解释

  • 轻量化优先:不引入Spring WebClient或Apache HttpClient这类重型客户端。JDK原生的HttpURLConnection完全够用,零依赖,避免java: you aren't using a compiler supported by lombok这类编译期冲突。Gradle里只需一行implementation 'org.jsoup:jsoup:1.17.2',比配置Lombok注解处理器还简单。

  • 稳定性锚点:所有请求必须携带合法的User-Agent,且不能是默认值。Google对Java/1.8.0这类UA会直接返回403。实测下来,伪装成主流浏览器的移动UA最稳,比如Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36。这不是随便抄的,而是从Android WebView的UA字符串库中筛选出的、在Google搜索接口上通过率最高的几个变体之一。

  • 可解释性底线:解析层必须用Jsoup而非正则表达式。有同事曾用Pattern.compile("<h3.*?>(.*?)</h3>")提取标题,结果在Google更新DOM结构后全盘崩溃。Jsoup的CSS选择器document.select("div.g > div > div > div > div > div > div > div > div > h3")虽然长,但语义清晰——它明确指向“搜索结果区块(.g)内嵌套层级最深的h3标签”,即使Google微调class名,只要DOM层级关系不变,选择器依然有效。这才是工程上可持续维护的方案。

2.3 架构图景:从请求到结果的四步原子操作

整个流程被拆解为四个不可分割的原子步骤,任何一步失败都需独立处理:

  1. 请求构造:拼接URL参数(q=关键词、hl=zh-CN、num=10)、设置连接超时(≤5秒)、注入User-Agent;
  2. 响应获取:捕获HTTP状态码,对302重定向手动跟进(Google常将搜索请求重定向到带/url?参数的中间页);
  3. HTML解析:用Jsoup加载响应体,过滤script/style标签,保留纯净文本结构;
  4. 结果抽取:按预设CSS选择器提取标题、链接、摘要,封装为POJO列表。

这四步没有“魔法”,每一步的输入输出都可打印、可断点、可单元测试。去年有位面试者现场写这段代码,卡在第三步Jsoup解析时报NullPointerException,我让他打印response.body(),发现是Google返回了403页面——问题根源立刻定位到User-Agent未设置。这种可调试性,正是轻量级方案的核心价值。

3. 核心细节解析:User-Agent不是“填个字符串”,而是信任凭证

3.1 User-Agent的深层作用机制

很多人以为User-Agent只是告诉服务器“我是谁”,其实它在Google的风控体系里扮演着设备指纹的初级标识符。当你发送一个User-Agent: Java/1.1的请求,Google的边缘节点会立即标记该IP为“非浏览器流量”,后续请求可能被限速或加入挑战队列。真正的User-Agent必须包含三个可信要素:

  • 平台标识Windows NT 10.0Macintosh; Intel Mac OS X 10_15_7Linux; Android 12
  • 渲染引擎AppleWebKit/537.36(Chrome/Safari/Firefox通用);
  • 浏览器标识Chrome/115.0.0.0Firefox/116.0,版本号需接近当前主流发布版。

我整理了一份经过实测的UA清单,按成功率排序(测试环境:AWS EC2东京区,连续1000次请求):

UA字符串成功率关键特征
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.3698.2%桌面端最稳,但易被识别为自动化
Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.3696.7%移动端UA,Google对移动搜索宽容度更高
Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.194.1%iOS UA,适合需要模拟真实用户场景

提示:绝对不要用curl/7.68.0PostmanRuntime/7.29.0这类工具UA,Google的WAF规则库已将它们列入高风险名单。

3.2 HTTP GET参数的隐藏陷阱

Google搜索URL看似简单:https://www.google.com/search?q=java+http+get,但实际生效的参数远不止q。必须显式声明以下参数才能获得稳定结果:

  • hl=zh-CN:强制界面语言为简体中文,避免因IP地理位置导致返回英文结果;
  • gl=cn:指定地理区域为中国,影响搜索结果排序(如本地商家优先);
  • num=10:单页返回结果数,最大值为100,但设为100时Google会显著增加反爬强度;
  • safe=off:关闭安全搜索,否则含技术术语的查询(如java outofmemoryerror)可能被过滤。

我曾遇到一个诡异问题:同一段代码在公司内网能正常获取结果,在家用宽带却总返回空列表。抓包对比发现,内网DNS将www.google.com解析为香港节点(IP段142.250.189.),而家用宽带解析为东京节点(IP段142.250.191.)。东京节点对未带gl=cn参数的请求默认返回日本地区结果,且中文内容极少。加上gl=cn后问题消失——这说明参数不仅是功能开关,更是地域路由的钥匙。

3.3 Jsoup解析的DOM结构适配策略

Google的搜索结果DOM结构并非一成不变。2023年Q4他们将主结果容器从<div class="g">升级为<div class="tF2Cxc">,导致大量旧代码失效。我们的应对策略是双选择器兜底

Elements results = doc.select("div.g, div.tF2Cxc"); if (results.isEmpty()) { // 降级尝试:匹配更宽泛的容器 results = doc.select("div[data-sncf]"); }

同时,标题提取不再依赖单一h3标签,而是组合判断:

for (Element result : results) { Element titleEl = result.selectFirst("h3, .LC20lb"); // 兼容新旧class名 String title = titleEl != null ? titleEl.text() : ""; Element linkEl = result.selectFirst("a[href^='https://']"); String url = linkEl != null ? linkEl.attr("href") : ""; Element descEl = result.selectFirst("div.VwiC3b, .IsZvec"); // 摘要区域class名迭代 String desc = descEl != null ? descEl.text() : ""; }

这种“宽匹配+窄校验”的模式,让代码在Google DOM微调时仍能保持80%以上可用性,比硬编码选择器可靠得多。

4. 实操过程:从零开始写一个可运行的Google搜索Java类

4.1 环境准备与依赖配置

首先确认JDK版本。这段代码在JDK 11+上运行最稳定,因为低版本(如JDK 8)的HttpURLConnection对HTTPS证书链验证更严格,容易触发javax.net.ssl.SSLHandshakeException。如果你必须用JDK 8,需额外添加Bouncy Castle Provider,但这是另一个复杂话题,此处不展开。

Gradle配置极其精简(build.gradle):

plugins { id 'java' } repositories { mavenCentral() } dependencies { implementation 'org.jsoup:jsoup:1.17.2' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' } java { toolchain { languageVersion = JavaLanguageVersion.of(11) } }

注意jsoup gradle这个热词,很多人搜到错误的依赖写法(如compile 'org.jsoup:jsoup:1.13.1'),导致Gradle同步失败。1.17.2是目前兼容性最好的版本,修复了对HTTP/2响应头的解析bug。

4.2 核心代码实现:逐行解读关键逻辑

下面是一段经过生产环境验证的完整代码(GoogleSearcher.java),我将逐行解释其设计意图:

import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; public class GoogleSearcher { // 1. 预设User-Agent池,避免单UA被封 private static final String[] USER_AGENTS = { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36" }; // 2. 构造函数注入可配置参数 private final String baseUrl = "https://www.google.com/search"; private final int timeoutMs = 5000; private final int maxResults = 10; public List<SearchResult> search(String keyword) throws IOException { // 3. URL编码关键词,防止空格、+号等特殊字符破坏URL结构 String encodedKeyword = URLEncoder.encode(keyword, StandardCharsets.UTF_8); // 4. 拼接完整URL,包含所有必需参数 String searchUrl = String.format( "%s?q=%s&hl=zh-CN&gl=cn&num=%d&safe=off", baseUrl, encodedKeyword, maxResults ); // 5. 创建连接,设置超时和UA HttpURLConnection connection = (HttpURLConnection) new URL(searchUrl).openConnection(); connection.setRequestMethod("GET"); connection.setConnectTimeout(timeoutMs); connection.setReadTimeout(timeoutMs); connection.setRequestProperty("User-Agent", USER_AGENTS[0]); // 轮询UA可在此处实现 // 6. 获取响应码,对302重定向手动处理 int responseCode = connection.getResponseCode(); if (responseCode == HttpURLConnection.HTTP_MOVED_TEMP || responseCode == HttpURLConnection.HTTP_MOVED_PERM) { String redirectUrl = connection.getHeaderField("Location"); if (redirectUrl != null && !redirectUrl.startsWith("https://www.google.com")) { // 非Google域名重定向,视为异常 throw new IOException("Unexpected redirect to: " + redirectUrl); } // 重新发起请求到重定向地址 connection = (HttpURLConnection) new URL(redirectUrl).openConnection(); connection.setRequestMethod("GET"); connection.setConnectTimeout(timeoutMs); connection.setReadTimeout(timeoutMs); connection.setRequestProperty("User-Agent", USER_AGENTS[0]); } // 7. 读取响应体,用Jsoup解析 Document doc; try { doc = Jsoup.parse(connection.getInputStream(), StandardCharsets.UTF_8.name(), ""); } catch (IOException e) { // 8. 对常见错误码做友好提示 if (responseCode == 403) { throw new IOException("Google拒绝访问:检查User-Agent是否合法,或IP是否被限流"); } else if (responseCode == 429) { throw new IOException("请求过于频繁:建议增加请求间隔,或更换IP"); } else { throw new IOException("HTTP请求失败,状态码:" + responseCode); } } // 9. 解析结果,使用双选择器兜底 Elements results = doc.select("div.g, div.tF2Cxc"); List<SearchResult> searchResults = new ArrayList<>(); for (Element result : results) { SearchResult item = new SearchResult(); // 标题提取:兼容新旧DOM结构 Element titleEl = result.selectFirst("h3, .LC20lb"); item.setTitle(titleEl != null ? titleEl.text() : ""); // 链接提取:过滤掉Google自身跳转链接 Element linkEl = result.selectFirst("a[href^='https://']:not([href*='google.com'])"); if (linkEl != null) { String href = linkEl.attr("href"); // 解析真实的跳转目标(Google的URL会包装一层) if (href.startsWith("/url?")) { href = parseGoogleRedirectUrl(href); } item.setUrl(href); } // 摘要提取:多class名匹配 Element descEl = result.selectFirst("div.VwiC3b, .IsZvec, .VwiC3b span"); item.setDescription(descEl != null ? descEl.text() : ""); searchResults.add(item); } return searchResults; } // 10. 解析Google跳转URL的核心方法 private String parseGoogleRedirectUrl(String googleUrl) { // 示例:/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwjXzNvD5qGGAxWlq1YBHQHcBkIQFnoECAsQAQ&url=https%3A%2F%2Fexample.com%2F&usg=AOvVaw0... int urlIndex = googleUrl.indexOf("url="); if (urlIndex == -1) return googleUrl; String urlPart = googleUrl.substring(urlIndex + 4); int end = urlPart.indexOf('&'); if (end != -1) { urlPart = urlPart.substring(0, end); } try { return java.net.URLDecoder.decode(urlPart, StandardCharsets.UTF_8); } catch (Exception e) { return googleUrl; // 解码失败则返回原始值 } } // 11. 内部结果类,便于单元测试和扩展 public static class SearchResult { private String title; private String url; private String description; // getter/setter省略 public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } } }

4.3 单元测试与结果验证

写完代码必须配单元测试。以下是一个典型的JUnit 5测试用例(GoogleSearcherTest.java):

import org.junit.jupiter.api.Test; import org.junit.jupiter.api.DisplayName; import java.io.IOException; import java.util.List; import static org.junit.jupiter.api.Assertions.*; public class GoogleSearcherTest { @Test @DisplayName("验证Google搜索能正确返回至少3条结果") void testSearchReturnsResults() throws IOException { GoogleSearcher searcher = new GoogleSearcher(); // 测试关键词选"java httpurlconnection",避开敏感词和广告干扰 List<GoogleSearcher.SearchResult> results = searcher.search("java httpurlconnection"); // 断言:至少返回3条有效结果(Google有时会插入1-2条广告,但主结果应充足) assertTrue(results.size() >= 3, "搜索结果数量不足,实际返回:" + results.size()); // 断言:第一条结果的URL必须包含java官方文档或知名技术博客 String firstUrl = results.get(0).getUrl(); assertTrue(firstUrl.contains("docs.oracle.com") || firstUrl.contains("baeldung.com") || firstUrl.contains("mkyong.com"), "首条结果URL不符合预期:" + firstUrl); // 断言:标题不能为空 assertFalse(results.get(0).getTitle().trim().isEmpty(), "首条结果标题为空"); } }

运行测试前,务必在IDE中设置JVM参数-Dfile.encoding=UTF-8,否则中文关键词URL编码可能出错。我见过太多人因为IDE默认编码是GBK,导致URLEncoder.encode("Java", "UTF-8")生成乱码,最终请求返回400错误。

5. 常见问题与排查技巧实录:那些文档里不会写的坑

5.1 “error response from daemon: get 'https://registry-1.docker.io/v2/': net/http” —— 这根本不是你的代码问题!

这个报错高频出现在Docker环境里运行Java程序时,但它和Google搜索代码毫无关系。它是Docker守护进程(daemon)在拉取镜像时,因网络策略(如公司代理、DNS污染)无法连接Docker Hub导致的。解决方案只有两个:

  • 在Docker Desktop设置中配置HTTP代理(Settings → Resources → Proxies);
  • 或改用国内镜像源:docker login https://registry.cn-hangzhou.aliyuncs.com

注意:这个错误常被误认为是Java HTTP请求失败,因为它共享了net/http关键词。请务必先用curl -v https://www.google.com在容器内验证网络连通性,再排查Java代码。

5.2 “406 Not Acceptable” 错误的真相

当看到info: 127.0.0.1:62269 - "GET /mcp HTTP/1.1" 406 not acceptable这类日志,很多人第一反应是修改Accept头。但在这个项目里,它99%指向一个更隐蔽的问题:URL中的特殊字符未正确编码。例如搜索词java & spring,如果直接拼接q=java & spring&会被当作URL参数分隔符,导致Google只收到q=java,后续参数全部错位。正确做法是URLEncoder.encode("java & spring", "UTF-8"),得到q=java+%26+spring。我曾帮一位同事调试两小时,最后发现他用String.replace(" ", "+")手动替换空格,却忘了处理&=?等字符——这种“半吊子编码”是406错误的温床。

5.3 内存溢出(OutOfMemoryError)的诱因与规避

java: outofmemoryerror: insufficient memory在批量搜索时极易出现。根本原因不是代码有内存泄漏,而是Jsoup默认将整个HTML响应体加载进内存并构建DOM树。一个Google搜索结果页HTML大小约200KB,若并发10个请求,DOM树对象占用可达150MB。解决方案有三:

  • 流式解析:用Jsoup.parseBodyFragment(html, baseUrl)替代Jsoup.parse(html),跳过head部分解析;
  • 结果截断:在search()方法中添加if (searchResults.size() >= maxResults) break;,避免解析多余结果;
  • JVM参数优化:启动时添加-Xmx512m -XX:+UseG1GC,G1垃圾收集器对短生命周期对象更友好。

5.4 “Unidbg直接调用Java层” —— 为什么这方案被我们主动放弃?

最近热词里出现unidbg直接调用java层,这是安卓逆向领域的新技术,通过unidbg模拟ARM环境运行so库,并桥接到Java层。有人提议用它来调用Google搜索的Android SDK。但我们坚决否决,原因有三:

  • 法律风险:Google Play服务条款明确禁止非授权方式调用其搜索API,unidbg属于模拟执行,违反ToS;
  • 维护成本:每次Google更新APK,unidbg脚本需重写,而HTTP方案只需微调选择器;
  • 性能灾难:启动unidbg模拟器耗时>3秒,比原生HTTP慢100倍。

实操心得:当一个新技术名词(如unidbg)突然成为热词,先问自己——它解决的是我的真实痛点,还是别人制造的伪需求?在工程决策中,保守主义往往是最高级的创新。

5.5 面试场景下的临场发挥技巧

如果你在面试中被要求手写这段代码,记住三个黄金法则:

  • 先画骨架,再填血肉:先写出search()方法签名和try-catch框架,再逐步填充URL拼接、连接设置、解析逻辑;
  • 主动暴露边界条件:当面试官问“如果Google返回429怎么办?”,不要只说“加sleep”,要给出具体方案:“在catch块中捕获IOException,检查message是否含'429',若是则TimeUnit.SECONDS.sleep(30)并递归重试,最多3次”;
  • 用生活化类比解释技术点:比如解释User-Agent,“就像去银行办业务,你得出示身份证(UA)证明自己是合法客户,而不是用‘未知访客’这种假证”。

最后分享一个真实案例:某大厂Java高级开发岗终面,面试官给了一台装有JDK11的干净Ubuntu虚拟机,要求15分钟内写出可运行的Google搜索代码。候选人花了8分钟写完,但测试时返回空列表。他没慌,而是打开终端执行curl -H "User-Agent: Mozilla/5.0" "https://www.google.com/search?q=test",发现返回403,立刻意识到UA问题,换成Chrome UA后成功。这个debug过程比代码本身更能体现工程素养——真正的高手,永远把验证放在实现之前

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/22 20:36:03

基于混合去噪自编码器的共享单车智能选址实战

1. 项目概述与核心价值 最近在复盘一个挺有意思的旧项目&#xff0c;是关于共享单车站点扩展选址的。当时我们团队面临一个典型难题&#xff1a;手里有海量的骑行订单数据、用户轨迹&#xff0c;甚至还有一些天气、POI&#xff08;兴趣点&#xff09;信息&#xff0c;但真要决定…

作者头像 李华
网站建设 2026/6/22 20:34:27

当数字笔记回归手写的温度:Saber如何重新定义你的创作体验

当数字笔记回归手写的温度&#xff1a;Saber如何重新定义你的创作体验 【免费下载链接】saber The cross-platform open-source app built for handwriting 项目地址: https://gitcode.com/GitHub_Trending/sab/saber 还记得上次用笔在纸上自由书写的感觉吗&#xff1f;…

作者头像 李华
网站建设 2026/6/22 20:26:24

2026年最值得关注的多端AI开发工具排行榜

2026年最值得关注的多端AI开发工具包括 UXbot、Tempo、Draftbit、Thunkable 和 Replit Agent——这五款工具均支持在一次工作流中面向 Web、iOS、Android 三端生成可用界面或原生代码&#xff0c;彻底绕开针对单一平台反复重写的低效路径。 根据MarkWide Research 发布的跨平台…

作者头像 李华
网站建设 2026/6/22 20:23:47

如何在OSX-KVM中实现GPU直通:3步彻底解决虚拟化性能瓶颈

如何在OSX-KVM中实现GPU直通&#xff1a;3步彻底解决虚拟化性能瓶颈 【免费下载链接】OSX-KVM Run macOS on QEMU/KVM. With OpenCore Monterey Ventura Sonoma support now! Only commercial (paid) support is available now to avoid spammy issues. No Mac system is re…

作者头像 李华
网站建设 2026/6/22 20:17:52

如何高效使用N_m3u8DL-RE:3个实用技巧快速解决流媒体下载难题

如何高效使用N_m3u8DL-RE&#xff1a;3个实用技巧快速解决流媒体下载难题 【免费下载链接】N_m3u8DL-RE Cross-Platform, modern and powerful stream downloader for MPD/M3U8/ISM. English/简体中文/繁體中文. 项目地址: https://gitcode.com/GitHub_Trending/nm3/N_m3u8D…

作者头像 李华
网站建设 2026/6/22 20:16:16

AI硬件级治理机制:技术原理与工程实践

1. AI硬件级治理机制概述随着人工智能技术的快速发展&#xff0c;各国政府越来越关注如何有效监管前沿AI系统的开发与部署。传统基于算法或数据的治理手段面临诸多挑战&#xff0c;而基于计算资源&#xff08;compute&#xff09;的治理方案因其可检测性、可排他性和可量化性三…

作者头像 李华