springboot第65集:字节跳动一面经,一文让你走出微服务迷雾架构周刊

共 60222字,需浏览 121分钟

 ·

2024-04-11 14:16

如今要考虑做分库分表时,可首先选用当当网的Sharding-Sphere框架,早些年原本只有Sharding-JDBC驱动层的分库分表,但到了后续又推出了代理层的Sharding-Proxy中间件,最终合并成立了Sharding-Sphere项目。

在之前的单库模式下,业务系统需要使用数据库时,只需要在相关的配置文件中,配置单个数据源的地址、用户、密码等信息即可。但分库分表后由于存在多个数据源,程序怎么样访问数据库,配置和代码该怎么写呢?

MySQL之分库分表后带来的“副作用”

之前在库中只存在一张表,所以非常轻松的就能进行联表查询获取数据,但是此时做了水平分表后,同一张业务的表存在多张小表,这时再去连表查询时具体该连接哪张呢?似乎这时问题就变的麻烦起来了,怎么办?解决方案如下:

  • ①如果分表数量是固定的,直接对所有表进行连接查询,但这样性能开销较大,还不如不分表。
  • ②如果不想用①,或分表数量会随时间不断变多,那就先根据分表规则,去确定要连接哪张表后再查询。
  • ③如果每次连表查询只需要从中获取1~3个字段,就直接在另一张表中设计冗余字段,避免连表查询。

现在是按月份来分表,那在连表查询前,就先确定要连接哪几张月份的表,才能得到自己所需的数据,确定了之后再去查询对应表即可

  • ①放入第三方中间件中,然后依赖于第三方中间件完成,如ES
  • ②定期跑脚本查询出一些常用的聚合数据,然后放入Redis缓存中,后续从Redis中获取。
  • ③首先从所有表中统计出各自的数据,然后在Java中作聚合操作。

比如count()函数,就是对所有表进行统计查询,最后在Java中求和,好比分组、排序等工作,先从所有表查询出符合条件的数据,然后在Java中通过Stream流进行处理。

垂直分库是按照业务属性的不同,直接将一个综合大库拆分成多个功能单一的独享库,分库之后能够让性能提升N倍,但随之而来的是需要解决更多的问题,而且问题会比单库分表更复杂!

因为将不同业务的表拆分到了不同的库中,而往往有些情况下可能会需要其他业务的表数据,在单库时直接join连表查询相应字段数据即可,但此时已经将不同的业务表放到不同库了,这时咋办?跨库Join也不太现实呀,此时有如下几种解决方案:

  • ①在不同的库需要数据的表中冗余字段,把常用的字段放到需要要数据的表中,避免跨库连表。
  • ②选择同步数据,通过广播表/网络表/全局表将对应的表数据直接完全同步一份到相应库中。
  • ③在设计库表拆分时创建ER绑定表,具备主外键的表放在一个库,保证数据落到同一数据库。
  • Java系统中组装数据,通过调用对方服务接口的形式获取数据,然后在程序中组装后返回。

分布式事务应该是分布式系统中最核心的一个问题,这个问题绝对不能出现,一般都要求零容忍,也就是所有分布式系统都必须要解决分布式事务问题,否则就有可能造成数据不一致性。

在之前单机的MySQL中,数据库自身提供了完善的事务管理机制,通过begin、commit/rollback的命令可以灵活的控制事务的提交和回滚,在Spring要对一组SQL操作使用事务时,也只需在对应的业务方法上加一个@Transactional注解即可,但这种情况在分布式系统中就不行了。

为什么说MySQL的事务机制会在分布式系统下失效呢?因为InnoDB的事务机制是建立在Undo-log日志的基础上完成的,以前只有一个Undo-log日志,所以一个事务的所有变更前的数据,都可以记录在同一个Undo-log日志中,当需要回滚时就直接用Undo-log中的旧数据覆盖变更过的新数据即可。

但垂直分库之后,会存在多个MySQL节点,这自然也就会存在多个Undo-log日志,不同库的变更操作会记录在各自的Undo-log日志中,当某个操作执行失败需要回滚时,仅能够回滚自身库变更过的数据,对于其他库的事务回滚权,当前节点是不具备该能力的,所以此时就必须要出现一个事务管理者来介入,从而解决分布式事务问题。

解决方案如下:

  • Best Efforts 1PC模式。
  • XA 2PC、3PC模式。
  • TTC事务补偿模式。
  • MQ最终一致性事务模式。

目前最常用的Seata的两种模式就是基于XA-3PCTCC思想实现的

这种情况前面说到过,经过垂直分库之后,某些核心业务库依旧需要承载过高的并发流量,因此一单节点模式部署,依然无法解决所存在的性能瓶颈,对于这种情况直接再做水平分库即可

水平分库这种方案,能够建立在垂直分库的基础上,进一步对存储层做拓展,能够让某些业务库具备更高的并发处理能力,不过水平分库虽然带来的性能收益巨大,但产生的问题也最多!

对单个业务库做了水平分库后,也就是又对单个业务库做了横向拓展后,一般都会将库中所有的表做水平切分,也就是不同库中的所有表,每个水平库节点中存储的数据是不同的,这时又会出现4.2阶段聊到的一些问题,如单业务的聚合操作、连表操作会无法进行,这种情况的解决思路和水平分表时一样,先确定读写的数据位于哪个库表中,然后再去生成SQL并执行。

MySQL数据库为例,如果是在之前的单库环境中,可以直接通过limit index,n的方式来做分页,而水平分库后由于存在多个数据源,因此分页又成为了一个难题,比如10条数据为1页,那如果想要拿到某张表的第一页数据

这种方式可以是可以,但略微有些繁杂,同时也会让拓展性受限,比如原本有两个水平分库的节点,因此只需要从两个节点中拿到第一页数据,然后再做一次过滤即可,但如果水平库从两节点扩容到四节点,这时又要从四个库中各自拿10条数据,然后做过滤操作,读取前十条数据显示,这显然会导致每次扩容需要改动业务代码,对代码的侵入性有些强,所以合理的解决方案如下:

  • ①常用的分页数据提前聚合到ES或中间表,运行期间跑按时更新其中的分页数据。
  • ②利用大数据技术搭建数据中台,将所有子库数据汇聚到其中,后续的分页数据直接从中获取。
  • ③从所有字库中先拿到数据,然后在Service层再做过滤处理。

