WebSocket SpringBoot实现文件上传进度消息通知

1. 需求

  1. 实现文件上传进度条展示
  2. 实现耗时异步任务完成消息通知
  3. 其他消息通知

2. 方案

文件上传进度消息:

  1. 后台使用commons-fileupload提供的功能,替代Spirng的文件解析器,注册自定义监听器,通过文件上传监听获取当前Spring框架已经读取的文件进度
  2. 服务模块通过Feign接口向消息模块发送文件上传进度消息
  3. 消息模块收到文件上传进度消息,并通过WebSocket发送给文件上传的用户
  4. 客户端收到进度,渲染上传进度条

异步耗时任务完成消息:

  1. 创建自定义注解@SendMessage
  2. 在需要发送消息的方法上注解@SendMessage
  3. 创建消息通知切面类MessageAspect,对@SendMessage进行环绕切面
  4. 在方法前后通过Feign接口向消息模块发送任务开始、结束消息
  5. 消息模块收到开始、结束消息,通过WebSocket向浏览器发送消息

3. 方案对比

常见方案:

  1. AJAX异步轮询
    优点:简单好用
    缺点:轮询任务很多时效率较低,无法实现服务端通知

  2. WebSocket集群
    WebSocket属于全双工通讯,与服务端建立会话后无法实现多个服务器间的会话共享,需要应用其他方案处理WebSocket集群问题。水平受限,暂未寻找到合适的集群方案,在此不做讨论。
    优点:支持大量用户同时维持WebSocket通讯,服务可拓展集群实现高并发高可用

  3. 单WebSocket消息模块部署
    这个是本案例中采用的方案,仅部署一个消息服务,该消息服务维护着所有与浏览器建立的WebSocket连接,其他模块可以多服务部署,通过Feign接口向消息服务发送消息,消息服务将消息转发给指定用户,消息服务充当中间人角色。
    优点:部署方便,可以实现服务端通知
    缺点:单服务处理能力受限,不支持大量用户,不适用于在线用户多的互联网应用

4. 文件上传进度消息实现

4.1 引入依赖

  <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.4</version>
        </dependency>
        <dependency>
            <groupId>commons-fileupload</groupId>
            <artifactId>commons-fileupload</artifactId>
            <version>1.3.1</version>
        </dependency>
  • 编写自定义文件上传监听器
  • update方法为框架自行调用,因此避免性能问题应限制发送消息的次数
  • update方法参数中pBytesRead pContentLength 均是当前Item,一次上传多个文件时注意需要计算整个文件数量的百分比,但该百分比并不能反映真实进度,因为文件的大小不一致,仅能反映模拟的一个上传进度。
  • MessageDto为自定义消息实体,这个可以根据实际发送消息的格式进行自定义
package com.tba.sc.common.listener;

import com.tba.sc.common.dto.message.MessageDto;
import com.tba.sc.common.enums.EnumMessageType;
import com.tba.sc.common.feign.message.FeignMessageService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.fileupload.ProgressListener;

/**
 * @author wangqichang
 * @since 2020/4/9
 */
@Data
@Slf4j
public class RedisFileUploadProgressListener implements ProgressListener {

    /**
     * 上传UUID
     */
    private String uploadUUID;
    private String taskName;
    private int itemNum = 1;

    private FeignMessageService messageService;

    /**
     * 已读字节数
     */
    private long megaBytes = -1;

    public RedisFileUploadProgressListener(String uploadUUID, String taskName, Integer itemNum, FeignMessageService messageService) {
        this.uploadUUID = uploadUUID;
        this.taskName = taskName;
        this.itemNum = itemNum;
        this.messageService = messageService;
    }

    @Override
    public void update(long pBytesRead, long pContentLength, int pItems) {
        //避免性能问题,每读取1M更新状态
        long mBytes = pBytesRead / 1000000;
        if (megaBytes == mBytes) {
            return;
        }
        megaBytes = mBytes;
        Double doubleLength = new Double(pContentLength);
        if (pContentLength > 0 && pItems > 0) {
            Double ps = pBytesRead / doubleLength * 100 * pItems / itemNum;
            log.info("文件上传监听:上传UUID:{} 当前ITEM:{} 百分比:{}", uploadUUID, pItems, ps);
            try {
                messageService.send(MessageDto.builder().type(EnumMessageType.FILE_UPLOAD_PROCESS.getType()).msgId(uploadUUID).percentage(ps).message(taskName).build());
            } catch (Exception e) {
                log.error("调用Message模块失败,未能发送上传百分比消息");
            }
        }
    }
}

