URL能够智能识别并高亮显示,同时解析出对应网页的标题,这样一来,用户只需轻轻一点即可跳转到目标网页,即便不点击也能一目了然地了解该网站的主要功能。这样的设计无疑为用户带来了极大的便利。那么,这样的功能是如何实现的呢?其中又涉及了哪些细节呢?本文将详尽地从调研开始,到方案的选择,再到具体的技术实现,一一为您揭晓。
调研
URL的识别其实相当直接,主要依赖于正则表达式的匹配。同样地,URL标题的解析也并不复杂,我们只需预先进行一次访问,然后从中抓取对应网站的title标签内容即可。
然而,真正的挑战在于我们如何选择解析或匹配URL的时机。这一步骤应该由后端来完成,还是由前端来负责?是应该在数据入库时就进行处理,还是在列表查询时才进行?
此外,我们还需要考虑与前端之间的消息交互机制。如何确保前后端之间的数据传递既高效又准确,是我们在设计整个系统时必须仔细考虑的问题。通过合理的设计和优化,我们可以确保URL的识别、解析以及前后端之间的消息传递都能顺畅进行,从而提升用户体验和系统性能。
调研了一下市面上已经成熟的产品:知识星球
星球会解析url的标题。通过抓包就可以了解他们实现的思路。
点击info下的消息体,复制出来发现被unicode编码了,进行解码一下。
https://www.jyshare.com/front-end/3602/
后端通过一种特定的格式直接将信息传递给前端,这些信息中明确指定了title的文本内容和对应的跳转链接。那么,我们是否也应该效仿这种做法由后端来负责执行呢。
方案选型
首先我们思考下url什么时候去解析。url的正则匹配不怎么耗时,主要是请求外部网站解析标题的时候比较耗时。
你可以选择以下三种方式:
1.在消息发送并入库之前进行解析。
2.用户访问消息列表时,后端进行实时解析。
3.将原文直接呈现给用户,由前端自行解析。
首先,我们排除第二种方式,因为对于同一条消息,不同的用户请求都会触发重复的解析过程,这不仅增加了接口的响应时间,还占用了宝贵的后端资源。
至于第三种方式,虽然它不会占用服务端的资源,而是由用户端自行解析,但这样做会导致消息阅读者的加载速度变慢。更为重要的是,如果在一个有千人在线的群聊中发送一条链接,这一千个前端都会去请求并解析该链接,这无疑会给目标网站带来沉重的负担。因此,我们应避免给其他网站造成不必要的困扰。
经过深思熟虑,我们最终选择了第一种方式,即让消息的发送者在发送前稍微承担一些工作(既然要发链接,就稍微委屈一下吧)。
那么,是否有可能采用异步的方式呢?也就是说,在消息入库后,再进行异步解析,并将解析结果推送给其他用户。然而,考虑到消息发送者通常需要实时查看自己的链接是否已被解析,因此这种异步方式并不适用。
综上所述,我们决定让消息发送者承担这一任务。不过,请放心,我们会尽力进行必要的优化,以减轻发送者的负担。
技术实现
在技术实现之前,我们需要先进行一个最小粒度的验证。就是先验证我们能够识别url和标题解析,再去进行代码更优雅的编写。
尝试识别url
public static void main(String[] args) {
String content= "访问我的博客 https://www.example.com/blog,里面有许多有趣的内容。如果你对编程感兴趣,可以访问这个论坛 http://forum.coding.com/index.php,它提供了很多学习资料。这是一个图片分享网站 http://photos.site.net/gallery?id=123,上面有很多漂亮的图片。ftp://files.portfolio.org/artworks/,可以看到我的最新创作。这是一个带有查询参数的URL https://search.example.net/?q=java+programming,你可以用它来搜索相关内容。在这个新闻网站 https://news.dailyupdate.com/top-stories,你可以了解最新的时事动态。";
Pattern pattern = Pattern.compile("((http|https)://)?(www.)?([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?");
List<String> matchList = ReUtil.findAll(pattern, content, 0);//hutool工具类
System.out.println(matchList);
}
输出的结果
[https://www.example.com/blog, http://forum.coding.com/index.php, http://photos.site.net/gallery?id=123, files.portfolio.org/artworks/, https://search.example.net/?q=java+programming, https://news.dailyupdate.com/top-stories]
大部分的case都匹配上了,没有问题。其实想要找一个完美的链接匹配正则是有难度的。这个正则还是用gpt4生成的。
尝试获取标题
引入jsoup依赖
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.15.3</version>
</dependency>
获取标题,我们可用java常用的爬虫工具Jsoup来进行内容的解析。一般浏览器展示的标签页的文本,就是html里面的<title></title>
标签
public static void main(String[] args) throws IOException {
Connection connect = Jsoup.connect("http://www.baidu.com");
Document document = connect.get();
String title = document.title();
System.out.println(title);
}
输出结果
百度一下,你就知道
为了让你能更加清楚的了解jsoup
爬取到的document
是啥样,我们debug看看
其实就是一个html页面。我们通过document.title()
去匹配标题的标签内容。
所有网页的标题都是这个title标签吗?事实上,不同的页面,标题在不同的标签内,需要不同的解析方式。我们也不知道哪些标题需要用到什么解析方式,只能把解析不出来的标题打个日志,然后再去慢慢的添加更多的解析方式,那未来肯定会扩展很多的解析方式。
把标题的解析方式做成解析器。每个解析器串成一个链条。通用的解析器优先级更高。链条中直到其中某个解析器解析出标题,就返回。
解析器串起的链条就是责任链模式,创建责任链的地方就是工厂模式。而不同的类实现不同的url解析方法,这就是策略模式,而抽象类里面的逻辑,又像是模板方法模式,一口气就能实现四种模式。
搭建url解析框架
正好想到之前学spring源码的时候,看见spring的参数解析器(就是识别方法的参数名)和我们的这个需求类似,我们可以参考spring去进行框架的搭建。
定义接口,核心的接口是getUrlContentMap
,其他的方法只是细分的获取步骤。
public interface UrlDiscover {
@Nullable
Map<String,UrlInfo> getUrlContentMap(String content);
@Nullable
UrlInfo getContent(String url);
@Nullable
String getTitle(Document document);
@Nullable
String getDescription(Document document);
@Nullable
String getImage(String url, Document document);
}
公共的逻辑放在抽象类。子类commonUrl
和WxUrl
就是不同的标题解析策略。
当一个用户发送了一段包含众多URL的消息时,我们是否需要按顺序逐个解析这些URL呢?实际上,由于每个URL的解析过程相互独立,没有必要串行处理。如果采用并行方式解析,将显著减少总体耗时,从而极大地提升用户体验,尤其是对于消息发送者而言,长时间等待是不可接受的。同时,考虑到如GitHub这类网站可能存在的较长请求响应时间,我们应设置合理的超时机制,例如超过1秒仍未获取到内容则放弃请求,以防止单个URL影响整体响应速度。
因此,为了提高效率和优化用户体验,我们可以利用多线程或异步IO技术来并发处理所有URL的解析任务。这样一来,每个URL解析会被分配到单独的线程或进程中执行,不再需要等待前一个URL解析完毕。最终,整个解析流程的耗时将主要取决于最慢的那个URL的解析所需时间,而非所有URL解析时间的叠加。
这样的一个并行框架要怎么去写呢?可以用CompletableFuture
,美团的一篇技术文章:CompletableFuture原理与实践-外卖商家端API的异步化。
美团提供了一个异步的工具类,对CompletableFuture
进行了一层封装,用起来更加简洁优雅。
public abstract class AbstractUrlDiscover implements UrlDiscover {
//链接识别的正则
private static final Pattern PATTERN = Pattern.compile("((http|https)://)?(www.)?([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?");
@Nullable
@Override
public Map<String, UrlInfo> getUrlContentMap(String content) {
if (StrUtil.isBlank(content)) {
return new HashMap<>();
}
List<String> matchList = ReUtil.findAll(PATTERN, content, 0);
//并行请求
List<CompletableFuture<Pair<String, UrlInfo>>> futures = matchList.stream().map(match -> CompletableFuture.supplyAsync(() -> {
UrlInfo urlInfo = getContent(match);
return Objects.isNull(urlInfo) ? null : Pair.of(match, urlInfo);
})).collect(Collectors.toList());
CompletableFuture<List<Pair<String, UrlInfo>>> future = FutureUtils.sequenceNonNull(futures);
//结果组装
return future.join().stream().collect(Collectors.toMap(Pair::getFirst, Pair::getSecond, (a, b) -> a));
}
@Nullable
@Override
public UrlInfo getContent(String url) {
Document document = getUrlDocument(assemble(url));
if (Objects.isNull(document)) {
return null;
}
return UrlInfo.builder()
.title(getTitle(document))
.description(getDescription(document))
.image(getImage(assemble(url), document)).build();
}
private String assemble(String url) {
if (!StrUtil.startWith(url, "http")) {
return "http://" + url;
}
return url;
}
protected Document getUrlDocument(String matchUrl) {
try {
Connection connect = Jsoup.connect(matchUrl);
connect.timeout(2000);
return connect.get();
} catch (Exception e) {
log.error("find error:url:{}", matchUrl, e);
}
return null;
}
/**
* 判断链接是否有效
* 输入链接
* 返回true或者false
*/
public static boolean isConnect(String href) {
//请求地址
URL url;
//请求状态码
int state;
//下载链接类型
String fileType;
try {
url = new URL(href);
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
state = httpURLConnection.getResponseCode();
fileType = httpURLConnection.getHeaderField("Content-Disposition");
//如果成功200,缓存304,移动302都算有效链接,并且不是下载链接
if ((state == 200 || state == 302 || state == 304) && fileType == null) {
return true;
}
httpURLConnection.disconnect();
} catch (Exception e) {
return false;
}
return false;
}
}
public class CommonUrlDiscover extends AbstractUrlDiscover {
@Nullable
@Override
public String getTitle(Document document) {
return document.title();
}
@Nullable
@Override
public String getDescription(Document document) {
String description = document.head().select("meta[name=description]").attr("content");
String keywords = document.head().select("meta[name=keywords]").attr("content");
String content = StrUtil.isNotBlank(description) ? description : keywords;
//只保留一句话的描述
return StrUtil.isNotBlank(content) ? content.substring(0, content.indexOf("。")) : content;
}
@Nullable
@Override
public String getImage(String url, Document document) {
String image = document.select("link[type=image/x-icon]").attr("href");
//如果没有去匹配含有icon属性的logo
String href = StrUtil.isEmpty(image) ? document.select("link[rel$=icon]").attr("href") : image;
//如果url已经包含了logo
if (StrUtil.containsAny(url, "favicon")) {
return url;
}
//如果icon可以直接访问或者包含了http
if (isConnect(!StrUtil.startWith(href, "http") ? "http:" + href : href)) {
return href;
}
return StrUtil.format("{}/{}", url, StrUtil.removePrefix(href, "/"));
}
}
public class WxUrlDiscover extends AbstractUrlDiscover {
@Nullable
@Override
public String getTitle(Document document) {
return document.getElementsByAttributeValue("property", "og:title").attr("content");
}
@Nullable
@Override
public String getDescription(Document document) {
return document.getElementsByAttributeValue("property", "og:description").attr("content");
}
@Nullable
@Override
public String getImage(String url, Document document) {
String href = document.getElementsByAttributeValue("property", "og:image").attr("content");
return isConnect(href) ? href: null;
}
}
prioritizedUrl
是我们的策略类,同时也是组装责任链的工厂。如果你调用它,它会按顺序执行责任链,直到解析出url标题。
public class PrioritizedUrlDiscover extends AbstractUrlDiscover {
private final List<UrlDiscover> urlDiscovers = new ArrayList<>(2);
//组装责任链
public PrioritizedUrlDiscover() {
urlDiscovers.add(new WxUrlDiscover());
urlDiscovers.add(new CommonUrlDiscover());
}
@Nullable
@Override
public String getTitle(Document document) {
//顺序执行责任链
for (UrlDiscover urlDiscover : urlDiscovers) {
String urlTitle = urlDiscover.getTitle(document);
if (StrUtil.isNotBlank(urlTitle)) {
return urlTitle;
}
}
return null;
}
@Nullable
@Override
public String getDescription(Document document) {
for (UrlDiscover urlDiscover : urlDiscovers) {
String urlDescription = urlDiscover.getDescription(document);
if (StrUtil.isNotBlank(urlDescription)) {
return urlDescription;
}
}
return null;
}
@Nullable
@Override
public String getImage(String url, Document document) {
for (UrlDiscover urlDiscover : urlDiscovers) {
String urlImage = urlDiscover.getImage(url,document);
if (StrUtil.isNotBlank(urlImage)) {
return urlImage;
}
}
return null;
}
}
看源码能让我们更加深入的理解设计模式的运用,更能为我们平时的开发提供思路灵感
这样一个url解析框架基本完成,发送消息的时候只需要匹配出所有url,然后通过责任链去解析标题就ok了。