对于一张表的主键通常会选用整数型字段,然后通过数据库的自增机制来保证唯一性,但在水平分库多节点的情况时,假设还是以数据库自增机制来维护主键唯一性,这就绝对会出现一定的问题,可能会导致多个库中出现ID相同、数据不同的情况

两个库需要存储不同的数据,当插入数据的请求被分发到对应节点时,如果再依据自增机制来确保ID唯一性,因为这里有两个数据库节点,两个数据库各自都维护着一个自增序列,因此两者ID值都是从1开始往上递增的,这就会导致前面说到的ID相同、数据不同的情况出现

保障分布式系统下ID唯一性的解决方案很多,如下:

  • ①通过设置数据库自增机制的起始值和步长,来控制不同节点的ID交叉增长,保证唯一性。
  • ②在业务系统中,利用特殊算法生成有序的分布式ID,比如雪花算法、Snowflake算法等。
  • ③利用第三方中间件生产ID,如使用Redisincr命令、或创建独立的库专门做自增ID工作。

第一种方案成本最低,但是会限制节点的拓展性,也就是当后续扩容时,数据要做迁移,同时要重新修改起始值和自增步长。

一般企业中都会使用第二种方案,也就是通过分布式ID生成的算法,在业务系统中生成有序的分布式ID,以此来确保ID的唯一性

数据的拆分规则,或者被称之为路由规则,具体的还是要根据自己的业务来进行选择,在拆分时只需遵循:数据分布均匀、查询方便、扩容/迁移

一般简单常用的数据分片规则如下:

  • ①随机分片:随机分发写数据的请求,但查询时需要读取全部节点才能拿取数据,一般不用。
  • ②连续分片:每个节点负责存储一个范围内的数据,如DB1:1~500W、DB2:500~1000W....
  • ③取模分片:通过整数型的ID值与水平库的节点数量做取模运算,最终得到数据落入的节点。
  • ④一致性哈希:根据某个具备唯一特性的字段值计算哈希值,然后再通过哈希值做取模分片。
  • ..........

流量迁移、容量规划、节点扩容等这些问题,在做架构改进时基本上都会碰到

线上环境从单库切换到分库分表模式,数据该如何迁移才能保证线上业务不受影响,对于这个问题来说,首先得写脚本将老库的数据同步到分库分表后的各个节点中,然后条件允许的情况下先上灰度发布,划分一部分流量过来做运营测试。

如果没有搭建完善的灰度环境,那先再本地再三测试确保没有问题后,采用最简单的方案,先挂一个公告:“今日凌晨两点后服务器会进入维护阶段,预计明日早晨八点会恢复正常运转”!然后停机更新,但前提工作做好,如:Java代码从单库到分库分表要改进完善、数据迁移要做好、程序调试一切无误后再切换,同时一定要做好版本回滚支持,如果迁移流量后出现问题,可以快捷切换回之前的老库。

线上环境从单库切换到分库分表模式,数据该如何迁移才能保证线上业务不受影响,对于这个问题来说,首先得写脚本将老库的数据同步到分库分表后的各个节点中,然后条件允许的情况下先上灰度发布,划分一部分流量过来做运营测试。

如果没有搭建完善的灰度环境,那先再本地再三测试确保没有问题后,采用最简单的方案,先挂一个公告:“今日凌晨两点后服务器会进入维护阶段,预计明日早晨八点会恢复正常运转”!然后停机更新,但前提工作做好,如:Java代码从单库到分库分表要改进完善、数据迁移要做好、程序调试一切无误后再切换,同时一定要做好版本回滚支持,如果迁移流量后出现问题,可以快捷切换回之前的老库。

关于分库分表首次到底切分成多少个节点合适,对于这点业内没有明确规定,更多的要根据自身业务的流量、并发情况决定,根据实际情况先做垂直分库,然后再对于核心库做水平分库,但水平分库的节点数量要保证的是2的整倍数,方便后续扩容。

扩容一般是指水平分库,也就是当一个业务库无法承载流量压力时,需要对相应的业务的节点数量,但扩容时必须要考虑本次增加节点会不会影响之前的业务,因为很多情况下,当节点的数量发生改变时,可能会影响数据分片的路由规则,这时就要考虑扩容是否会影响原本的路由规则。

扩容一般都是基于水平分库的基础上,进一步对水平库做节点扩容,目前业内有两种主流做法:水平双倍扩容法、异步双写扩容法。

水平双倍扩容法

想要使用双倍扩容法对节点进行扩容,首先必须要求原先节点数为2的整数倍,同时路由规则必须要为数值取模法、或Hash取模法,否则依旧会造成扩容难度直线提升。同时双倍扩容法还有一种进阶做法,被称之为从库升级法,也就是给原本每个节点都配置一个从库,然后同步主节点的所有数据,当需要扩容时仅需将从库升级为主节点即可

起初某个业务的水平库节点数量为2,因此业务服务中的数据源配置为{DB0:144.xxx.xxx.001、DB1:144.xxx.xxx.002},当读写数据时,如果路由键经哈希取模运算后的结果为0,则将对应请求落到DB0处理,如若取模结果为1,则将数据落到DB1中处理,此时两节点的数据如下:

  • DB0:{2、4、6、8、10、12、14、16.....}
  • DB1:{1、3、5、7、9、11、13、15......}

同时DB0、DB1两个节点都各有一个从节点,从节点会同步各自主节点的所有数据,此时假设两个节点无法处理请求压力时,需要进一步对水平库做扩容,这时可直接将从节点升级为主节点

技术栈

  • 注册中心:Nacos
  • 配置中心:Nacos
  • 服务网关:Spring cloud Gateway
  • 服务调用:Spring cloud open-Feign
  • 负载均衡:Spring cloud loadbalancer
  • 链路追踪:zipkin - sleuth
  • 权限认证:Spring security
  • 熔断降级:Sentinel
  • 消息队列:RabbitMQ
  • 项目部署:Docker

vue中使用高德地图实现历史轨迹回放并能控制播放轨迹的倍速

      
      this.marker = new AMap.Marker({
    position: [108.478935, 34.997761],
    icon: "https://qinglite-1253448069.cos.ap-shanghai.myqcloud.com/web/82e21d203d6b51c8deabe450ea9240549e987212",
    offset: new AMap.Pixel(-13, -26),
});
this.map.add(this.marker)