4. 2编写自定义文件上传解析器,封装参数,注册监听

  • 该解析器执行时,springmvc尚未封装参数,因此如果监听器必要参数需要获取时,本例是由前端拼接URL参数,此处从URL中获取必要参数
  • cleanupMultipart方法在整个上传方法结束后调用做清理工作,上传文件后进行业务逻辑处理完毕后才会调用,并不是Controller获取到文件后清理。
package com.tba.sc.common.config;

import com.tba.sc.common.feign.message.FeignMessageService;
import com.tba.sc.common.listener.RedisFileUploadProgressListener;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;

import javax.servlet.http.HttpServletRequest;

/**
 * @author wangqichang
 * @since 2020/4/10
 */
@Slf4j
public class MyCommonsMultipartResolver extends CommonsMultipartResolver {

    RedisTemplate redisTemplate;
    FeignMessageService feignMessageService;

    public MyCommonsMultipartResolver(RedisTemplate redisTemplate, FeignMessageService feignMessageService) {
        this.redisTemplate = redisTemplate;
        this.feignMessageService = feignMessageService;
    }


    /**
     * 注册上传监听
     *
     * @param request
     * @return
     * @throws MultipartException
     */
    @Override
    protected MultipartParsingResult parseRequest(HttpServletRequest request) throws MultipartException {

        //向request设置上传文件ID
        String uuid = IdUtil.fastUUID();
        request.setAttribute(SystemConstants.MSG_ID_PARAM, uuid);
        String encoding = determineEncoding(request);
        FileUpload fileUpload = prepareFileUpload(encoding);
        String queryString = request.getQueryString();
        try {
            RedisFileUploadProgressListener redisFileUploadProgressListener = null;
            if (StrUtil.isNotBlank(queryString)) {
                String[] split = queryString.split("&");
                if (ArrayUtil.isNotEmpty(split) && split.length > 1) {
                    String[] param = split[0].split("=");
                    String[] itemParam = split[1].split("=");
                    //设置监听
                    if (ArrayUtil.isNotEmpty(param) && param.length > 1 && SystemConstants.UPLOAD_TASK_NAME.equals(param[0])) {
                        String taskName = URLDecoder.decode(param[1], "UTF-8");
                        request.setAttribute(SystemConstants.UPLOAD_TASK_NAME, taskName);
                        Integer item = 1;
                        if (SystemConstants.UPLOAD_ITEM_NUM.equals(itemParam[0])) {
                            item = Integer.valueOf(itemParam[1]);
                        }

                        redisFileUploadProgressListener = new RedisFileUploadProgressListener(uuid, taskName, item, feignMessageService);
                        fileUpload.setProgressListener(redisFileUploadProgressListener);
                    }
                }
            }

            List<FileItem> fileItems = ((ServletFileUpload) fileUpload).parseRequest(request);
            return parseFileItems(fileItems, encoding);
        } catch (FileUploadBase.SizeLimitExceededException ex) {
            throw new MaxUploadSizeExceededException(fileUpload.getSizeMax(), ex);
        } catch (FileUploadBase.FileSizeLimitExceededException ex) {
            throw new MaxUploadSizeExceededException(fileUpload.getFileSizeMax(), ex);
        } catch (FileUploadException ex) {
            throw new MultipartException("Failed to parse multipart servlet request", ex);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        }
    }

    /**
     * 上传文件结束
     * @param request
     */
    @Override
    public void cleanupMultipart(MultipartHttpServletRequest request) {
        super.cleanupMultipart(request);
        String uploadId = (String) request.getAttribute(SystemConstants.MSG_ID_PARAM);
        String taskName = (String) request.getAttribute(SystemConstants.UPLOAD_TASK_NAME);
        if (StrUtil.isNotBlank(taskName)) {
            feignMessageService.send(MessageDto.builder().type(EnumMessageType.FILE_UPLOAD_PROCESS.getType()).msgId(uploadId).message(taskName + "任务文件上传完成").percentage(100D).finalNotice(Boolean.TRUE).build());
        }
    }
}

4. 3 向spring容器中注入解析器

根据解析器构造,传入必要参数。该解析器将替代默认实现

@Bean
    MyCommonsMultipartResolver commonsMultipartResolver(RedisTemplate redisTemplate, FeignMessageService feignMessageService) {
        return new MyCommonsMultipartResolver(redisTemplate,feignMessageService);
    }

5 搭建消息服务模块

5.1 核心依赖

spring为WebSocket提供了很好的支持,参照官方文档即可完成服务搭建

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

