• 献给转java的c#和java程序员的数据库orm框架


    献给转java的c#和java程序员的数据库orm框架

    一个好的程序员不应被语言所束缚,正如我现在开源java的orm框架一样,如果您是一位转java的c#程序员,那么这个框架可以带给你起码没有那么差的业务编写和强类型体验。如果您是一位java程序员,那么该框架可以提供比Mybatis-Plus功能更加丰富、性能更高,更加轻量和完全免费的体验来做一个happy coding crud body。

    背景

    easy-query该框架是我在使用Mybatis-Plus(下面统称MP) 2年后开发的,因为MP不支持多表(不要提join插件(逻辑删除子表不支持)),并且Mybatis原本的xml十分恶心,导致项目中有非常多的代码需要编写sql,并且整体数据库架构因为存在逻辑删除字段和多租户字段所以编写的sql基本上多多少少都会有问题,我不相信大家没遇到过,而且MP得一些功能还需要收费这大大让我坚定还是自己开发一款。

    介绍

    easy-query 🚀 是一款无任何依赖的JAVA ORM 框架,十分轻量,拥有非常高的性能,支持单表查询、多表查询、union、子查询、分页、动态表名、VO对象查询返回、逻辑删、全局拦截、数据库列加密(支持高性能like查询)、数据追踪差异更新、乐观锁、多租户、自动分库、自动分表、读写分离,支持框架全功能外部扩展定制,拥有强类型表达式。

    📚 文档

    GITHUB地址 | GITEE地址

    缺点

    先说一下缺点,目前只适配了MySql,不过基本上如果你是pgsql很少需要改动就直接可以用了,其他数据库可能因为自己的语法和特性会需要稍微做一下修改但是整体而言无需过多的变动,框架已经全部抽象好了。

    功能点

    • 实体对象insert,update,delete全部支持
    • 单表查询、多表join查询,in子查询,exists子查询,连表统计(select a,(select count(1) from b) from c),联合查询union | all,分组group | having
    • 分页
    • 动态表名:运行时修改表名
    • 原生sql执行,查询
    • select查询map结果返回
    • select支持直接返回DTO对象实现自定义列查询返回,而不是全部列返回
    • select支持标记large字段不返回(默认返回)
    • 逻辑删除,自定义逻辑删除,支持多字段逻辑删除填充,支持运行时禁用
    • 全局拦截器,支持运行时选择性使用某几个或者不使用,支持entity操作 insert,update,条件拦截 select、update、delete的where条件拦截,update set字段拦截器
    • 多租户,支持表的列范围多租户模式
    • 数据库列加密,支持高性能的like模糊搜索匹配(不是单纯的调用数据库加密函数或者单纯的调用框架加密解密函数)
    • 数据追踪差异更新,而不是全列更新,用过efcore的肯定很熟悉
    • 版本号、乐观锁,支持自定义乐观锁
    • 支持分库分表(身为sharding-core作者不支持说不过去),全自动分库分表,仅需用户新增表和告知easy-query系统中有的表
    • 高性能分库分表分页,支持顺序分页,反向分页,支持高性能顺序分页和反向分页
    • 分库分表多字段分片
    • 分库分表自定义分片路由规则
    • 支持读写分离,一主多从支持分片下读写分离

    目前项目正处于起步阶段后续会随着用户不断地完善各数据库的适配和功能的支持

    开始使用

    安装

    以下是spring-boot环境和控制台模式的安装

    spring-boot

    <properties>
        <easy-query.version>0.8.10easy-query.version>
    properties>
    <dependency>
        <groupId>com.easy-querygroupId>
        <artifactId>sql-springboot-starterartifactId>
        <version>${easy-query.version}version>
    dependency>
    

    console

    以mysql为例

    <properties>
        <easy-query.version>0.8.10easy-query.version>
    properties>
    <dependency>
        <groupId>com.easy-querygroupId>
        <artifactId>sql-mysqlartifactId>
        <version>${easy-query.version}version>
    dependency>
    
    //初始化连接池
     HikariDataSource dataSource = new HikariDataSource();
    dataSource.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/easy-query-test?serverTimezone=GMT%2B8&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true&rewriteBatchedStatements=true");
    dataSource.setUsername("root");
    dataSource.setPassword("root");
    dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
    dataSource.setMaximumPoolSize(20);
    //创建easy-query
     EasyQuery easyQuery = EasyQueryBootstrapper.defaultBuilderConfiguration()
                    .setDefaultDataSource(dataSource)
                    .useDatabaseConfigure(new MySQLDatabaseConfiguration())
                    .build();
    

    开始

    sql脚本

    create table t_topic
    (
        id varchar(32) not null comment '主键ID'primary key,
        stars int not null comment '点赞数',
        title varchar(50) null comment '标题',
        create_time datetime not null comment '创建时间'
    )comment '主题表';
    
    create table t_blog
    (
        id varchar(32) not null comment '主键ID'primary key,
        deleted tinyint(1) default 0 not null comment '是否删除',
        create_by varchar(32) not null comment '创建人',
        create_time datetime not null comment '创建时间',
        update_by varchar(32) not null comment '更新人',
        update_time datetime not null comment '更新时间',
        title varchar(50) not null comment '标题',
        content varchar(256) null comment '内容',
        url varchar(128) null comment '博客链接',
        star int not null comment '点赞数',
        publish_time datetime null comment '发布时间',
        score decimal(18, 2) not null comment '评分',
        status int not null comment '状态',
        `order` decimal(18, 2) not null comment '排序',
        is_top tinyint(1) not null comment '是否置顶',
        top tinyint(1) not null comment '是否置顶'
    )comment '博客表';
    

    查询对象

    
    
    
    @Data
    public class BaseEntity implements Serializable {
        private static final long serialVersionUID = -4834048418175625051L;
    
        @Column(primaryKey = true)
        private String id;
        /**
         * 创建时间;创建时间
         */
        private LocalDateTime createTime;
        /**
         * 修改时间;修改时间
         */
        private LocalDateTime updateTime;
        /**
         * 创建人;创建人
         */
        private String createBy;
        /**
         * 修改人;修改人
         */
        private String updateBy;
        /**
         * 是否删除;是否删除
         */
        @LogicDelete(strategy = LogicDeleteStrategyEnum.BOOLEAN)
        private Boolean deleted;
    }
    
    
    @Data
    @Table("t_topic")
    @ToString
    public class Topic {
    
        @Column(primaryKey = true)
        private String id;
        private Integer stars;
        private String title;
        private LocalDateTime createTime;
    }
    
    @Data
    @Table("t_blog")
    public class BlogEntity extends BaseEntity{
    
        /**
         * 标题
         */
        private String title;
        /**
         * 内容
         */
        private String content;
        /**
         * 博客链接
         */
        private String url;
        /**
         * 点赞数
         */
        private Integer star;
        /**
         * 发布时间
         */
        private LocalDateTime publishTime;
        /**
         * 评分
         */
        private BigDecimal score;
        /**
         * 状态
         */
        private Integer status;
        /**
         * 排序
         */
        private BigDecimal order;
        /**
         * 是否置顶
         */
        private Boolean isTop;
        /**
         * 是否置顶
         */
        private Boolean top;
    }
    
    

    单表查询

    Topic topic = easyQuery
                    .queryable(Topic.class)
                    .where(o -> o.eq(Topic::getId, "3"))
                    .firstOrNull();      
    
    ==> Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic` t WHERE t.`id` = ? LIMIT 1
    ==> Parameters: 3(String)
    <== Time Elapsed: 15(ms)
    <== Total: 1     
    

    多表查询

    Topic topic = easyQuery
                    .queryable(Topic.class)
                    .leftJoin(BlogEntity.class, (t, t1) -> t.eq(t1, Topic::getId, BlogEntity::getId))
                    .where(o -> o.eq(Topic::getId, "3"))
                    .firstOrNull();
    
    ==> Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic` t LEFT JOIN `t_blog` t1 ON t1.`deleted` = ? AND t.`id` = t1.`id` WHERE t.`id` = ? LIMIT 1
    ==> Parameters: false(Boolean),3(String)
    <== Time Elapsed: 2(ms)
    <== Total: 1
    

    复杂查询

    join + group +分页

    
    EasyPageResult page = easyQuery
            .queryable(Topic.class).asTracking()
            .innerJoin(BlogEntity.class, (t, t1) -> t.eq(t1, Topic::getId, BlogEntity::getId))
            .where((t, t1) -> t1.isNotNull(BlogEntity::getTitle))
            .groupBy((t, t1)->t1.column(BlogEntity::getId))
            .select(BlogEntity.class, (t, t1) -> t1.column(BlogEntity::getId).columnSum(BlogEntity::getScore))
            .toPageResult(1, 20);
    
    
    ==> Preparing: SELECT t1.`id`,SUM(t1.`score`) AS `score` FROM `t_topic` t INNER JOIN `t_blog` t1 ON t1.`deleted` = ? AND t.`id` = t1.`id` WHERE t1.`title` IS NOT NULL GROUP BY t1.`id` LIMIT 20
    ==> Parameters: false(Boolean)
    <== Time Elapsed: 5(ms)
    <== Total: 20
    

    动态表名

    
    String sql = easyQuery.queryable(BlogEntity.class)
            .asTable(a->"aa_bb_cc")
            .where(o -> o.eq(BlogEntity::getId, "123"))
            .toSQL();
         
    
     SELECT t.`id`,t.`create_time`,t.`update_time`,t.`create_by`,t.`update_by`,t.`deleted`,t.`title`,t.`content`,t.`url`,t.`star`,t.`publish_time`,t.`score`,t.`status`,t.`order`,t.`is_top`,t.`top` FROM `aa_bb_cc` t WHERE t.`deleted` = ? AND t.`id` = ?  
    

    新增

    
    Topic topic = new Topic();
    topic.setId(String.valueOf(0));
    topic.setStars(100);
    topic.setTitle("标题0");
    topic.setCreateTime(LocalDateTime.now().plusDays(i));
    
    long rows = easyQuery.insertable(topic).executeRows();
    
    
    //返回结果rows1
    ==> Preparing: INSERT INTO `t_topic` (`id`,`stars`,`title`,`create_time`) VALUES (?,?,?,?) 
    ==> Parameters: 0(String),100(Integer),标题0(String),2023-03-16T21:34:13.287(LocalDateTime)
    <== Total: 1
    

    修改

    //实体更新
     Topic topic = easyQuery.queryable(Topic.class)
            .where(o -> o.eq(Topic::getId, "7")).firstNotNull("未找到对应的数据");
            String newTitle = "test123" + new Random().nextInt(100);
            topic.setTitle(newTitle);
    
    long rows=easyQuery.updatable(topic).executeRows();
    
    ==> Preparing: UPDATE t_topic SET `stars` = ?,`title` = ?,`create_time` = ? WHERE `id` = ?
    ==> Parameters: 107(Integer),test12364(String),2023-03-27T22:05:23(LocalDateTime),7(String)
    <== Total: 1
    
    //表达式更新
    long rows = easyQuery.updatable(Topic.class)
                    .set(Topic::getStars, 12)
                    .where(o -> o.eq(Topic::getId, "2"))
                    .executeRows();
    //rows为1
    easyQuery.updatable(Topic.class)
                        .set(Topic::getStars, 12)
                        .where(o -> o.eq(Topic::getId, "2"))
                        .executeRows(1,"更新失败");
    //判断受影响行数并且进行报错,如果当前操作不在事务内执行那么会自动开启事务!!!会自动开启事务!!!会自动开启事务!!!来实现并发更新控制,异常为:EasyQueryConcurrentException 
    //抛错后数据将不会被更新
    
    ==> Preparing: UPDATE t_topic SET `stars` = ? WHERE `id` = ?
    ==> Parameters: 12(Integer),2(String)
    <== Total: 1
    

    删除

    long l = easyQuery.deletable(Topic.class)
                        .where(o->o.eq(Topic::getTitle,"title998"))
                        .executeRows();
    
    ==> Preparing: DELETE FROM t_topic WHERE `title` = ?
    ==> Parameters: title998(String)
    <== Total: 1
    
    Topic topic = easyQuery.queryable(Topic.class).whereId("997").firstNotNull("未找到当前主题数据");
    long l = easyQuery.deletable(topic).executeRows();
    
    ==> Preparing: DELETE FROM t_topic WHERE `id` = ?
    ==> Parameters: 997(String)
    <== Total: 1
    

    联合查询

    Queryable q1 = easyQuery
                    .queryable(Topic.class);
    Queryable q2 = easyQuery
            .queryable(Topic.class);
    Queryable q3 = easyQuery
            .queryable(Topic.class);
    List list = q1.union(q2, q3).where(o -> o.eq(Topic::getId, "123321")).toList();
    
    
    ==> Preparing: SELECT t1.`id`,t1.`stars`,t1.`title`,t1.`create_time` FROM (SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic` t UNION SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic` t UNION SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic` t) t1 WHERE t1.`id` = ?
    ==> Parameters: 123321(String)
    <== Time Elapsed: 19(ms)
    <== Total: 0
    

    子查询

    in子查询

    Queryable idQueryable = easyQuery.queryable(BlogEntity.class)
            .where(o -> o.eq(BlogEntity::getId, "1"))
            .select(String.class,o->o.column(BlogEntity::getId));
    List list = easyQuery
            .queryable(Topic.class, "x").where(o -> o.in(Topic::getId, idQueryable)).toList();
    
    ==> Preparing: SELECT x.`id`,x.`stars`,x.`title`,x.`create_time` FROM `t_topic` x WHERE x.`id` IN (SELECT t.`id` FROM `t_blog` t WHERE t.`deleted` = ? AND t.`id` = ?) 
    ==> Parameters: false(Boolean),1(String)
    <== Time Elapsed: 3(ms)
    <== Total: 1    
    

    exists子查询

    Queryable where1 = easyQuery.queryable(BlogEntity.class)
                    .where(o -> o.eq(BlogEntity::getId, "1"));
    List x = easyQuery
            .queryable(Topic.class, "x").where(o -> o.exists(where1.where(q -> q.eq(o, BlogEntity::getId, Topic::getId)))).toList();
    
    ==> Preparing: SELECT x.`id`,x.`stars`,x.`title`,x.`create_time` FROM `t_topic` x WHERE EXISTS (SELECT 1 FROM `t_blog` t WHERE t.`deleted` = ? AND t.`id` = ? AND t.`id` = x.`id`) 
    ==> Parameters: false(Boolean),1(String)
    <== Time Elapsed: 10(ms)
    <== Total: 1
    

    分片

    easy-query支持分表、分库、分表+分库

    分表

    //创建分片对象
    @Data
    @Table(value = "t_topic_sharding_time",shardingInitializer = TopicShardingTimeShardingInitializer.class)
    @ToString
    public class TopicShardingTime {
    
        @Column(primaryKey = true)
        private String id;
        private Integer stars;
        private String title;
        @ShardingTableKey
        private LocalDateTime createTime;
    }
    //分片初始化器很简单 假设我们是2020年1月到2023年5月也就是当前时间进行分片那么要生成对应的分片表每月一张
    public class TopicShardingTimeShardingInitializer extends AbstractShardingMonthInitializer {
    
        @Override
        protected LocalDateTime getBeginTime() {
            return LocalDateTime.of(2020, 1, 1, 1, 1);
        }
    
        @Override
        protected LocalDateTime getEndTime() {
            return LocalDateTime.of(2023, 5, 1, 0, 0);
        }
    
    
        @Override
        public void configure0(ShardingEntityBuilder builder) {
    
    ////以下条件可以选择配置也可以不配置用于优化分片性能
    //        builder.paginationReverse(0.5,100)
    //                .ascSequenceConfigure(new TableNameStringComparator())
    //                .addPropertyDefaultUseDesc(TopicShardingTime::getCreateTime)
    //                .defaultAffectedMethod(false, ExecuteMethodEnum.LIST,ExecuteMethodEnum.ANY,ExecuteMethodEnum.COUNT,ExecuteMethodEnum.FIRST)
    //                .useMaxShardingQueryLimit(2,ExecuteMethodEnum.LIST,ExecuteMethodEnum.ANY,ExecuteMethodEnum.FIRST);
    
        }
    }
    //分片时间路由规则按月然后bean分片属性就是LocalDateTime也可以自定义实现
    public class TopicShardingTimeTableRule extends AbstractMonthTableRule {
    
        @Override
        protected LocalDateTime convertLocalDateTime(Object shardingValue) {
            return (LocalDateTime)shardingValue;
        }
    }
    
    

    数据库脚本参考源码

    其中shardingInitializer为分片初始化器用来初始化告诉框架有多少分片的表名(支持动态添加)

    ShardingTableKey表示哪个字段作为分片键(分片键不等于主键)

    执行sql

    LocalDateTime beginTime = LocalDateTime.of(2021, 1, 1, 1, 1);
    LocalDateTime endTime = LocalDateTime.of(2021, 5, 2, 1, 1);
    Duration between = Duration.between(beginTime, endTime);
    long days = between.toDays();
    List list = easyQuery.queryable(TopicShardingTime.class)
            .where(o->o.rangeClosed(TopicShardingTime::getCreateTime,beginTime,endTime))
            .orderByAsc(o -> o.column(TopicShardingTime::getCreateTime))
            .toList();
    
    
    
    ==> SHARDING_EXECUTOR_2, name:ds2020, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_time_202101` t WHERE t.`create_time` >= ? AND t.`create_time` <= ? ORDER BY t.`create_time` ASC
    ==> SHARDING_EXECUTOR_3, name:ds2020, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_time_202102` t WHERE t.`create_time` >= ? AND t.`create_time` <= ? ORDER BY t.`create_time` ASC
    ==> SHARDING_EXECUTOR_2, name:ds2020, Parameters: 2021-01-01T01:01(LocalDateTime),2021-05-02T01:01(LocalDateTime)
    ==> SHARDING_EXECUTOR_3, name:ds2020, Parameters: 2021-01-01T01:01(LocalDateTime),2021-05-02T01:01(LocalDateTime)
    <== SHARDING_EXECUTOR_3, name:ds2020, Time Elapsed: 3(ms)
    <== SHARDING_EXECUTOR_2, name:ds2020, Time Elapsed: 3(ms)
    ==> SHARDING_EXECUTOR_2, name:ds2020, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_time_202103` t WHERE t.`create_time` >= ? AND t.`create_time` <= ? ORDER BY t.`create_time` ASC
    ==> SHARDING_EXECUTOR_3, name:ds2020, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_time_202104` t WHERE t.`create_time` >= ? AND t.`create_time` <= ? ORDER BY t.`create_time` ASC
    ==> SHARDING_EXECUTOR_2, name:ds2020, Parameters: 2021-01-01T01:01(LocalDateTime),2021-05-02T01:01(LocalDateTime)
    ==> SHARDING_EXECUTOR_3, name:ds2020, Parameters: 2021-01-01T01:01(LocalDateTime),2021-05-02T01:01(LocalDateTime)
    <== SHARDING_EXECUTOR_3, name:ds2020, Time Elapsed: 2(ms)
    <== SHARDING_EXECUTOR_2, name:ds2020, Time Elapsed: 2(ms)
    ==> main, name:ds2020, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_time_202105` t WHERE t.`create_time` >= ? AND t.`create_time` <= ? ORDER BY t.`create_time` ASC
    ==> main, name:ds2020, Parameters: 2021-01-01T01:01(LocalDateTime),2021-05-02T01:01(LocalDateTime)
    <== main, name:ds2020, Time Elapsed: 2(ms)
    <== Total: 122
    

    分库

    
    @Data
    @Table(value = "t_topic_sharding_ds",shardingInitializer = DataSourceAndTableShardingInitializer.class)
    @ToString
    public class TopicShardingDataSource {
    
        @Column(primaryKey = true)
        private String id;
        private Integer stars;
        private String title;
        @ShardingDataSourceKey
        private LocalDateTime createTime;
    }
    public class DataSourceShardingInitializer implements EntityShardingInitializer {
        @Override
        public void configure(ShardingEntityBuilder builder) {
            EntityMetadata entityMetadata = builder.getEntityMetadata();
            String tableName = entityMetadata.getTableName();
            List tables = Collections.singletonList(tableName);
            LinkedHashMap> initTables = new LinkedHashMap>() {{
                put("ds2020", tables);
                put("ds2021", tables);
                put("ds2022", tables);
                put("ds2023", tables);
            }};
            builder.actualTableNameInit(initTables);
    
    
        }
    }
    //分库数据源路由规则
    public class TopicShardingDataSourceRule extends AbstractDataSourceRouteRule {
        @Override
        protected RouteFunction getRouteFilter(TableAvailable table, Object shardingValue, ShardingOperatorEnum shardingOperator, boolean withEntity) {
            LocalDateTime createTime = (LocalDateTime) shardingValue;
            String dataSource = "ds" + createTime.getYear();
            switch (shardingOperator){
                case GREATER_THAN:
                case GREATER_THAN_OR_EQUAL:
                    return ds-> dataSource.compareToIgnoreCase(ds)<=0;
                case LESS_THAN:
                {
                    //如果小于月初那么月初的表是不需要被查询的
                    LocalDateTime timeYearFirstDay = LocalDateTime.of(createTime.getYear(),1,1,0,0,0);
                    if(createTime.isEqual(timeYearFirstDay)){
                        return ds->dataSource.compareToIgnoreCase(ds)>0;
                    }
                    return ds->dataSource.compareToIgnoreCase(ds)>=0;
                }
                case LESS_THAN_OR_EQUAL:
                    return ds->dataSource.compareToIgnoreCase(ds)>=0;
    
                case EQUAL:
                    return ds->dataSource.compareToIgnoreCase(ds)==0;
                default:return t->true;
            }
        }
    }
    
    
    LocalDateTime beginTime = LocalDateTime.of(2020, 1, 1, 1, 1);
    LocalDateTime endTime = LocalDateTime.of(2023, 5, 1, 1, 1);
    Duration between = Duration.between(beginTime, endTime);
    long days = between.toDays();
    EasyPageResult pageResult = easyQuery.queryable(TopicShardingDataSource.class)
            .orderByAsc(o -> o.column(TopicShardingDataSource::getCreateTime))
            .toPageResult(1, 33);
    
    
    ==> SHARDING_EXECUTOR_23, name:ds2022, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_ds` t ORDER BY t.`create_time` ASC LIMIT 33
    ==> SHARDING_EXECUTOR_11, name:ds2021, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_ds` t ORDER BY t.`create_time` ASC LIMIT 33
    ==> SHARDING_EXECUTOR_2, name:ds2020, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_ds` t ORDER BY t.`create_time` ASC LIMIT 33
    ==> SHARDING_EXECUTOR_4, name:ds2023, Preparing: SELECT t.`id`,t.`stars`,t.`title`,t.`create_time` FROM `t_topic_sharding_ds` t ORDER BY t.`create_time` ASC LIMIT 33
    <== SHARDING_EXECUTOR_4, name:ds2023, Time Elapsed: 4(ms)
    <== SHARDING_EXECUTOR_23, name:ds2022, Time Elapsed: 4(ms)
    <== SHARDING_EXECUTOR_2, name:ds2020, Time Elapsed: 4(ms)
    <== SHARDING_EXECUTOR_11, name:ds2021, Time Elapsed: 6(ms)
    <== Total: 33
    

    最后

    希望看到这边的各位大佬给我点个star谢谢这对我很重要

  • 相关阅读:
    多智能体系统集群协同控制实验平台详解与典型案例
    【重点突破】—— quasar 与 uniapp 选型简单对比
    实用新型专利的注意事项
    自学之路Flutter使用Provider进行状态管理
    写论文工具:LaTex在线网站
    VRRP的工作原理和实验详解
    中文分词:Python、Golang、Java
    上海亚商投顾:沪指放量反弹 医药、AI概念股集体走强
    opencv连通域标记 connectedComponentsWithStats()函数
    NISP网络安全中护网面试如何应对(一)
  • 原文地址:https://www.cnblogs.com/xuejiaming/p/17414849.html