const lineArr= [[108.478935, 34.997761], [108.478934, 34.997825], [108.478912, 34.998549], [108.478912, 34.998549], [108.478998, 34.998555], [108.478998, 34.998555], [108.479282, 34.99856], [108.479658, 34.998528], [108.480151, 34.998453], [108.480784, 34.998302], [108.480784, 34.998302], [108.481149, 34.998184], [108.481573, 34.997997], [108.481863, 34.997846], [108.482072, 34.997718], [108.482362, 34.997718], [108.483633, 34.998935], [108.48367, 34.998968], [108.484648, 34.999861]] // 轨迹
// 绘制轨迹
this.polyline = new AMap.Polyline({
    path: lineArr,
    showDir: true,
    strokeColor: "#28F",  //线颜色
    // strokeOpacity: 1,     //线透明度
    strokeWeight: 6,      //线宽
    // strokeStyle: "solid"  //线样式
});
this.map.add(this.polyline)

// 走过的路径
 this.passedPolyline = new AMap.Polyline({
    strokeColor: "#AF5",  //线颜色
    strokeWeight: 6,      //线宽
 });
this.map.add(this.passedPolyline)

AMap.plugin('AMap.MoveAnimation', () => {
   console.log('开始回放')
    this.marker.moveAlong(this.lineArr, {
        // 每一段的时长
        duration: this.duration,//可根据实际采集时间间隔设置
       // JSAPI2.0 是否延道路自动设置角度在 moveAlong 里设置
       autoRotation: true,
   });
})

// 监听marker移动
this.marker.on('moving', (e) => {
    console.log('marker动了', e)
    this.passedPolyline.setPath(e.passedPath); // 设置路径样式
    this.map.setCenter(e.target.getPosition(), true) // 设置地图中心点
});

<template>
    <div style="position: relative;">
        <div style="position: absolute; right: 10px; top: 10px; z-index: 1;">
            <el-button @click="startMove">开始回放</el-button>
            <el-button @click="pauseAnimation">暂停回放</el-button>
            <el-button @click="resumeAnimation">继续回放</el-button>
            <el-select v-model="speed" style="width: 100px; margin-left: 10px;" placeholder="选择倍速" @change="handleSelect($event)">
                <el-option :value="'1倍速'">1倍速</el-option>
                <el-option :value="'2倍速'">2倍速</el-option>
                <el-option :value="'3倍速'">3倍速</el-option>
                <el-option :value="'4倍速'">4倍速</el-option>
            </el-select>
        </div>
        <!-- <div style="position:absolute; left: 50px; bottom: 30px; z-index: 1; width: 95%; height: 20px;">
            <el-progress :percentage="percentage"></el-progress>
        </div> -->
        <div id="amapcontainer" style="width: 100%; height: 880px"></div>
    </div>
</template>
  
<script>
import AMapLoader from '@amap/amap-jsapi-loader';
window._AMapSecurityConfig = {
    securityJsCode: '' // '「申请的安全密钥」',
}
export default {
    data() {
        return {
            map: null, // 高德地图实例
            lineArr: [[108.478935, 34.997761], [108.478934, 34.997825], [108.478912, 34.998549], [108.478912, 34.998549], [108.478998, 34.998555], [108.478998, 34.998555], [108.479282, 34.99856], [108.479658, 34.998528], [108.480151, 34.998453], [108.480784, 34.998302], [108.480784, 34.998302], [108.481149, 34.998184], [108.481573, 34.997997], [108.481863, 34.997846], [108.482072, 34.997718], [108.482362, 34.997718], [108.483633, 34.998935], [108.48367, 34.998968], [108.484648, 34.999861]], // 轨迹
            marker: null,
            polyline: null,
            speed: '1倍速',
            duration: 500,  // 轨迹回放时间
            percentage: 50, // 进度条进度
        }
    },
    methods: {
        // 地图初始化
        initAMap() {
            AMapLoader.load({
                key: "", // 申请好的Web端开发者Key,首次调用 load 时必填
                version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
                plugins: ["AMap.Scale""AMap.ToolBar""AMap.ControlBar"'AMap.Geocoder''AMap.Marker',
                    'AMap.CitySearch''AMap.Geolocation''AMap.AutoComplete''AMap.InfoWindow'], // 需要使用的的插件列表,如比例尺'AMap.Scale'
            }).then((AMap) => {
                // 获取到作为地图容器的DOM元素,创建地图实例
                this.map = new AMap.Map("amapcontainer", { //设置地图容器id
                    resizeEnable: true,
                    viewMode: "3D", // 使用3D视图
                    zoomEnable: true, // 地图是否可缩放,默认值为true
                    dragEnable: true, // 地图是否可通过鼠标拖拽平移,默认为true
                    doubleClickZoom: true, // 地图是否可通过双击鼠标放大地图,默认为true
                    zoom: 17, //初始化地图级别
                    center: [108.347428, 34.90923], // 初始化中心点坐标 北京
                    // mapStyle: "amap://styles/darkblue", // 设置颜色底层
                })

                this.marker = new AMap.Marker({
                    position: [108.478935, 34.997761],
                    icon: "https://qinglite-1253448069.cos.ap-shanghai.myqcloud.com/web/82e21d203d6b51c8deabe450ea9240549e987212",
                    offset: new AMap.Pixel(-13, -26),
                });
                this.map.add(this.marker)

                // 绘制轨迹
                this.polyline = new AMap.Polyline({
                    path: this.lineArr,
                    showDir: true,
                    strokeColor: "#28F",  //线颜色
                    // strokeOpacity: 1,     //线透明度
                    strokeWeight: 6,      //线宽
                    // strokeStyle: "solid"  //线样式
                });
                this.map.add(this.polyline)

                // 走过的路径
                this.passedPolyline = new AMap.Polyline({
                    strokeColor: "#AF5",  //线颜色
                    strokeWeight: 6,      //线宽
                });
                this.map.add(this.passedPolyline)

                // 监听marker移动
                this.marker.on('moving', (e) => {
                    console.log('marker动了', e)
                    this.passedPolyline.setPath(e.passedPath); // 设置路径样式
                    this.map.setCenter(e.target.getPosition(), true) // 设置地图中心点
                });

                this.map.setFitView(); // 根据覆盖物自适应展示地图

            }).catch(e => {
                console.log(e)
            })
        },
        // 开始回放
        startMove() {
            AMap.plugin('AMap.MoveAnimation', () => {
                console.log('开始回放')
                this.marker.moveAlong(this.lineArr, {
                    // 每一段的时长
                    duration: this.duration,//可根据实际采集时间间隔设置
                    // JSAPI2.0 是否延道路自动设置角度在 moveAlong 里设置
                    autoRotation: true,
                });
            })

        },
        // 暂停回放
        pauseAnimation() {
            this.marker.pauseMove();
        },
        // 继续回放
        resumeAnimation() {
            this.marker.resumeMove();
        },
        // 倍速控制
        handleSelect(e) {
            console.log('e',parseInt(e.charAt(0)))
            this.duration = 500 / parseInt(e.charAt(0))
        }

    },
    mounted() {
        //DOM初始化完成进行地图初始化
        this.initAMap()
    }
}
</script>
  