5.2创建WebSocket配置类

继承WebSocketMessageBrokerConfigurer类,重写registerStompEndpoints() configureMessageBroker() configureClientInboundChannel()方法。

  • registerStompEndpoints方法为注册Stomp端点,暴露用于建立WebSocket的端点接口。其中DefaultHandshakeHandler为端口握手处理,重写determineUser方法,name为当前WebSocket的唯一标识,本例中为用户名(注意,需保证同一时间一个用户只能在一个客户端建立WebSocket连接)
  • configureMessageBroker为配置消息代理,设置前缀及配置消息订阅主题
  • configureClientInboundChannel配置websocket权限,本例中使用stomp携带token标头,实际上仅在建立连接时做判断也是可以的
package com.tba.message.config;

import org.springframework.messaging.Message;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;

import java.security.Principal;
/**
 * STOMP over WebSocket support is available in the spring-messaging and spring-websocket modules. Once you have those dependencies, you can expose a STOMP endpoints, over WebSocket with SockJS Fallback, as the following example shows:
 *
 * @author wangqichang
 * @since 2020/3/13
 */
@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Autowired
    RedisTemplate redisTemplate;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        /**
         * is the HTTP URL for the endpoint to which a WebSocket (or SockJS) client needs to connect for the WebSocket handshake.
         */
        registry
                .addEndpoint("/ws")
                .setHandshakeHandler(new DefaultHandshakeHandler() {
                    @Override
                    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
                        return new UserPrincipal() {
                            @Override
                            public String getName() {
                                //使用了spring security框架时,框架将自动封装Principal
//                                Principal principal = request.getPrincipal();
                                //根据自行权限框架,根据token自行封装Principal
                                List<String> authToken = request.getHeaders().get(SystemConstants.TOKEN_HEADER);
                                if (CollUtil.isNotEmpty(authToken)) {
                                    String token = authToken.get(0);
                                    String redisTokenKey = RedisKeyConstants.TOKEN_PREFIX + token;
                                    CurrentUser user = (CurrentUser) redisTemplate.opsForValue().get(redisTokenKey);
                                    if (ObjectUtil.isNotNull(user)) {
                                        return user.getUsername();
                                    }
                                }
                                throw new ServiceException("无法注册当前连接的用户,请检查是否携带用户凭证");
                            }
                        };
                    }
                })
                .setAllowedOrigins("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        /**
         *  STOMP messages whose destination header begins with /app are routed to @MessageMapping methods in @Controller classes.
         * Use the built-in message broker for subscriptions and broadcasting and route messages whose destination header begins with /topic `or `/queue to the broker.
         */
        config.setApplicationDestinationPrefixes("/app");
        //topic 广播主题消息 queue 一对一消息
        config.enableSimpleBroker("/topic", "/queue");

    }


    /**
     * 从stomp中获取token标头
     *
     * @param registration
     */
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor =
                        MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    List<String> nativeHeader = accessor.getNativeHeader(SystemConstants.TOKEN_HEADER);
                    String token = nativeHeader.get(0);
                    Assert.notNull(token, "未携带用户凭证的请求");

                    //根据token从redis中获取当前用户
                    CurrentUser user = (CurrentUser) redisTemplate.opsForValue().get(RedisKeyConstants.TOKEN_PREFIX + token);
                    if (ObjectUtil.isNotNull(user)) {
                        String username = user.getUsername();
                        accessor.setUser(new com.tba.message.security.UserPrincipal(username));
                        return message;
                    }
                    throw new ServiceException("用户凭证已过期");
                }
                return message;
            }
        });
    }
}

5.3 编写Controller,暴露发送消息的Restful接口

  • 此接口暴露给其他服务调用,通过Message服务,向客户端发送消息。Message服务相当于中间代理,因为客户端仅与Message服务维持WebSocket连接
  • 这个方法从线程变量中取出当前用户username(线程变量中用户信息为拦截器拦截token,查询用户并设置),向该用户发送消息,历史未结束消息放在redis缓存中,每次从redis中查询该用户历史数据,通过msgId更新消息或者新增消息。最后一次提示消息发送成功则从list删除,不进行历史未结束消息的缓存。
package com.tba.message.controller;
import com.tba.sc.common.dto.message.MessageDto;
import com.tba.sc.common.user.CurrentUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;

/**
 * @author wangqichang
 * @since 2020/4/10
 */
@Slf4j
@Controller
@RequestMapping("/msg")
@RestController
public class MsgController {
    @Autowired
    RedisTemplate redisTemplate;

    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;

