HTML解析

HTML5解析由两部分组成:分词与构建树。

分词属于语法分析部分,它把输入解析成符号序列。在HTML中符号就是开始标签,结束标签,属性名称和属性值。

分词算法

分词算法可以用状态机来描述。每一个状态从输入流中消费一个或者多个字符,并根据它们更新下一状态。

决策受当前符号状态和树的构建状态影响。这意味着同样的字符可能会产生不同的结果,取决于当前的状态。

<html>
    <body>
        Hello world
    </body>
</html>

初始状态是Data state,当遇到<时状态改为Tag open state。吃掉a-z字符组成的符号后产生了Start tag token,状态变更为Tag name state。我们一直保持此状态,直到遇到>。每个字符都被追加到新的符号名上。在我们的例子中,解出的符号就是html

当碰到>时,当前符号完成,状态改回Data state<body>标签将会以同样的方式处理。现在htmlbody标签都完成了,我们回到Data state状态。吃掉HHello world第一个字母)时会产生一个字符符号,直到碰到</body><符号,我们就完成了一个字符符号Hello world

现在我们回到Tag open state状态。吃掉下一个输入/时会产生一个end tag token并变更为Tag name state状态。同样,此状态保持到我们碰到>时。这时新标签符号完成,我们又回到Data state。同样</html>也会被这样处理。

树的构建算法

当分词解析器被创建时,文档对象也会被创建。

在树的创建过程中DOM树的根节点(Document)将被修改,元素被添加到上面去。每个分词器完成的节点都会被树构建器处理。

除了把元素添加到DOM树外,它还会被添加到一个开放元素堆栈。这个堆栈用于纠正嵌套错误和标签未关闭错误。

<html>
    <body>
        Hello world
    </body>
</html>

树的构建过程中,输入就是分词过程中得到的符号序列。第一个模式叫initial mode。接收html符号后会变成before html模式并重新处理此模式中的符号。这会创建一个HTMLHtmlElement元素并追加到根文档节点。

然后状态改变为before head。我们收到body时,会隐式创建一个HTMLHeadElement,尽管我们没有这个标签,它也会被创建并添加到树中。

现在我们进入in head模式,然后是after headBody会被重新处理,创建HTMLBodyElement元素并插入,然后进入in body模式。

字符符号Hello world收到后会创建一个Text节点,所有字符都被一一追加到上面。

收到body结束标签后进入after body模式,收到html结束标签后进入after after body模式。所有符号处理完后将终止解析。

2016/2/23 posted in  others