<style lang="less"></style>
  

      
      let _legendColor = '#8FD8FF';
let _fontSize = 40;
let _fontColor = '#8FD8FF';
let data1 = [70, 90, 100]; 
let data2 = [80, 90, 100]; 
option = {
    backgroundColor:"#102F63",
    legend: {
        x: 'center',
        y: '3%',
        itemWidth: 30,
        itemHeight: 30,
        textStyle: {
            fontSize: 40,
            color: 'rgba(255,255,255,.7)',
        },
    },
    grid: {
        left: '5%',
        top: '25%',
        right: '5%',
        bottom: '10%',
        containLabel: true,
    },
    yAxis: {
        name: '',
        type'value',
        axisLabel: {
            show: false,
        },
        splitLine: {
            show: false,
        },
        axisTick: {
            show: false,
        },
        axisLine: {
            show: false,
        },
    },
    xAxis: {
        type'category',
        nameTextStyle: {
            fontSize: 40,
            color: '#7dd6ea',
        },
        axisLabel: {
            show: true,
            interval: 0,
            margin: 20,
            textStyle: {
                color: '#7dd6ea',
                fontSize: 40,
            },
        },
        splitLine: {
            show: false,
        },
        axisTick: {
            show: false,
        },
        axisLine: {
            show: true,
            lineStyle: {
                color: '#4E84AC',
                width: 3,
            },
        },
        data: ['2022年''2025年''2030年'],
    },
    series: [
        {
            name: 'blue',
            type'bar',
            barWidth: 53,
            barGap: '70%',
            itemStyle: {
                normal: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        {
                            offset: 0,
                            color: 'rgba(0,234,255, 1)',
                        },
                        {
                            offset: 1,
                            color: 'rgba(0,234,255, .1)',
                        },
                    ]),
                },
            },
            label: {
                show: true,
                position: 'top',
                textStyle: {
                    fontSize: 36,
                    color: '#00EAFF',
                },
                formatter: function (params) {
                    return params.value + '%';
                },
            },
            data: data1,
            z: 10,
            zlevel: 0,
        },
        {
            // 分隔
            type'pictorialBar',
            itemStyle: {
                normal: {
                    color: '#0F375F',
                },
            },
            symbolRepeat: 'fixed',
            symbolMargin: 25,
            symbol: 'rect',
            symbolClip: true,
            symbolSize: [53, 3],
            symbolPosition: 'start',
            symbolOffset: [-45, 0],
            data: data1,
            width: 2,
            z: 0,
            zlevel: 1,
        },
        {
            name: 'yellow',
            type'bar',
            barWidth: 53,
            itemStyle: {
                normal: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        {
                            offset: 0,
                            color: 'rgba(252,160,0, 1)',
                        },
                        {
                            offset: 1,
                            color: 'rgba(252,160,0, .1)',
                        },
                    ]),
                },
            },
            label: {
                show: true,
                position: 'top',
                textStyle: {
                    fontSize: 36,
                    color: '#FFA200',
                },
                formatter: function (params) {
                    return params.value + '%';
                },
            },
            data: data2,
            z: 10,
            zlevel: 0,
        },
        {
            // 分隔
            type'pictorialBar',
            itemStyle: {
                normal: {
                    color: '#0F375F',
                },
            },
            symbolRepeat: 'fixed',
            symbolMargin: 25,
            symbol: 'rect',
            symbolClip: true,
            symbolSize: [53, 3],
            symbolPosition: 'start',
            symbolOffset: [45, 0],
            data: data2,
            width: 2,
            z: 0,
            zlevel: 1,
        },
    ],
};

项目结构简介:

1个主包 + 15个分包(不是本次讨论重点,不做过多详细介绍);

从上图可以看到我们的小程序在经过压缩后代码总体积为5410KB,主包体积为2017KB,处于体积即将超限的情况下。

主包之所以这么大,主要原因是我们采用了微信的tabbar的方案来实现我们的业务功能, 虽然使用起来很方便,但是它具有一个致命缺陷 - tabbar页面必须在主包中去实现。

而巧合的是我们由于业务需要,所以很多东西都必须在tabbar页面中去展示,这就使我们不得已在tabbar页面实现了很多的业务逻辑,直接导致了我们的主包体积余额严重不足。

主包功能分析:主要可归为两类

1、Taro产生的编译文件(这个我们无法缩减,不做讨论);

2、公共模块的实现

  • 公共组件
  • 工具函数
  • 其他的公共数据
  • tabbar页面的实现

3、小程序入口

是整个小程序的入口,主要负责处理相关数据初始化、扫码跳转处理、公众号跳转处理等方式;

tips:其中扫码 + 公众号的跳转处理指的是我们所有的二维码和公众号的初始跳转路径都必须是该页面;在该页面对于数据做处理时会根据他们携带的参数去做相应的页面跳转等功能。这样做的好处是为了便于管理,保证我们的小程序只有一个入口,这对于我们的维护成本来说其实是很重要的。

由于微信小程序的包体积限制,我相信很多不断迭代的微信小程序最终都会面临一个很棘手的问题-包体积溢出的问题。

我们的项目也不例外,随着版本的不断更新迭代,我们的包体积大小目前即将出现超出微信小程序的包体积限制的严重问题。

当问题出现时我尝试去寻找一个好的方案帮助我解决目前项目中所遇到的困境,但很遗憾,在社区中提出的大部分解决方法都对我们的项目无效,主要原因是大部分的手段我们的都已经应用在了我们的项目中,也就是说目前我们的纯业务代码就已经快超出了2M限制。

tips:

目前小程序分包大小有以下限制:

  • 整个小程序所有分包大小不超过 20M
  • 单个分包/主包大小不能超过 2M

减少!important的使用,简化嵌套,以及一些样式的合并。但请注意,去除!important可能需要确保其他地方的样式不会覆盖这些规则。

  1. 延迟加载和分批处理标记: 对于大量的数据点,考虑使用延迟加载(只在需要时加载标记)或分批处理数据。
  2. 避免重复的DOM操作: 对于创建信息窗口和标记的过程,避免在循环中进行重复的DOM操作。
  3. 使用碎片化渲染: 当处理大量DOM元素时,使用文档碎片(DocumentFragment)可以减少页面重绘和重排的次数。
  4. 内存管理: 当标记不再需要时,确保适当地清理以避免内存泄漏。
  5. 拆分函数:将复杂的逻辑拆分为更小的函数。
  6. 异步操作优化:使用 async/await 以简化异步操作的处理。
  7. 错误处理:增加必要的错误处理,提高代码的健壮性。
  8. 简化逻辑:尽可能简化逻辑,避免过度复杂的表达式和嵌套。
  9. 代码重构:重构代码以提高其可读性和可维护性。
      
              // plugins: ['AMap.MoveAnimation'],
        // AMapUI: {
        //     //是否加载 AMapUI,缺省不加载
        //     version: '1.1', //AMapUI 版本
        //     plugins: ['ui/misc/PathSimplifier'] //需要加载的 AMapUI ui 插件
        // }
        // Loca: {
        //  //是否加载 Loca, 缺省不加载
        //  version: "2.0", //Loca 版本
        // }
      
      ✔ All packages installed (1545 packages installed from npm registry, used 10s(network 10s), speed 6.63MB/s, json 579(22.06MB), tarball 41.58MB, manifests cache hit 831, etag hit 150 / miss 2)

✔ 安装项目依赖成功

创建项目 **myApp** 成功!

请进入项目目录 **myApp** 开始工作吧!😝

查看node的版本号

下载好node之后,以管理员身份打开cmd管理工具,,输入 node -v ,回车,查看node版本号,出现版本号则说明安装成功。

      
      输入命令: node -v

@tarojs/cli @ 3.6.24 | MIT | deps: 21 | versions: 729

cli tool for taro

MongoDB 是一个基于分布式文件存储的数据库。由 C++ 语言编写。旨在为 WEB 应用提供可扩展的高性能数据存储解决方案。

MongoDB 将数据存储为一个文档,数据结构由键值(key=>value)对组成。MongoDB 文档类似于 JSON 对象。字段值可以包含其他文档,数组及文档数组。

MongoDB 发展

  • 1.x - 支持复制和分片
  • 2.x - 更丰富的数据库功能
  • 3.x - WiredTiger 和周边生态
  • 4.x - 支持分布式事务

MongoDB 和 RDBMS

特性 MongoDB RDBMS
数据模型 文档模型 关系型
CRUD 操作 MQL/SQL SQL
高可用 复制集 集群模式
扩展性 支持分片 数据分区
扩繁方式 垂直扩展+水平扩展 垂直扩展
索引类型 B 树、全文索引、地理位置索引、多键索引、TTL 索引 B 树
数据容量 没有理论上限 千万、亿

MongoDB 特性

  • 数据是 JSON 结构
    • 支持结构化、半结构化数据模型
    • 可以动态响应结构变化
  • 通过副本机制提供高可用
  • 通过分片提供扩容能力

MongoDB 概念

SQL 术语/概念 MongoDB 术语/概念 解释/说明
database database 数据库
table collection 数据库表/集合
row document 数据记录行/文档
column field 数据字段/域
index index 索引
table joins
表连接,MongoDB 不支持
primary key primary key 主键,MongoDB 自动将_id 字段设置为主键

数据库

一个 MongoDB 中可以建立多个数据库。

MongoDB 的默认数据库为"db",该数据库存储在 data 目录中。

MongoDB 的单个实例可以容纳多个独立的数据库,每一个都有自己的集合和权限,不同的数据库也放置在不同的文件中。

"show dbs"  命令可以显示所有数据的列表。

      
      $ ./mongo
MongoDBshell version: 3.0.6
connecting to: test
> show dbs
local  0.078GB
test   0.078GB
>

执行  "db"  命令可以显示当前数据库对象或集合。

      
      $ ./mongo
MongoDBshell version: 3.0.6
connecting to: test
> db
test
>

运行"use"命令,可以连接到一个指定的数据库。

      
      > use local
switched to db local
> db
local
>

数据库也通过名字来标识。数据库名可以是满足以下条件的任意 UTF-8 字符串。

  • 不能是空字符串("")。
  • 不得含有 ' '(空格)、.$/、``和 \0 (空字符)。
  • 应全部小写。
  • 最多 64 字节。

有一些数据库名是保留的,可以直接访问这些有特殊作用的数据库。

  • admin:从权限的角度来看,这是"root"数据库。要是将一个用户添加到这个数据库,这个用户自动继承所有数据库的权限。一些特定的服务器端命令也只能从这个数据库运行,比如列出所有的数据库或者关闭服务器。
  • local:这个数据永远不会被复制,可以用来存储限于本地单台服务器的任意集合
  • config:当 Mongo 用于分片设置时,config 数据库在内部使用,用于保存分片的相关信息。

文档

文档是一组键值(key-value)对(即 BSON)。MongoDB 的文档不需要设置相同的字段,并且相同的字段不需要相同的数据类型,这与关系型数据库有很大的区别,也是 MongoDB 非常突出的特点。

需要注意的是:

  • 文档中的键/值对是有序的。
  • 文档中的值不仅可以是在双引号里面的字符串,还可以是其他几种数据类型(甚至可以是整个嵌入的文档)。
  • MongoDB 区分类型和大小写。
  • MongoDB 的文档不能有重复的键。
  • 文档的键是字符串。除了少数例外情况,键可以使用任意 UTF-8 字符。

文档键命名规范:

  • 键不能含有 \0 (空字符)。这个字符用来表示键的结尾。
  • . 和 $ 有特别的意义,只有在特定环境下才能使用。
  • 以下划线 _ 开头的键是保留的(不是严格要求的)。

集合

集合就是 MongoDB 文档组,类似于 RDBMS (关系数据库管理系统:Relational Database Management System)中的表格。

