Springboot 实现简易短链功能

1. 什么是 URL 短链

URL 短链,就是把原来较长的网址,转换成比较短的网址。我们可以在短信和微博里可以经常看到短链的身影。如下图:

image

上图所示短信中,蓝色链接就是一条短链。 用户点击蓝色的短链,就可以在浏览器中看到它对应的原网址

那么为什么要做这样的转换呢?来看看短链带来的好处:

  • 在微博, Twitter 这些限制字数的应用中,短链带来的好处不言而喻: 网址短、美观、便于发布、传播,可以写更多有意义的文字;
  • 在短信中,如果含长网址的短信内容超过 70 字,就会被拆成两条发送,而用短链则可能一条短信就搞定,如果短信量大也可以省下不少钱;
  • 我们平常看到的二维码,本质上也是一串 URL ,如果是长链,对应的二维码会密密麻麻,扫码的时候机器很难识别,而短链则不存在这个问题;
  • 出于安全考虑,不想让有意图的人看到原始网址。

2. 库表设计

短链表

CREATE TABLE `short_url` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增id',
  `long_url` varchar(500) DEFAULT NULL COMMENT '长链接',
  `short_url` varchar(30) NOT NULL COMMENT '短链接',
  `title` varchar(100) DEFAULT NULL COMMENT '短链名称',
  `deleted` int(11) DEFAULT '0' COMMENT '逻辑删除',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `punter` varchar(30) CHARACTER SET utf8 DEFAULT NULL COMMENT '客户',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uk_short_url` (`short_url`) USING BTREE,
  KEY `idx_long_url` (`long_url`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='短链列表';

访问表

CREATE TABLE `short_url_access` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增id',
  `short_url` varchar(30) DEFAULT NULL COMMENT '短链接',
  `access_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '访问时间',
  `ip` varchar(30) DEFAULT NULL COMMENT '访问ip',
  `device` varchar(30) DEFAULT NULL COMMENT '设备型号',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `idx_short_url` (`short_url`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='短链访问列表';

3.相关代码

只贴核心代码,其他代码自行编写

重定向Controller

    /**
     * 短链重定向
     *
     * @param mav      视图
     * @param shortUrl 短链
     * @return 重定向
     */
    @RequestMapping(value = {"/redirect/{shortUrl}", "/redirect"})
    public ModelAndView redirect(@PathVariable(value = "shortUrl", required = false) String shortUrl, ModelAndView mav,
                                 HttpServletRequest httpServletRequest) {
        return shortUrlService.redirect(mav, shortUrl, httpServletRequest);
    }

重定向Service实现

   /**
     * 重定向根路径
     */
    final String BASE_URL = "";

    /**
     * 路径错误地址
     */
    final String ERROR_URL = "";

    /**
     * 重定向前缀
     */
    final String REDIRECT_PREFIX = "redirect:";

    /**
     * 重定向
     *
     * @param mav                视图模型信息
     * @param shortUrl           短链地址
     * @param httpServletRequest 请求信息
     * @return 结果
     */
    @Override
    public ModelAndView redirect(ModelAndView mav, String shortUrl, HttpServletRequest httpServletRequest) {
        ShortUrl shortUrlData = getOne(new LambdaQueryWrapper<ShortUrl>().eq(ShortUrl::getShortUrl, BASE_URL + shortUrl));

        // 成功重定向到该地址
        if (Objects.nonNull(shortUrlData)) {
            // 插入访问记录
            shortUrlAccessService.save(new ShortUrlAccess()
                    .setShortUrl(BASE_URL + shortUrl)
                    .setIp(IpUtils.getIpAddr(httpServletRequest))
                    .setDevice(getDeviceInfo(httpServletRequest.getHeader("User-Agent"))));

            mav.setViewName(REDIRECT_PREFIX + shortUrlData.getLongUrl());
            return mav;
        }

        mav.setViewName(REDIRECT_PREFIX + ERROR_URL);
        return mav;
    }

  /**
     * 创建短链
     *
     * @return 短链
     */
    private String createShortUrl() {
        List<String> shortUrlList = list(new LambdaQueryWrapper<ShortUrl>()
                .select(ShortUrl::getShortUrl)).stream()
                .map(item -> item.getShortUrl().replace(BASE_URL, ""))
                .collect(Collectors.toList());
        // 这里的逻辑先简单判断,如果后期用户多了需要加锁
        String shortUrl = IdUtil.nanoId(6);
        if (shortUrlList.contains(shortUrl)) {
            int i = 0;
            int max = 100;
            do {
                if (i == max) {
                    throw new ServiceException("生成短链失败,请稍后重试");
                }
                shortUrl = IdUtil.nanoId(6);
                i++;
            } while (!shortUrlList.contains(shortUrl));
        }
        return shortUrl;
    }

    /**
     * 获取设备信息
     *
     * @param userAgent 请求头信息
     * @return 结果
     */
    public String getDeviceInfo(String userAgent) {
        if (userAgent.contains("iPhone")) {
            return "iPhone";
        } else if (userAgent.contains("Android")) {
            return "Android";
        } else if (userAgent.contains("Windows")) {
            return "windows";
        }
        return "未知设备";
    }

统计访问数据

 /**
     * 查询访问信息
     *
     * @param shortUrlAccess 对象
     * @return 结果
     */
    @Override
    public List<ShortUrlAccessInfoVO> queryAccessInfo(ShortUrlAccess shortUrlAccess) {
        Map<String, Object> params = shortUrlAccess.getParams();
        DateTime beginAccessTime = DateUtil.parseDate(params.get("beginAccessTime").toString());
        DateTime endAccessTime = DateUtil.parseDate(params.get("endAccessTime").toString());

        // 初始化参数,判断单位为小时或者天
        long between = DateUtil.between(beginAccessTime, endAccessTime, DateUnit.DAY);
        boolean isHour = Objects.equals(1L, between);
        Function<? super ShortUrlAccess, ? extends String> groupFunction;
        Function<String, String> formatFunction;
        LinkedList<String> timeList;
        if (isHour) {
            groupFunction = item -> String.valueOf(DateUtil.hour(item.getAccessTime(), true));
            formatFunction = item -> item + "点";
            timeList = IntStream.rangeClosed(0, 23)
                    .mapToObj(String::valueOf)
                    .collect(Collectors.toCollection(LinkedList::new));
        } else {
            groupFunction = item -> DateUtil.format(item.getAccessTime(), DatePattern.NORM_DATE_PATTERN);
            formatFunction = Function.identity();
            timeList = DateUtil.rangeToList(beginAccessTime, DateUtil.offsetDay(endAccessTime, -1), DateField.DAY_OF_MONTH).stream()
                    .map(item -> DateUtil.format(item, DatePattern.NORM_DATE_PATTERN))
                    .collect(Collectors.toCollection(LinkedList::new));
        }
        return this.buildAccessInfo(shortUrlAccess, groupFunction, formatFunction, beginAccessTime, endAccessTime, timeList);
    }


    /**
     * 生成访问数据
     *
     * @param shortUrlAccess  对象
     * @param groupFunction   分组函数
     * @param formatFunction  格式化函数
     * @param beginAccessTime 开始时间
     * @param endAccessTime   结束时间
     * @param timeList        时间列表
     * @return 结果
     */
    private List<ShortUrlAccessInfoVO> buildAccessInfo(ShortUrlAccess shortUrlAccess,
                                                       Function<? super ShortUrlAccess, ? extends String> groupFunction,
                                                       Function<String, String> formatFunction,
                                                       DateTime beginAccessTime,
                                                       DateTime endAccessTime,
                                                       LinkedList<String> timeList) {
        // 指定短链指定时间段内的访问记录
        List<ShortUrlAccess> shortUrlAccessList = list(new LambdaQueryWrapper<ShortUrlAccess>()
                .eq(ShortUrlAccess::getShortUrl, shortUrlAccess.getShortUrl())
                .between(ShortUrlAccess::getAccessTime, beginAccessTime, endAccessTime));

        // 生成不同ip第一次访问的时间集合,用于后面求uv
        Map<String, Optional<ShortUrlAccess>> firstAccessMap = shortUrlAccessList.stream()
                .collect(Collectors.groupingBy(ShortUrlAccess::getIp, Collectors.minBy(Comparator.comparing(ShortUrlAccess::getAccessTime))));

        // 小时/日分组
        Map<String, List<ShortUrlAccess>> groupMap = shortUrlAccessList.stream()
                .collect(Collectors.groupingBy(groupFunction));

        return timeList.stream()
                .map(index -> {
                    // 获取当前小时访问记录
                    List<ShortUrlAccess> accessList = groupMap.getOrDefault(index, Collections.emptyList());
                    // 如果ip第一次访问则算一次访客,否则不记
                    Integer uv = accessList.stream()
                            .map(item -> {
                                Optional<ShortUrlAccess> firstAccess = firstAccessMap.get(item.getIp());
                                return Objects.equals(item.getId(), firstAccess.orElse(new ShortUrlAccess()).getId()) ? 1 : 0;
                            })
                            .reduce(Integer::sum).orElse(0);
                    // 封装返回值
                    ShortUrlAccessInfoVO vo = new ShortUrlAccessInfoVO();
                    vo.setTime(formatFunction.apply(index));
                    vo.setPv(accessList.size());
                    vo.setUv(uv);
                    return vo;
                })
                .collect(Collectors.toList());
    }

4.效果

短链页面

image

统计页面

image

posted @ 2025-01-08 10:08  启航黑珍珠号  阅读(229)  评论(0)    收藏  举报