JSoup

  • 如果要建立一个对象只包含一个ArrayList<Object>,可以extends这个ArrayList<Object>

  • TokenType包含

    TokenType
    ├── Doctype: name, publicIdentifier, systemIdentifier
    ├── StartTag: tagName, pendingAttributeName, pendingAttributeValue, attributes
    ├── EndTag: tagName, pendingAttributeName, pendingAttributeValue
    ├── Comment: data
    ├── Character: data
    ├── EOF: EOF
  • HtmlTreeBuilderState中定义了分析html页面时各种状态以及在各种状态下对应的处理方法(状态机

    this.state.process(..)
    

譬如

Initial {
        boolean process(Token t, HtmlTreeBuilder tb) {
            if (isWhitespace(t)) {
                return true; // ignore whitespace
            } else if (t.isComment()) {
                tb.insert(t.asComment());
            } else if (t.isDoctype()) {
                // todo: parse error check on expected doctypes
                // todo: quirk state check on doctype ids
                Token.Doctype d = t.asDoctype();
                DocumentType doctype = new DocumentType(d.getName(), d.getPublicIdentifier(), d.getSystemIdentifier(), tb.getBaseUri());
                tb.getDocument().appendChild(doctype);
                if (d.isForceQuirks())
                    tb.getDocument().quirksMode(Document.QuirksMode.quirks);
                tb.transition(BeforeHtml);
            } else {
                // todo: check not iframe srcdoc
                tb.transition(BeforeHtml);
                return tb.process(t); // re-process token
            }
            return true;
        }
    },

当进入initial状态的时候,只可能出现四种情况whitespace, comment, doctype, 其他。在不同的情况下,定义了各种后续的策略

状态和下一状态的转换使用tb.transition(BeforeHtml)来进行。

  • HTML解析状态机
    <!-- State: Initial -->
    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
    <!-- State: BeforeHtml -->
    <html lang='zh-CN' xml:lang='zh-CN' xmlns='http://www.w3.org/1999/xhtml'>
    <!-- State: BeforeHead -->
    <head>
      <!-- State: InHead -->
      <script type="text/javascript">
      //<!-- State: Text -->
        function xx(){
        }
      </script>
      <noscript>
        <!-- State: InHeadNoscript -->
        Your browser does not support JavaScript!
      </noscript>
    </head>
    <!-- State: AfterHead -->
    <body>
    <!-- State: InBody -->
    <textarea>
        <!-- State: Text -->
        xxx
    </textarea>
    <table>
        <!-- State: InTable -->
        <!-- State: InTableText -->
        xxx
        <tbody>
        <!-- State: InTableBody -->
        </tbody>
        <tr>
            <!-- State: InRow -->
            <td>
                <!-- State: InCell -->
            </td>
        </tr>    
    </table>
    </html>

  • HTML解析树

根据如下代码:

        String html = "<html><head><title>First!</title></head><body><p>First post! <img src=\"foo.png\" /></p></body></html>";
        Document doc = Jsoup.parse(html);

生成的HTML解析树(Document

doc->childNodes: 
        <html><head>...</head><body>...</body></html>

doc->childNodes->childNodes
        <head>...</head>
        <body>...</body>

然后继续按照childNodes分下去就是一颗完整的树

  • HTML解析

Token token = tokeniser.read();就是在分词,分出<html>等词出来

protected void runParser() {
   while (true) {
       Token token = tokeniser.read();
       process(token);

       if (token.type == Token.TokenType.EOF)
           break;
   }
}
  • getElementByTag("a")

寻找tagNameaelement

new NodeTraversor(new Accumulator(root, elements, eval)).traverse(root);

使用NodeTraversor一个element一个element的遍历生成的document,找到一个element之后在Accumulatorhead方法中判断是不是要找的tagNameaelement,如果是,就加到elements变量中

  • TokenQueue
TokenQueue tq = new TokenQueue("(one (two) three) four");
String guts = tq.chompBalanced('(', ')');
assertEquals("one (two) three", guts);

使用chompBalanced(char open, char close)获取openclose之间的内容

do {
       if (isEmpty()) break;
       Character c = consume();
       if (last == 0 || last != ESC) {
           if (c.equals(open))
               depth++;
           else if (c.equals(close))
               depth--;
       }

       if (depth > 0 && last != 0)
           accum.append(c); // don't include the outer match pair in the return
       last = c;
   } while (depth > 0);

找到符合open的元素,depth+1;找到符号close的元素,depth-1。期间的内容append就是需要提取的元素。

depth不为0但是依然没有找到符合close的元素,就把找到的全输出

TokenQueue tq = new TokenQueue("(one (two three) four");
String guts = tq.chompBalanced('(', ')');
// guts = one (two three four
2016/2/22 posted in  源码阅读

JFinal

  • 使用PropKit获取要读的文件名,然后再取元素

    PropKit.use("redis_config.txt").get("host");
    
2016/2/22 posted in  源码阅读

商城架构演变

性能

一开始的重点是提高服务的性能、反应速度,并且尽可能的保证系统的安全。

第一阶段

商城第一阶段的框架采用的是传统的动静分离+负载均衡的配置。

  • 最外层是采用F5做的负载均衡和反向代理
  • 两台Ngnix服务器负责处理静态资源的请求,并将动态请求分发给Tomcat服务器集群
  • 商城的应用(网站、触屏版等)都建立在Tomcat服务器上,主要采用SpringMVC + Freemarker
  • 应用通过API服务器暴露出的接口对数据库进行增删改查的操作

第二阶段

第二阶段框架的出现是为了解决第一阶段暴露出的几个问题:

  • Tomcat服务器过于忙碌:

    • Tomcat服务器的一般工作流为:接收到Ngnix服务器转发的动态请求 => 将动态请求按照业务逻辑调用API服务器的接口 => 将API服务器返回的数据和Freemarker结合,生成HTML文件
    • 对于经常被访问到的页面(首页、商品详情页等),Tomcat服务器需要不断重复他的一般工作流,即使最后生成的HTML文件都是一模一样的。
  • Tomcat服务器API服务器交互过于频繁:即使API服务器有如memcached类的缓存,依然会有很多不必要的网络消耗

因此,我们觉得最好能将HTML文件缓存下来

我们在Tomcat服务器上加上了EhCache过滤器。一些经常会被访问到的页面(譬如首页、商品详情页等)在第一次被访问过并成功生成HTML页面后,会被记录在内存中,下一次访问的时候就不会再向API服务器请求,也不会再解析Freemarker模板,内存中的页面会直接返回。

第三阶段

第二阶段的框架一上线就暴露出了问题:页面不能及时更新,需要等EhCache自带的缓存更新机制(通常是缓存池满了)激活,缓存才会更新;而我们需要缓存更新是及时的、是可控的。

所以,我们自制了一个叫Backbone的微服务

其实Backbone目前就被当成了一个定时任务系统,只不过起名的时候,我们对这个系统寄以重托,所以给了一个很大的名号。

工作流是这样的:

  • 当有缓存的内容进入EhCache的时候,Backbone会接收到请求的参数
  • Backbone根据接收到的请求参数,按照业务逻辑,请求API服务器;之后将API服务器返回的结果拼接,生成一个签名,最后将<请求参数,签名>存入Redis
  • Backbone定期从Redis中取出请求参数,并按照页面逻辑,请求API服务器,如果API服务器返回的结果拼接后的签名和Redis中存的签名一致,则无变化;如果不一致,Backbone会删除Redis中的记录,并调用Tomcat服务器暴露的接口删除EhCache中该请求的缓存。

第四阶段

第三阶段的框架还是有瑕疵,有这么三个最为明显:

  • 既然第二次访问同链接访问的是缓存的内容,为什么还要到Tomcat服务器才处理
  • 缓存的HTML文件能不能看到
  • EhCache中存的很多数据都是冗余的

于是,我们采用了Ngnix+Lua的方式来解决上面三个问题。

Ngnix服务器Tomcat服务器生成的HTML文件保存到Redis中,这样Redis就被做成了Ngnix服务器集群统一存储HTML文件的地方。

优化

日志系统

经过四个阶段的性能优化,整个商城的服务应该算OK,接下来我们想让开发调试更轻松一些。

我们觉得目前开发调试的瓶颈是日志:

  • 一方面 因为正式环境需要堡垒机才能操作,如果需要通过看日志来解决问题,需要到堡垒机看日志或者让运维拖日志下来,整个流程非常的难受。日志不是什么生死攸关的东西,我们想要看到线上实时的日志。
  • 日志中打印了很多很多的内容,使用tail -f之类的命令,滚屏会非常的快,这样看日志太伤神了。我们想要更优雅、更简单的查看日志的方法。

一种解决方法是将日志保存到专门的一台服务器,然后通过tail -f XX | grep XXX之类的命令来看。这种方法是能基本解决以上两个问题,但是不那么优雅,不能算作一个系统的解决方案。

于是,我们采用了ElasticSearch + LogStash + KinabaELK)。一开始我们想自己利用Bootstrap或者Framework7写一套系统,但是太懒,同时也发现ELK已经把我们想做的都做了,有些小功能,我们改改Kinaba就能实现,所以直接把ELK拿来用了。

首先我们自制了一个Admin微服务来监听处理Tomcat服务器通过MQ发送过来的日志

日志服务的工作流是这样的:

  • Tomcat服务器的日志会被发送到MQExchanger
  • Admin系统会将监听到的日志进行处理(Tomcat服务器的日志利用拓展log4j.appender,封装了一些附加信息),打印到admin-log.log文件中
  • LogStash会分析admin-log.log,并将分析的结果实时的放入ElasticSearch
  • KinabaElasticSearch提供了一个可视化的界面,在这个界面中,我们能筛选日志,能实时打印日志

小助手

最初的想法是利用Slack + Hubot,但是依然是因为懒,而转用了微信。

微信小助手的主要用处就是检查服务的上线状态,发送上线检查之类的关键字,就会激活我们写的检查程序,主要涉及商城页面能不能正常打开,有些流程能不能走通,如果走不通是因为跟API服务器的沟通断了还是API服务器坏了还是什么。其次还加上了一些权限设置以及小助手注册机制等等。

2016/2/15 posted in  others

深入浅出TCP/IP中的send和recv

每个TCP socket在内核中都有一个发送缓冲区和一个接收缓冲区。

接收缓冲区把数据缓存入内核,应用进程一直没有调用read进行读取的话,此数据会一直缓存在相应socket的接收缓冲区内。不管进程是否读取socket,对端发来的数据都会经由内核接收并且缓存到socket的内核接收缓冲区之中。read所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的buffer里面。

接收缓冲区被TCPUDP用来缓存网络上来的数据,一直保存到应用进程读走为止。对于TCP,如果应用进程一直没有读取,buffer满了之后,发生的动作是:通知对端TCP协议中的窗口关闭。这个便是滑动窗口的实现。保证TCP套接口接收缓冲区不会溢出,从而保证了TCP是可靠传输。如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方TCP将丢弃它。

进程调用send发送的数据的时候,最简单情况,将数据拷贝进入socket的内核发送缓冲区之中,然后send便会在上层返回。也就是说,send返回之时,数据不一定会发送到对端去(和write写文件有点类似),send仅仅是把应用层buffer的数据拷贝进socket的内核发送buffer中。

每个UDP socket都有一个接收缓冲区,没有发送缓冲区,从概念上来说就是只要有数据就发,不管对方是否可以正确接收,所以不缓冲,不需要发送缓冲区。

由于TCP是流式的,对于TCP而言,每个TCP连接只有syn开始和fin结尾,中间发送的数据是没有边界的,多个连续的send所干的事情仅仅是:
- 假如socket的文件描述符被设置为阻塞方式,而且发送缓冲区还有足够空间容纳这个send所指示的应用层buffer的全部数据,那么把这些数据从应用层的buffer,拷贝到内核的发送缓冲区,然后返回
- 假如socket的文件描述符被设置为阻塞方式,但是发送缓冲区没有足够空间容纳这个send所指示的应用层buffer的全部数据,那么能拷贝多少就拷贝多少,然后进程挂起,等到TCP对端的接收缓冲区有空余空间时,通过滑动窗口协议通知TCP本端:“我已经做好准备,您现在可以继续向我发送X个字节的数据了”,然后本端的内核唤醒进程,继续向发送缓冲区拷贝剩余数据,并且内核向TCP对端发送TCP数据,如果send所指示的应用层buffer中的数据在本次仍然无法全部拷贝完,那么过程重复。。。直到所有数据全部拷贝完,返回。后续发送过程中,接收端会不断的用ACK通知发送端自己的接收窗口的大小状态;而发送数据的量,就根据这个接收窗口的大小来确定,发送端不会发送超过接收端能力的数据量,这样就起到了一个流量控制的作用。
- 假如socket的文件描述符被设置为非阻塞方式,而且发送缓冲区还有足够空间容纳这个send所指示的应用层buffer的全部数据,那么把这些数据从应用层的buffer拷贝到内核的发送缓冲区,然后返回
- 假如socket的文件描述符被设置为非阻塞方式,但是发送缓冲区没有足够空间容纳这个send所指示的应用层buffer的全部数据,那么能拷贝多少就拷贝多少,然后返回拷贝的字节数

2016/2/14 posted in  网络