集合存在于数据库中,集合没有固定的结构,这意味着你在对集合可以插入不同格式和类型的数据,但通常情况下我们插入集合的数据都会有一定的关联性。

合法的集合名:

  • 集合名不能是空字符串""。
  • 集合名不能含有 \0 字符(空字符),这个字符表示集合名的结尾。
  • 集合名不能以"system."开头,这是为系统集合保留的前缀。
  • 用户创建的集合名字不能含有保留字符。有些驱动程序的确支持在集合名里面包含,这是因为某些系统生成的集合中包含该字符。除非你要访问这种系统创建的集合,否则千万不要在名字里出现 $

元数据

数据库的信息是存储在集合中。它们使用了系统的命名空间:dbname.system.*

在 MongoDB 数据库中名字空间 <dbname>.system.* 是包含多种系统信息的特殊集合(Collection),如下:

集合命名空间 描述
dbname.system.namespaces 列出所有名字空间。
dbname.system.indexes 列出所有索引。
dbname.system.profile 包含数据库概要(profile)信息。
dbname.system.users 列出所有可访问数据库的用户。
dbname.local.sources 包含复制对端(slave)的服务器信息和状态。

对于修改系统集合中的对象有如下限制。

在 system.indexes 插入数据,可以创建索引。但除此之外该表信息是不可变的(特殊的 drop index 命令将自动更新相关信息)。system.users 是可修改的。system.profile 是可删除的。

MongoDB 数据类型

数据类型 描述
String 字符串。存储数据常用的数据类型。在 MongoDB 中,UTF-8 编码的字符串才是合法的。
Integer 整型数值。用于存储数值。根据你所采用的服务器,可分为 32 位或 64 位。
Boolean 布尔值。用于存储布尔值(真/假)。
Double 双精度浮点值。用于存储浮点值。
Min/Max keys 将一个值与 BSON(二进制的 JSON)元素的最低值和最高值相对比。
Array 用于将数组或列表或多个值存储为一个键。
Timestamp 时间戳。记录文档修改或添加的具体时间。
Object 用于内嵌文档。
Null 用于创建空值。
Symbol 符号。该数据类型基本上等同于字符串类型,但不同的是,它一般用于采用特殊符号类型的语言。
Date 日期时间。用 UNIX 时间格式来存储当前日期或时间。你可以指定自己的日期时间:创建 Date 对象,传入年月日信息。
Object ID 对象 ID。用于创建文档的 ID。
Binary Data 二进制数据。用于存储二进制数据。
Code 代码类型。用于在文档中存储 JavaScript 代码。
Regular expression 正则表达式类型。用于存储正则表达式。

MongoDB CRUD

数据库操作

查看所有数据库

      
      show dbs

创建数据库

      
      use <database>

如果数据库不存在,则创建数据库,否则切换到指定数据库。

【示例】创建数据库,并插入一条数据

刚创建的数据库 test 并不在数据库的列表中, 要显示它,需要插入一些数据

      
      > use test
switched to db test
>
> show dbs
admin   0.000GB
config  0.000GB
local   0.000GB
> db.test.insert({"name":"mongodb"})
WriteResult({ "nInserted" : 1 })
> show dbs
admin   0.000GB
config  0.000GB
local   0.000GB
test    0.000GB

删除数据库

删除当前数据库

      
      db.dropDatabase()

集合操作

查看集合

      
      show collections

创建集合

      
      db.createCollection(name, options)

参数说明:

  • name: 要创建的集合名称
  • options: 可选参数, 指定有关内存大小及索引的选项

options 可以是如下参数:

字段 类型 描述
capped 布尔 (可选)如果为 true,则创建固定集合。固定集合是指有着固定大小的集合,当达到最大值时,它会自动覆盖最早的文档。 当该值为 true 时,必须指定 size 参数。
autoIndexId 布尔 3.2 之后不再支持该参数。(可选)如为 true,自动在 _id 字段创建索引。默认为 false。
size 数值 (可选)为固定集合指定一个最大值,即字节数。 如果 capped 为 true,也需要指定该字段。
max 数值 (可选)指定固定集合中包含文档的最大数量。

在插入文档时,MongoDB 首先检查固定集合的 size 字段,然后检查 max 字段。

      
      > db.createCollection("collection")
"ok" : 1 }
> show collections
collection

删除集合

      
      > db.collection.drop()
true
> show collections
>

插入文档操作

MongoDB 使用 insert() 方法完成插入操作。

语法格式

      
      # 插入单条记录
db.<集合>.insertOne(<JSON>)
# 插入多条记录
db.<集合>.insertMany([<JSON 1>, <JSON 2>, ..., <JSON N>])

【示例】insertOne

      
      > db.color.insertOne({name: "red"})
{
        "acknowledged" : true,
        "insertedId" : ObjectId("5f533ae4e8f16647950fdf43")
}

【示例】insertMany

      
      > db.color.insertMany([
  {
    "name""yellow"
  },
  {
    "name""blue"
  }
])
{
        "acknowledged" : true,
        "insertedIds" : [
                ObjectId("5f533bcae8f16647950fdf44"),
                ObjectId("5f533bcae8f16647950fdf45")
        ]
}
>

查询文档操作

MongoDB 使用 find() 方法完成查询文档操作。

语法格式

      
      db.<集合>.find(<JSON>)

查询条件也是 json 形式,如果不设置查询条件,即为全量查询。

查询条件

操作 格式 范例 RDBMS 中的类似语句
等于 {<key>:<value>} db.book.find({"pageCount": {$eq: 0}}) where pageCount = 0
不等于 {<key>:{$ne:<value>}} db.book.find({"pageCount": {$ne: 0}}) where likes != 50
大于 {<key>:{$gt:<value>}} db.book.find({"pageCount": {$gt: 0}}) where likes > 50
{<key>:{$gt:<value>}} db.book.find({"pageCount": {$gt: 0}}) where likes > 50 大于或等于
小于 {<key>:{$lt:<value>}} db.book.find({"pageCount": {$lt: 200}}) where likes < 50
小于或等于 {<key>:{$lte:<value>}} db.book.find({"pageCount": {$lte: 200}}) where likes <= 50

说明:

        
        $eq  --------  equal  =
$ne ----------- not equal  !=
$gt -------- greater than  >
$gte --------- gt equal  >=
$lt -------- less than  <
$lte --------- lt equal  <=

【示例】

      
      