    @PostMapping(value = "/send")
    public InvokeResult send(@RequestBody MessageDto message) {
        //根据当前http请求中获取用户信息
        CurrentUser current = UserContext.current();
        Assert.notNull(current);

        //从redis中获取当前用户的消息列表
        List<MessageDto> list = (List<MessageDto>) redisTemplate.opsForValue().get(RedisKeyConstants.TOKEN_MSG + current.getToken());
        if (ObjectUtil.isNull(list)) {
            list = new ArrayList<>();
        }
        if (CollUtil.isNotEmpty(list) && StrUtil.isNotBlank(message.getMsgId())) {
            for (int i = 0; i < list.size(); i++) {
                //更新消息
                if (message.getMsgId().equals(list.get(i).getMsgId())) {
                    list.set(i, message);
                    message.setCreateDate(list.get(i).getCreateDate());
                }
            }
        } else {
            //新增消息
            list.add(message);
        }
        try {
            this.simpMessagingTemplate.convertAndSendToUser(current.getUsername(), "/queue", list);
            log.info("用户:{}  消息数量:{} 发送新消息:{}", current.getRealname(), list.size(), message.toString());
            //发送成功,删除消息
            if (message.isFinalNotice()) {
                list.remove(message);
            }
            return InvokeResult.success();
        } catch (Exception e) {
            e.printStackTrace();
            log.error(e.getMessage());
            return InvokeResult.failure("消息发送失败");
        } finally {
            //发送失败,进缓存
            redisTemplate.opsForValue().set(RedisKeyConstants.TOKEN_MSG + current.getToken(), list, 7, TimeUnit.DAYS);
        }
    }
}

5.4 暴露消息Feign接口

@FeignClient(name = "消息服务实例名称", path = "/msg")
public interface FeignMessageService {

    @PostMapping(value = "/send")
    InvokeResult send(@RequestBody MessageDto message);
}

6 耗时任务消息发送

此处通过注解切面,在需要执行的方法前后想Message服务发送消息

6.1 自定义@SendMessage注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SendMessage {
}

6.2 定义MessageAspect切面类

该切面将以@SendMessage注解为切入点,利用反射获取形参名及参数值,封装MessageDto,调用Feign接口向消息模块发送消息

package com.tba.sc.common.advice;
import com.tba.sc.common.dto.message.MessageDto;
import com.tba.sc.common.enums.EnumMessageType;
import com.tba.sc.common.feign.message.FeignMessageService;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;

import java.lang.reflect.Method;
import java.lang.reflect.Parameter;

/**
 * @author wangqichang
 * @since 2020/4/15
 */
@Slf4j
@Aspect
@Component
public class MessageAspect {

    @Autowired
    FeignMessageService feignMessageService;

    @Around("@annotation(com.tba.sc.common.annotation.SendMessage)")
    public Object BeforeMethod(ProceedingJoinPoint jp) throws Throwable {

        MethodSignature methodSignature = (MethodSignature) jp.getSignature();
        Method method = methodSignature.getMethod();
        Object[] args = jp.getArgs();
        //注意:该方法需指定编译插件-parameters参数,否则无法获取到形参名称。配置在pom中maven-compiler-plugin
        Parameter[] parameters = method.getParameters();
        String taskName = null;
        String taskId = null;
        String url = null;
        methodSignature.getParameterNames();
        for (Parameter parameter : parameters) {
            Integer index = (Integer) ReflectUtil.getFieldValue(parameter, "index");
            if ("taskName".equals(parameter.getName()) && ArrayUtil.isNotEmpty(args)) {
                taskName = (String) args[index];
            } else if ("id".equals(parameter.getName())) {
                taskId = (String) args[index];
            } else if ("url".equals(parameter.getName())) {
                url = (String) args[index];
            }
        }
        log.info("taskName:{} id:{} url:{}", taskName, taskId, url);
        if (StrUtil.isNotBlank(taskName)) {
            String msgId = IdUtil.fastUUID();
            MessageDto msg = MessageDto.builder()
                    .msgId(msgId)
                    .type(EnumMessageType.BUSINESS_NOTICE.getType())
                    .finalNotice(Boolean.FALSE)
                    .createDate(new Date())
                    .message(taskName + "任务开始")
                    .taskId(taskId)
                    .url(url)
                    .build();

            try {
                log.info("发送消息:{}", msg.toString());
                feignMessageService.send(msg);
                Object proceed = jp.proceed();
                msg.setFinalNotice(Boolean.TRUE);
                msg.setMessage(taskName + "任务完成");
                msg.setSuccess(Boolean.TRUE);
                log.info("发送消息:{}", msg.toString());
                feignMessageService.send(msg);
                return proceed;

            } catch (Throwable throwable) {
                msg.setFinalNotice(Boolean.TRUE);
                msg.setMessage(taskName + "任务异常结束");
                msg.setSuccess(Boolean.FALSE);
                log.info("发送消息:{}", msg.toString());
                feignMessageService.send(msg);
                throw throwable;
            }
        }
        log.info("未能获取到任务名称参数,未发送消息");
        return jp.proceed();
    }
}

