github详细源码分析:https://github.com/code4craft/jsoup-learning
最近做网页分析时接触了一些 包括jsoup在内开源工具。 今天有时间读了下jsoup的源码,记录一下心得。
【特色】
作为html 解析工具,jsoup 出现的时间远不如大名鼎鼎的HttpClient。但是他有一些不错的特色:
1.实现了CSS选择器语法,有了这个页面内容提取真不是一般的方便。
2.解析算法不使用递归,而是enum配合状态模式遍历数据(先预设所有语法组合),减少性能瓶颈。另外,不需要任何第三方依赖。
【示例】
比如要想要过滤一个网页上所有的jpeg图片的链接,只需要下面几句即可。
- Document doc = Jsoup.connect("http://www.mafengwo.cn/i/760809.html").get();
- Elements jpegs = doc.select("img[src$=.jpeg]");
- for (Element jpeg : jpegs) {
- System.out.println(jpeg.attr("abs:src")); // 获取图片的绝对路径
- }
【流程分析】
上面的代码可分为三个步骤,后面的源码分析也按照这个思路来走。
1.根据输入构建DOM树
2.解析CSS选择字符串到 过滤表中
3.用深度优先算法将树状节点逐一过滤
【源码分析】
1.DOM树构造
先说一下容器是位于org.jsoup.nodes 下 抽象类 Node及其派生类。看名字就知道意思,典型的组合模式。每个都可以包含子Node列表。其中最重要的是 Element类,代表一个html元素,包含一个tag,多个属性值及子元素。
树构造的关键代码位于 模板类TreeBuilder 中:
TreeBuilder类
- protected void runParser() {
- while (true) {
- Token token = tokeniser.read(); // 这里读入所有的Token
- process(token);
- if (token.type == Token.TokenType.EOF)
- break;
- }
- }
Tokeniser 类
- Token read() {
- if (!selfClosingFlagAcknowledged) {
- error("Self closing flag not acknowledged");
- selfClosingFlagAcknowledged = true;
- }
- while (!isEmitPending)
- state.read(this, reader); //此处 在做预设好的各种状态转移 以遍历所有标签
- // if emit is pending, a non-character token was found: return any chars in buffer, and leave token for next read:
- if (charBuffer.length() > 0) {
- String str = charBuffer.toString();
- charBuffer.delete(0, charBuffer.length());
- return new Token.Character(str);
- } else {
- isEmitPending = false;
- return emitPending;
- }
- }
- enum TokeniserState {
- Data {
- // in data state, gather characters until a character reference or tag is found
- void read(Tokeniser t, CharacterReader r) {
- switch (r.current()) {
- case '&':
- t.advanceTransition(CharacterReferenceInData); // 这里做状态切换
- break;
- case '<':
- t.advanceTransition(TagOpen); // 这里也是状态切换
- break;
- case nullChar:
- t.error(this); // NOT replacement character (oddly?)
- t.emit(r.consume()); // emit()方法会将 isEmitPending 设为 true,循环结束
- break;
- case eof:
- t.emit(new Token.EOF());
- break;
- default:
- String data = r.consumeToAny('&', '<', nullChar);
- t.emit(data);
- break;
- }
- }
- },
- 。。。(略)
TokeniserState 包含多达67个成员,即67种解析的中间状态。可以说整个html语法解析的核心。
TokeniserState,Tokeniser 是强耦合关系,循环遍历在Tokeniser 中进行,循环终止条件在TokeniserState中调用。Token解析完就要装载,这里又用到了一个enum HtmlTreeBuilderState,也有类似的状态切换,这里不再累述。
HtmlTreeBuilder类
- @Override
- protected boolean process(Token token) {
- currentToken = token;
- return this.state.process(token, this);
- }
2.CSS选择字符串的解析
解析后的容器是 Evaluator 匹配器类,它自身为抽象类,包含为数众多的子类实现。
这些类分别对应CSS的匹配语法,涉及细节较多。每一种实现表示一种语义并实现的对应的匹配方法:
- public abstract boolean matches(Element root, Element element);
作者可能处于减少类文件数量的考虑除了CombiningEvaluator 和 StructuralEvaluator 其他都实现为 Evaluator 的内部静态公有派生子类。
解析代码位于QueryParser类中,还是采用消费模式。
QueryParser类
- Evaluator parse() {
- tq.consumeWhitespace();
- if (tq.matchesAny(combinators)) { // if starts with a combinator, use root as elements
- evals.add(new StructuralEvaluator.Root());
- combinator(tq.consume());
- } else {
- findElements();
- }
- while (!tq.isEmpty()) { // 不断消费直到消费空
- // hierarchy and extras
- boolean seenWhite = tq.consumeWhitespace();
- if (tq.matchesAny(combinators)) {
- combinator(tq.consume()); //这里处理组合语义关联的符号 ",", ">", "+", "~", " "
- } else if (seenWhite) {
- combinator(' ');
- } else { // E.class, E#id, E[attr] etc. AND
- findElements(); // take next el, #. etc off queue
- }
- }
- if (evals.size() == 1)
- return evals.get(0);
- return new CombiningEvaluator.And(evals); // 这里将组合的选择器组装为list
- }
最后得到了一个 匹配器列表。
3.用非递归的深度优先算法将 树状节点逐一过滤
这里是一个访问者模式的应用。
NodeTraversor 类
- private NodeVisitor visitor; //这个visitor 即是下面的Accumulator类
- public NodeTraversor(NodeVisitor visitor) {
- this.visitor = visitor;
- }
- public void traverse(Node root) {
- Node node = root;
- int depth = 0;
- while (node != null) {
- visitor.head(node, depth);
- if (node.childNodeSize() > 0) { //有子元素先处理(深度优先)
- node = node.childNode(0);
- depth++;
- } else {
- while (node.nextSibling() == null && depth > 0) {
- visitor.tail(node, depth);
- node = node.parentNode();
- depth--;
- }
- visitor.tail(node, depth);
- if (node == root)
- break;
- node = node.nextSibling();
- }
- }
- }
Collector 类
- public static Elements collect (Evaluator eval, Element root) {
- Elements elements = new Elements();
- //这里是访问者的入口,注意 NodeTraversor 仅仅是个“媒介类”,利用其构造方法关联观察者
- //Accumulator就是访问者
- // elements 是访问者的出口
- new NodeTraversor(new Accumulator(root, elements, eval)).traverse(root);
- return elements;
- }
- private static class Accumulator implements NodeVisitor {
- private final Element root;
- private final Elements elements;
- private final Evaluator eval;
- Accumulator(Element root, Elements elements, Evaluator eval) {
- this.root = root;
- this.elements = elements;
- this.eval = eval;
- }
- public void head(Node node, int depth) {
- if (node instanceof Element) {
- Element el = (Element) node;
- if (eval.matches(root, el))
- elements.add(el); //这里是访问者在收集 elements 了
- }
- }
- public void tail(Node node, int depth) {
- // void
- }
- }
【CSS选择器的扩展】
以下是一些有用的选择器扩展,基本上能满足所有的需求。(参考org.jsoup.select.Selector 的API)
:contains(text) 匹配包含字符 范围:
对元素及其所有子元素
:matches(regex)
匹配这则表达式 范围:
对元素及其所有子元素
:containsOwn(text)
匹配包含字符 范围:仅
对本元素(不包含子元素)
:matchesOwn(regex)
匹配这则表达式
范围:仅
对本元素(不包含子元素)
【总结】
用过 jsoup 的人一定对其方便实用的功能印象深刻,采用CSS选择器语法确实是一个创意之举。不过如果要将 jsoup正式应用于项目还需要谨慎。
一是,现在开发只有一个人 Jonathan Hedley。不管这么说太少了点,长远来看项目后期维护有一定风险。
(源码中todo数目不少就是佐证)
二是,该工具是将整个html解析后,再进行搜索,有一定的解析成本。如果是很简单查询还是不如直接正则来得快。
【资源】
项目网站: http://jsoup.org/
值得一提的是 http://try.jsoup.org/ 可以直接拿你要用的html内容或Url,来测试css选择语法,非常实用。
另外,有中文翻译CookBook网址:http://www.open-open.com/jsoup/