# 统计匹配查询条件的记录数
> db.book.find({"status""MEAP"}).count()
68

查询逻辑条件

(1)and 条件

MongoDB 的 find() 方法可以传入多个键(key),每个键(key)以逗号隔开,即常规 SQL 的 AND 条件。

语法格式如下:

      
      > db.col.find({key1:value1, key2:value2}).pretty()

(2)or 条件

MongoDB OR 条件语句使用了关键字  $or,语法格式如下:

      
      >db.col.find(
   {
      $or: [
         {key1: value1}, {key2:value2}
      ]
   }
).pretty()

模糊查询

查询 title 包含"教"字的文档:

      
      db.col.find({ title: /教/ })

查询 title 字段以"教"字开头的文档:

      
      db.col.find({ title: /^教/ })

查询 titl e 字段以"教"字结尾的文档:

      
      db.col.find({ title: /教$/ })

Limit() 方法

如果你需要在 MongoDB 中读取指定数量的数据记录,可以使用 MongoDB 的 Limit 方法,limit()方法接受一个数字参数,该参数指定从 MongoDB 中读取的记录条数。

limit()方法基本语法如下所示:

      
      >db.COLLECTION_NAME.find().limit(NUMBER)

Skip() 方法

我们除了可以使用 limit()方法来读取指定数量的数据外,还可以使用 skip()方法来跳过指定数量的数据,skip 方法同样接受一个数字参数作为跳过的记录条数。

skip() 方法脚本语法格式如下:

      
      >db.COLLECTION_NAME.find().limit(NUMBER).skip(NUMBER)

Sort() 方法

在 MongoDB 中使用 sort() 方法对数据进行排序,sort() 方法可以通过参数指定排序的字段,并使用 1 和 -1 来指定排序的方式,其中 1 为升序排列,而 -1 是用于降序排列。

sort()方法基本语法如下所示:

      
      >db.COLLECTION_NAME.find().sort({KEY:1})

注意:skip(), limilt(), sort()三个放在一起执行的时候,执行的顺序是先 sort(), 然后是 skip(),最后是显示的 limit()。

更新文档操作

update() 方法用于更新已存在的文档。语法格式如下:

      
      db.collection.update(
   <query>,
   <update>,
   {
     upsert: <boolean>,
     multi: <boolean>,
     writeConcern: <document>
   }
)

参数说明:

  • query : update 的查询条件,类似 sql update 查询内 where 后面的。
  • update : update 的对象和一些更新的操作符(如inc...)等,也可以理解为 sql update 查询内 set 后面的
  • upsert : 可选,这个参数的意思是,如果不存在 update 的记录,是否插入 objNew,true 为插入,默认是 false,不插入。
  • multi : 可选,mongodb 默认是 false,只更新找到的第一条记录,如果这个参数为 true,就把按条件查出来多条记录全部更新。
  • writeConcern :可选,抛出异常的级别。

【示例】更新文档

      
      db.collection.update({ title: 'MongoDB 教程' }, { $set: { title: 'MongoDB' } })

【示例】更新多条相同文档

以上语句只会修改第一条发现的文档,如果你要修改多条相同的文档,则需要设置 multi 参数为 true。

      
      db.collection.update(
  { title: 'MongoDB 教程' },
  { $set: { title: 'MongoDB' } },
  { multi: true }
)

【示例】更多实例

只更新第一条记录:

      
      db.collection.update({ count: { $gt: 1 } }, { $set: { test2: 'OK' } })

全部更新:

      
      db.collection.update(
  { count: { $gt: 3 } },
  { $set: { test2: 'OK' } },
  false,
  true
)

只添加第一条:

      
      db.collection.update(
  { count: { $gt: 4 } },
  { $set: { test5: 'OK' } },
  true,
  false
)

全部添加进去:

      
      db.collection.update(
  { count: { $gt: 4 } },
  { $set: { test5: 'OK' } },
  true,
  false
)

全部更新:

      
      db.collection.update(
  { count: { $gt: 4 } },
  { $set: { test5: 'OK' } },
  true,
  false
)

只更新第一条记录:

      
      db.collection.update(
  { count: { $gt: 4 } },
  { $set: { test5: 'OK' } },
  true,
  false
)

删除文档操作

官方推荐使用 deleteOne() 和 deleteMany() 方法删除数据。

删除 status 等于 A 的全部文档:

      
      db.collection.deleteMany({ status: 'A' })

删除 status 等于 D 的一个文档:

      
      db.collection.deleteOne({ status: 'D' })

索引操作

索引通常能够极大的提高查询的效率,如果没有索引,MongoDB 在读取数据时必须扫描集合中的每个文件并选取那些符合查询条件的记录。

这种扫描全集合的查询效率是非常低的,特别在处理大量的数据时,查询可以要花费几十秒甚至几分钟,这对网站的性能是非常致命的。

索引是特殊的数据结构,索引存储在一个易于遍历读取的数据集合中,索引是对数据库表中一列或多列的值进行排序的一种结构。

MongoDB 使用 createIndex() 方法来创建索引。

createIndex()方法基本语法格式如下所示:

      
      >db.collection.createIndex(keys, options)

语法中 Key 值为你要创建的索引字段,1 为指定按升序创建索引,如果你想按降序来创建索引指定为 -1 即可。

      
      >db.col.createIndex({"title":1})

createIndex() 方法中你也可以设置使用多个字段创建索引(关系型数据库中称作复合索引)。

      
      >db.col.createIndex({"title":1,"description":-1})

createIndex() 接收可选参数,可选参数列表如下:

Parameter Type Description
background Boolean 建索引过程会阻塞其它数据库操作,background 可指定以后台方式创建索引,即增加 "background" 可选参数。 "background" 默认值为false
unique Boolean 建立的索引是否唯一。指定为 true 创建唯一索引。默认值为false.
name string 索引的名称。如果未指定,MongoDB 的通过连接索引的字段名和排序顺序生成一个索引名称。
dropDups Boolean 3.0+版本已废弃。在建立唯一索引时是否删除重复记录,指定 true 创建唯一索引。默认值为 false
sparse Boolean 对文档中不存在的字段数据不启用索引;这个参数需要特别注意,如果设置为 true 的话,在索引字段中不会查询出不包含对应字段的文档.。默认值为 false.
expireAfterSeconds integer 指定一个以秒为单位的数值,完成 TTL 设定,设定集合的生存时间。
v index version 索引的版本号。默认的索引版本取决于 mongod 创建索引时运行的版本。
weights document 索引权重值,数值在 1 到 99,999 之间,表示该索引相对于其他索引字段的得分权重。
default_language string 对于文本索引,该参数决定了停用词及词干和词器的规则的列表。 默认为英语
language_override string 对于文本索引,该参数指定了包含在文档中的字段名,语言覆盖默认的 language,默认值为 language.