6.2在所需接口上注解@SendMessage,并声明形参

  • 此处部分参数并未传递给Service,目的是为了切面类可以拿到形参及实参封装消息实体
    @PostMapping("/xxx")
    @SendMessage
    public InvokeResult xxx(String id, String taskName,String url) {
       xxxService.xxx(id);
        return InvokeResult.success();
    }

7. 效果展示

文件上传监听日志,成功监听上传进度

2020-05-18 18:01:28.706  INFO 16744 --- [io-6001-exec-30] .t.s.c.l.RedisFileUploadProgressListener : 文件上传监听:上传UUID:fdbdf76f-3421-436a-8614-837aa8fe7972 当前ITEM:1 百分比:93.90534762273536
2020-05-18 18:01:28.743  INFO 16744 --- [io-6001-exec-30] .t.s.c.l.RedisFileUploadProgressListener : 文件上传监听:上传UUID:fdbdf76f-3421-436a-8614-837aa8fe7972 当前ITEM:1 百分比:96.51222534735146
2020-05-18 18:01:28.787  INFO 16744 --- [io-6001-exec-30] .t.s.c.l.RedisFileUploadProgressListener : 文件上传监听:上传UUID:fdbdf76f-3421-436a-8614-837aa8fe7972 当前ITEM:1 百分比:99.11910307196756

文件上传进度消息发送日志

2020-05-15 14:37:23.033  INFO 2924 --- [nio-9015-exec-9] c.tba.message.controller.MsgController   : 用户:超管  消息数量:1 发送新消息:MessageDto{msgId='3cce247c-5e67-46e1-9d18-3e4bc25cc1e4', type=2, taskId='null', message='11', percentage=99.57479978077085, finalNotice=false, success=false, createDate=null, url='null'}
2020-05-15 14:37:24.125  INFO 2924 --- [io-9015-exec-13] c.tba.message.controller.MsgController   : 用户:超管  消息数量:1 发送新消息:MessageDto{msgId='3cce247c-5e67-46e1-9d18-3e4bc25cc1e4', type=2, taskId='null', message='11', percentage=99.79995204151234, finalNotice=false, success=false, createDate=null, url='null'}

耗时任务消息模块发送日志

2020-05-15 10:50:40.501  INFO 2924 --- [MessageBroker-5] o.s.w.s.c.WebSocketMessageBrokerStats    : WebSocketSession[3 current WS(2)-HttpStream(0)-HttpPoll(1), 13 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(11)-CONNECTED(10)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 120], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 43], sockJsScheduler[pool size = 8, active threads = 1, queued tasks = 4, completed tasks = 651]
2020-05-15 10:50:57.728  INFO 2924 --- [io-9015-exec-10] c.tba.message.controller.MsgController   : 用户:超管  消息数量:1 发送新消息:MessageDto{msgId='debbafbf-63a3-432e-8107-15cf03becebe', type=3, taskId='8afad4bd7202f283017212458b9d0111', message='测试002N3-N4任务开始', percentage=null, finalNotice=false, success=false, createDate=Fri May 15 18:50:57 CST 2020, url='/operation?type=2&id=8afad4bd7202f28301721245d3cf0112&taskName=%E6%B5%8B%E8%AF%95002&taskType=1'}
2020-05-15 10:51:06.304  INFO 2924 --- [io-9015-exec-11] c.tba.message.controller.MsgController   : 用户:超管  消息数量:1 发送新消息:MessageDto{msgId='debbafbf-63a3-432e-8107-15cf03becebe', type=3, taskId='8afad4bd7202f283017212458b9d0111', message='测试002N3-N4任务完成', percentage=null, finalNotice=true, success=true, createDate=Fri May 15 18:50:57 CST 2020, url='/operation?type=2&id=8afad4bd7202f28301721245d3cf0112&taskName=%E6%B5%8B%E8%AF%95002&taskType=1'}

前端消息渲染效果


image.png

大功告成!
尚有诸多缺点,但保证了基础功能够用,诸位大佬可以做个小参考。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345