MongoDB 聚合操作

MongoDB 中聚合(aggregate)主要用于处理数据(诸如统计平均值,求和等),并返回计算后的数据结果。有点类似 sql 语句中的 count(*)。

管道

整个聚合运算过程称为管道,它是由多个步骤组成,每个管道

  • 接受一系列文档(原始数据);
  • 每个步骤对这些文档进行一系列运算;
  • 结果文档输出给下一个步骤;

聚合操作的基本格式

      
      pipeline = [$stage1$stage1, ..., $stageN];

db.<集合>.aggregate(pipeline, {options});

聚合步骤

步骤 作用 SQL 等价运算符
$match 过滤 WHERE
$project 投影 AS
$sort 排序 ORDER BY
$group 分组 GROUP BY
$skip / $limit 结果限制 SKIP / LIMIT
$lookup 左外连接 LEFT OUTER JOIN
$unwind 展开数组 N/A
$graphLookup 图搜索 N/A
$facet / $bucket 分面搜索 N/A

【示例】

      
      > db.collection.insertMany([{"title":"MongoDB Overview","description":"MongoDB is no sql database","by_user":"collection","tagsr":["mongodb","database","NoSQL"],"likes":"100"},{"title":"NoSQL Overview","description":"No sql database is very fast","by_user":"collection","tagsr":["mongodb","database","NoSQL"],"likes":"10"},{"title":"Neo4j Overview","description":"Neo4j is no sql database","by_user":"Neo4j","tagsr":["neo4j","database","NoSQL"],"likes":"750"}])
> db.collection.aggregate([{$group : {_id : "$by_user", num_tutorial : {$sum : 1}}}])
"_id" : null, "num_tutorial" : 3 }
"_id" : "Neo4j""num_tutorial" : 1 }
"_id" : "collection""num_tutorial" : 2 }

下表展示了一些聚合的表达式:

表达式 描述 实例
$sum 计算总和。 db.mycol.aggregate([{$group : {_id : "$by_user", num_tutorial : {$sum : "$likes"}}}])
$avg 计算平均值 db.mycol.aggregate([{$group : {_id : "$by_user", num_tutorial : {$avg : "$likes"}}}])
$min 获取集合中所有文档对应值得最小值。 db.mycol.aggregate([{$group : {_id : "$by_user", num_tutorial : {$min : "$likes"}}}])
$max 获取集合中所有文档对应值得最大值。 db.mycol.aggregate([{$group : {_id : "$by_user", num_tutorial : {$max : "$likes"}}}])
$push 在结果文档中插入值到一个数组中。 db.mycol.aggregate([{$group : {_id : "$by_user", url : {$push: "$url"}}}])
$addToSet 在结果文档中插入值到一个数组中,但不创建副本。 db.mycol.aggregate([{$group : {_id : "$by_user", url : {$addToSet : "$url"}}}])
$first 根据资源文档的排序获取第一个文档数据。 db.mycol.aggregate([{$group : {_id : "$by_user", first_url : {$first : "$url"}}}])
$last 根据资源文档的排序获取最后一个文档数据 db.mycol.aggregate([{$group : {_id : "$by_user", last_url : {$last : "$url"}}}])

Java 支持的变量类型有:

  • 局部变量 - 类方法中的变量。
  • 实例变量(也叫成员变量) - 类方法外的变量,不过没有 static 修饰。
  • 类变量(也叫静态变量) - 类方法外的变量,用 static 修饰。

特性对比:

局部变量 实例变量(也叫成员变量) 类变量(也叫静态变量)
局部变量声明在方法、构造方法或者语句块中。 实例变量声明在方法、构造方法和语句块之外。 类变量声明在方法、构造方法和语句块之外。并且以 static 修饰。
局部变量在方法、构造方法、或者语句块被执行的时候创建,当它们执行完成后,变量将会被销毁。 实例变量在对象创建的时候创建,在对象被销毁的时候销毁。 类变量在第一次被访问时创建,在程序结束时销毁。
局部变量没有默认值,所以必须经过初始化,才可以使用。 实例变量具有默认值。数值型变量的默认值是 0,布尔型变量的默认值是 false,引用类型变量的默认值是 null。变量的值可以在声明时指定,也可以在构造方法中指定。 类变量具有默认值。数值型变量的默认值是 0,布尔型变量的默认值是 false,引用类型变量的默认值是 null。变量的值可以在声明时指定,也可以在构造方法中指定。此外,静态变量还可以在静态语句块中初始化。
对于局部变量,如果是基本类型,会把值直接存储在栈;如果是引用类型,会把其对象存储在堆,而把这个对象的引用(指针)存储在栈。 实例变量存储在堆。 类变量存储在静态存储区。
访问修饰符不能用于局部变量。 访问修饰符可以用于实例变量。 访问修饰符可以用于类变量。
局部变量只在声明它的方法、构造方法或者语句块中可见。 实例变量对于类中的方法、构造方法或者语句块是可见的。一般情况下应该把实例变量设为私有。通过使用访问修饰符可以使实例变量对子类可见。 与实例变量具有相似的可见性。但为了对类的使用者可见,大多数静态变量声明为 public 类型。

实例变量可以直接通过变量名访问。但在静态方法以及其他类中,就应该使用完全限定名:ObejectReference.VariableName。 静态变量可以通过:ClassName.VariableName 的方式访问。


无论一个类创建了多少个对象,类只拥有类变量的一份拷贝。


类变量除了被声明为常量外很少使用。

变量修饰符

  • 访问级别修饰符

    • 如果变量是实例变量或类变量,可以添加访问级别修饰符(public/protected/private)
  • 静态修饰符

    • 如果变量是类变量,需要添加 static 修饰
  • final

    • 如果变量使用 final 修饰符,就表示这是一个常量,不能被修改。

加群联系作者vx:xiaoda0423

仓库地址:https://github.com/webVueBlog/JavaGuideInterview

浏览 17
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报