当前位置: 首页 > news >正文

Flink 实时数仓(二)【DIM 层搭建】

1、DIM 层搭建

1.1、设计要点

DIM层设计要点:

  • DIM层存的是维度表(环境信息,比如人、场、货等)
  • DIM层的数据存储在 HBase 表中
  • DIM层表名的命名规范为dim_表名

DIM 层表是用于维度关联的,要通过主键(维度外键)去获取相关维度信息,这种场景下 K-V 类型数据库的效率较高。常见的 K-V 类型数据库有 Redis、HBase,而 Redis 的数据常驻内存,会给内存造成较大压力,所以这里选用 HBase 存储维度数据。

1.2、设计分析

在 ODS 层,我们需要首先把所有维表全量同步一次,之后当事实数据来了的时候就可以直接关联;现在 DIM 层,我们需要考虑的问题就是,如何把维表信息保存到 HBase:

  • 读取数据
    • Kafka 的 topic_db 主题中(包含所有46张业务表)
    • 可以使用 Flink 的 Kafka 连接器读取
  • 过滤数据
    • 从 46 张业务表中过滤出所有维表数据
      • 在代码中写死十几张维度表的表名
      • 问题:如果增加维表就需要修改代码重新编译项目,重启任务
      • 思考:如何不修改代码且不重启任务?
        • 1)定时任务:每隔一段时间加载一次配置信息(实时性不好,不可取)
        • 2)监控配置信息:
          • MySQL binlog:配置信息写到 MySQL,然后使用 FlinkCDC 监控直接创建流(数据流 connect 配置流,将配置流中的信息写入到状态中,然后数据流把状态中存在的表过滤出来
          • 文件:Flume tailDir Source -> Kafka -> Flink 的 kafka source 消费创建流(太复杂了,不可取)

注意:多个并行度下,配置流中的数据(需要过滤的表名)会被分配给多个相同的算子处理,会导致并行算子之间的状态不一致,可能导致数据丢失的问题;这就需要把配置流做成一个广播流来和数据流进行 connect,这样写入并行算子的状态就是一致的了;

        广播流的缺点就是存在冗余,而且并行度越大冗余也越大;配置信息小点还好,如果配置信息很大,那么将会占用的资源页越大;这种情况下我们的解决办法就是分流:

        对数据流和配置流都按照表名进行 keyby,相同的 key 再去做 connect,但是这种方案会产生数据倾斜;

  • 写出数据
    • 使用 Phoenix 写出到 HBase(使用 JdbcSink,如果不行就自定义 Sink)

1.3、DIM 层实现

DIM 层的主要任务:读取 Kafka 的数据 -> 简单ETL ->  保存到HBase

        Maxwell 同步过来的数据是到 Kafka 的,我们通过 Flink 自带的 Kafka 连接器进行连接读取,然后对数据先进行简单的 ETL,比如

  • 删除掉非 JSON 格式的数据(这里因为是业务数据所以一般不会有非JSON的情况出现,但是日志数据可能会存在这种情况)
  • 删除掉 type 为 bootstrap-start 和 bootstrap-complete 的数据;
  • 删除掉 type 为 delete 的数据;

最后,通过 Phoenix API 将数据插入到 HBase;

1.3.1、读取 Kafka 中的数据

        读取 Kafka 中的数据为的是创建主流,这里设计到的一个重点就是:Flink 作为消费者在从 Kafka 消费的时候需要对数据进行反序列化,而在反序列时如果使用 Flink 默认的 Kafka 反序列化器(FlinkKafkaConsumer)进行消费的话,可能会出现空指针异常:

        可以看到,反序列化方法中是直接把 kafka message 创建为一个 String 对象,但是 String 的构造器源码中明确声明构造参数不可为 null,而我们的 message 又不可避免存在一些空值,所以这里我们需要重写 FlinkKafkaConsumer 的反序列化方法:

public class MyKafkaUtil {private static final String KAFKA_SERVER = "hadoop102:9092";public static FlinkKafkaConsumer<String> getFlinkKafkaConsumer(String topic,String groupId){Properties properties = new Properties();properties.setProperty(ConsumerConfig.GROUP_ID_CONFIG,groupId);properties.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,KAFKA_SERVER);return new FlinkKafkaConsumer<String>(topic,// 反序列化格式new KafkaDeserializationSchema<String>() {@Overridepublic boolean isEndOfStream(String nextElement) {return false; // 无界流所以返回 false}@Overridepublic String deserialize(ConsumerRecord<byte[], byte[]> record) throws Exception {if (record == null || record.value() == null){return null;}else {return new String(record.value());}}@Overridepublic TypeInformation<String> getProducedType() {return BasicTypeInfo.STRING_TYPE_INFO;}},properties);}
}

这样,我们就可以直接使用通过 Kafka 地址主题 和 组id 使用 Flink 对 Kafka 的主题进行消费:

env.addSource(MyKafkaUtil.getFlinkKafkaConsumer(topic, groupId));

至此,我们的主流已经创建完毕; 

1.3.2、简单 ETL 

        这一步主要为的是将不必要的数据移除掉,比如非JSON数据(日志数据中才可能出现)、maxwell 的脏数据( bootstrap-start 、bootstrap-complete 和 delete)这种无意义的信息;

public class DimApp {public static void main(String[] args) {// TODO 1. 获取执行环境StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();env.setParallelism(1); // 生产环境中设置为kafka主题的分区数// 1.1 开启checkpointenv.enableCheckpointing(5 * 60000L, CheckpointingMode.EXACTLY_ONCE);env.getCheckpointConfig().setCheckpointStorage("hdfs://hadoop102:8020/s/ck");env.getCheckpointConfig().setCheckpointTimeout(10 * 60000L);env.getCheckpointConfig().setMaxConcurrentCheckpoints(2); // 设置最大共存的checkpoint数量env.setRestartStrategy(RestartStrategies.fixedDelayRestart(3,5000L)); // 固定频率重启: 尝试3次重启,每5s重启一次// 1.2 设置状态后端env.setStateBackend(new HashMapStateBackend());// TODO 2. 读取Kafka的topic_db主题,创建主流String topic = "topic_db";String groupId = "test";DataStreamSource<String> kafkaDS = env.addSource(MyKafkaUtil.getFlinkKafkaConsumer(topic, groupId));// TODO 3. ETL// TODO 3.1 过滤掉非JSON数据以及Maxwell的脏数据SingleOutputStreamOperator<JSONObject> filterJsonDS = kafkaDS.flatMap(new FlatMapFunction<String, JSONObject>() {@Overridepublic void flatMap(String value, Collector<JSONObject> out) throws Exception {try {// 过滤非JSON数据JSONObject jsonObject = JSON.parseObject(value);String type = jsonObject.getString("type");if ("bootstrap-insert".equals(type) || "insert".equals(type) || "update".equals(type)) {out.collect(jsonObject);}} catch (Exception e) {System.out.println("发现脏数据" + value);}}});// ...}
}

关于 Flink 的并行度,生产中一般设置为 Kafka 的分区数量(使消费者数量 = 主题分区数量),而不是机器的 CPU核数(机器总不能只跑这一个任务)!

1.3.3、动态增删维表

        现在我们需要从 46 张业务表中过滤出需要的维表,而且这个维表并不是写死的,很可能会出现新增和删除,所以我们希望做到不修改代码且不重启服务的情况下实现,我们上面业说过了,最好使用监控配置信息的方式,一共有三种解决方案:

  • MySQL binlog
    • 也就是把配置信息做成表格,使用 FlinkCDC 实时监控,使用双流联结(主流和配置流),配置流把配置信息(需要同步的维度表信息,包括业务系统中的维表名、写入到phoenix的表名、字段、主键、额外信息等)写入到状态当中,然后主流再去状态中读取并处理;
  • 文件:使用 Flume 的 tailDir source 实时监听文件内容,写到 Kafka 当中,Flink 再从 Kafka 去读,这种方式太复杂了,一般不用
  • Zookeeper:通过 Zookeeper 的 watch 机制将配置信息写入到一个 znode 节点,同样比较复杂

综上,我们一般选择第一种方案:

1)创建配置表
CREATE TABLE `table_process` (`source_table` varchar(200) NOT NULL COMMENT '业务系统来源表', -- mysql业务系统中的表名`sink_table` varchar(200) DEFAULT NULL COMMENT 'phoenix输出表', -- phoenix的表名`sink_columns` varchar(2000) DEFAULT NULL COMMENT 'phoenix建表所需字段', -- 建表需要的字段,过滤主流数据字段`sink_pk` varchar(200) DEFAULT NULL COMMENT 'phoenix建表的主键字段', -- 建表使用的主键(表名做主键)`sink_extend` varchar(200) DEFAULT NULL COMMENT 'phoenix建表扩展', -- 比如预分区等信息PRIMARY KEY (`source_table`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

这里,我们将 source_table 作为配置表的主键,这样可以通过它获取到该表需要同步的字段、主键和建表扩展,从而得到完整的 Phoenix 建表语句。

2)创建配置表的实体类

配置流在创建的时候我们并不会直接把它转为 JSON 格式,毕竟我们还要对它进行一些处理,而操作java对象比json对象要更容易;

@Data
public class TableProcess {//来源表String sourceTable;//输出表String sinkTable;//输出字段String sinkColumns;//主键字段String sinkPk;//建表扩展String sinkExtend;
}
3)使用 FlinkCDC 创建配置流

注意:FlinkCDC 把 binlog 读取过来会转为 json 格式

        // TODO 4. 使用FlinkCDC读取MySQL配置信息表创建配置流MySqlSource<String> mySqlSource = MySqlSource.<String>builder().hostname("hadoop102").port(3306).username("root").password("123456").databaseList("gmall").tableList("gmall.table_config").startupOptions(StartupOptions.initial()) // 全部读取.deserializer(new JsonDebeziumDeserializationSchema()) // flink读取binlog会把它转为json格式,所以这里需要一共json的反序列化方式.build();DataStreamSource<String> mysqlSourceDS = env.fromSource(mySqlSource,WatermarkStrategy.noWatermarks(),"MysqlSource");
4)配置流形成广播流

配置流不能直接和主流联结,会造成数据丢失(多并行度下,配置信息会轮询发送到相同的算子上),所以我们需要把它转为广播流;

创建广播流需要传入一个 Map 类型的状态描述器:

  • K 必须是主流和配置流都有的信息,这样主流才能和广播流产生关联,所以这里我们使用表名做为 K;
  • V 是配置流中的数据,这里我们选择上面自定义 TableProcess 对象,这个对象包含了该表(K)的所有配置信息;
        // TODO 5. 将配置流处理为广播流// K的要求: 1.必须是主流和配置流都有的字段 2. 唯一// V: 这里的v应该是配置流中的数据,但是为了方便过滤字段(操作java对象比json对象要容易),所以这里转为Java对象MapStateDescriptor<String, TableProcess> mapStateDescriptor = new MapStateDescriptor<>("map-state", String.class, TableProcess.class);// 这里的泛型是广播流的类型BroadcastStream<String> configBroadcastStream = mysqlSourceDS.broadcast(mapStateDescriptor);
5)双流联结并处理

这里对主流和配置流联结后需要进行处理:

  • 把配置流中的配置信息写入到状态后端使其自动广播
    • 从配置流(FlinkCDC 读取过来 json 信息)中提取出表格信息
    • 校验 phoenix 表格(不存在就创建)
    • 写入到状态中(因为现在是广播流所以会自动广播)
  • 把主流中的非维表去除掉,以及维表中不需要的字段
    • 从状态中获得配置信息
    • 除去非维表并过滤字段,只留下配置流当中存在的字段
    • 给主流添加上配置信息中的 sinkTable 字段(因为主流不知道最终向哪个phoenix表写入)
        // TODO 6. 连接主流和广播流// 这里的泛型: 1. 非广播流的数据类型 2. 广播流的数据类型BroadcastConnectedStream<JSONObject, String> connectedStream = filterJsonDS.connect(configBroadcastStream);// TODO 7. 处理连接流,根据配置信息处理主流数据// 得到维表数据流(已经把配置流中不需要的维表字段过滤掉了,以及非维表也被过滤掉了)SingleOutputStreamOperator<JSONObject> dimDS = connectedStream.process(new TableProcessFunction(mapStateDescriptor));

这里的 TableProcessFunction 是我们自定义的广播流处理函数:

// 这里的泛型: 1. 非广播流的数据类型 2. 广播流的数据类型 3. 输出类型这里选择主流的数据类型,因为毕竟我们用配置流的目的就是为了得到过滤后的主流数据
public class TableProcessFunction extends BroadcastProcessFunction<JSONObject, String, JSONObject> {private Connection connection;private MapStateDescriptor<String, TableProcess> mapStateDescriptor;public TableProcessFunction(MapStateDescriptor<String, TableProcess> mapStateDescriptor){this.mapStateDescriptor = mapStateDescriptor;}// 保证每个并行度创建一个连接@Overridepublic void open(Configuration parameters) throws Exception {connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);}@Overridepublic void close() throws Exception {connection.close();}/*** value 的值:* {*  "before":null,*  "after":{*      "source_table":"aa",*      "sink_table":"bb",*      "sink_columns":"cc",*      "sink_pk":"id",*      "sink_extend":"xxx"},*  "source":{*      "version":"1.5.4.Final",*      "connector":"mysql",*      "name":"mysql_binlog_source",*      "ts_ms":1652513039549,*      "snapshot":"false",*      "db":"gmall-211126-config",*      "sequence":null,*      "table":"table_process",*      "server_id":0,*      "gtid":null,*      "file":"",*      "pos":0,*      "row":0,*      "thread":null,*      "query":null},*   "op":"r",*   "ts_ms":1652513039551,*   "transaction":null}*/@Overridepublic void processBroadcastElement(String value, Context context, Collector<JSONObject> collector) throws Exception {// TODO 1. 获取并解析数据,方便主流操作(把 "after" 字段的内容解析为 TableProcess 对象)JSONObject jsonObject = JSONObject.parseObject(value);TableProcess tableProcess = JSONObject.parseObject(jsonObject.getString("after"), TableProcess.class);// TODO 2. 校验表在phoenix中是否存在checkTable(tableProcess.getSinkTable(),tableProcess.getSinkColumns(),tableProcess.getSinkPk(),tableProcess.getSinkExtend());// TODO 3. 写入状态,广播出去BroadcastState<String, TableProcess> broadcastState = context.getBroadcastState(mapStateDescriptor);broadcastState.put(tableProcess.getSourceTable(),tableProcess);}/*** 校验并创建phoenix表: create table if not exists db.tb(xx varchar primary key,xx varchar, ...) xxx* @param sinkTable 表名* @param sinkColumns 字段* @param sinkPk 主键* @param sinkExtend 扩展字段*/private void checkTable(String sinkTable, String sinkColumns, String sinkPk, String sinkExtend) {PreparedStatement preparedStatement = null;try {// 处理特殊字段值(null)if (sinkPk == null || "".equals(sinkPk))sinkPk = "id";if (sinkExtend == null)sinkExtend = "";// 拼接 SQLStringBuilder sql = new StringBuilder("create table if not exists ").append(GmallConfig.HBASE_SCHEMA).append(".").append(sinkTable).append("(");String[] columns = sinkColumns.split(",");for (int i = 0; i < columns.length-1; i++) {sql.append(" ").append(columns[i]).append(" varchar");if (columns[i].equals(sinkPk))sql.append(" primary key");sql.append(",");}sql.append(columns[columns.length-1]).append(") ").append(sinkExtend);// 编译 SQLpreparedStatement = connection.prepareStatement(sql.toString());// 执行 SQLpreparedStatement.execute();}catch (SQLException e){// 要停止程序必须使用运行时异常而不是编译时异常throw new RuntimeException("建表失败 " + sinkTable);}finally {// 释放资源if (preparedStatement != null){try {preparedStatement.close();} catch (SQLException e) {e.printStackTrace();}}}}// 主流数据是一个json格式(maxwell数据)/*** {*  "database":"gmall-211126-flink",*  "table":"base_trademark",*  "type":"bootstrap-insert",*  "ts":1652499295,*  "data":{*      "id":1,*      "tm_name":"三星",*      "logo_url":"/static/default.jpg"*      }* }*/@Overridepublic void processElement(JSONObject jsonObject, ReadOnlyContext readOnlyContext, Collector<JSONObject> collector) throws Exception {// TODO 1. 获取广播的配置数据ReadOnlyBroadcastState<String, TableProcess> broadcastState = readOnlyContext.getBroadcastState(mapStateDescriptor);// 如果返回 null 说明不是维表TableProcess tableProcess = broadcastState.get(jsonObject.getString("table"));// TODO 2. 过滤字段,只留下配置流当中存在的字段if (tableProcess == null) return;filterColumns(jsonObject.getJSONObject("data"),tableProcess.getSinkColumns());// TODO 3. 补充 SinkTable 字段(因为主流中是不包含phoenix表名的)jsonObject.put("sinkTable",tableProcess.getSinkTable());collector.collect(jsonObject);}/*** 过滤字段: 将主流中配置流中有的字段留下,其它字段删除* @param data 可能是维表,也可能是事实表 {"database":"gmall-211126-flink","table":"base_trademark","type":"bootstrap-insert","ts":1652499295,"data":{"id":1,"tm_name":"三星","logo_url":"/static/default.jpg"}}* @param sinkColumns phoenix 列名*/private void filterColumns(JSONObject data, String sinkColumns) {// 把 JSONObject 当做 Map 处理即可String[] split = sinkColumns.split(",");Set<String> phoenix_columns = new HashSet<>(Arrays.asList(split));for (String column : data.keySet()){if (!phoenix_columns.contains(column)){data.remove(column);}}// 简写: data.entrySet().removeIf(entry -> !phoenix_columns.contains(entry.getKey()));}}

至此,我们就得到了最终等待写入到 HBase 的流 dimDS;

1.3.4、写入 Phoenix

        上面我们在连接 phoenix 校验表格的时候用的是 jdbc 来访问的,而 Flink 也提供了 JdbcSink 连接器,那这里我们能不能使用呢?

        其实这里使用 JdbcSink 是可以的,但是不推荐,因为 JdbcSink 适合的是单表写入的场景,而我们的 dimDS 数据流中存放的是多个维度表的数据,这就要求当数据来的时候,我们要根据不同的表生成不同的 SQL,而这里的 addSink 方法中的 sql 语句必须是先给定的,尽管不确定的表名、字段名等可以使用占位符,但是我们不能保证所有维表的字段数量都是一样的;所以,这种方式显然不可取,那我们就只能自定义一个 Sink 了:

1)创建 Druid 连接池

Phoenix 是支持 JDBC 协议,这里为了方便连接管理我们使用 Druid 来创建连接池;

public class DruidDSUtil {private static DruidDataSource druidDataSource = null;public static DruidDataSource createDataSource() {// 创建连接池druidDataSource = new DruidDataSource();// 设置驱动全类名druidDataSource.setDriverClassName(GmallConfig.PHOENIX_DRIVER);// 设置连接 urldruidDataSource.setUrl(GmallConfig.PHOENIX_SERVER);// 设置初始化连接池时池中连接的数量druidDataSource.setInitialSize(5);// 设置同时活跃的最大连接数druidDataSource.setMaxActive(20);// 设置空闲时的最小连接数,必须介于 0 和最大连接数之间,默认为 0druidDataSource.setMinIdle(1);// 设置没有空余连接时的等待时间,超时抛出异常,-1 表示一直等待druidDataSource.setMaxWait(-1);// 验证连接是否可用使用的 SQL 语句druidDataSource.setValidationQuery("select 1");// 指明连接是否被空闲连接回收器(如果有)进行检验,如果检测失败,则连接将被从池中去除// 注意,默认值为 true,如果没有设置 validationQuery,则报错// testWhileIdle is true, validationQuery not setdruidDataSource.setTestWhileIdle(true);// 借出连接时,是否测试,设置为 false,不测试,否则很影响性能druidDataSource.setTestOnBorrow(false);// 归还连接时,是否测试druidDataSource.setTestOnReturn(false);// 设置空闲连接回收器每隔 30s 运行一次druidDataSource.setTimeBetweenEvictionRunsMillis(30 * 1000L);// 设置池中连接空闲 30min 被回收,默认值即为 30 mindruidDataSource.setMinEvictableIdleTimeMillis(30 * 60 * 1000L);return druidDataSource;}
}
2)Phoenix 工具类

为了方法的复用性,我们把写入 Phoenix 的方法抽出来:

public class PhoenixUtil {/*** 将主流数据写入 phoenix* @param connection phoenix连接* @param sinkTable 表名* @param data 数据* @throws SQLException 这里的异常直接抛出去.因为工具类中的方法是给大家公用的,而不同的业务捕获到异常的处理方案是不一样的* 所以这里把处理异常的权利交给每个调用该方法的人*/public static void upsertValues(DruidPooledConnection connection, String sinkTable, JSONObject data) throws SQLException {// 1. 拼接 SQL: upsert into db.tb(id,name,sex) values ('1001','zhangsan','man')Set<String> columns = data.keySet();Collection<Object> values = data.values();String sql = "upsert into " + GmallConfig.HBASE_SCHEMA + "." + sinkTable + "("+ StringUtils.join(columns,",") +") values ( '"+ StringUtils.join(values,"','") + "')";// 2. 预编译 SQLPreparedStatement preparedStatement = connection.prepareStatement(sql);// 3. 执行preparedStatement.execute();connection.commit();// 4. 释放资源preparedStatement.close(); // connection 在 Sink 的 invoke 里面关}
}
3)自定义 Sink

自定义 Sink ,在 open 方法中获得连接,在 invoke 中执行插入数据到 Phoenix ,然后回收连接;

// 输入数据类型应该是 dimDS 的类型,也就是主流类型 JSONObject
public class DimSinkFunction extends RichSinkFunction<JSONObject> {private DruidDataSource druidDataSource = null;@Overridepublic void open(Configuration parameters) throws Exception {druidDataSource = DruidDSUtil.createDataSource();}/*** value:* {*  "database":"gmall-211126-flink",*  "table":"base_trademark",*  "type":"bootstrap-insert",*  "ts":1652499295,*  "data":{*      "id":1,*      "tm_name":"三星"*      },*  "sinkTable": "dim_xxx"* }*/@Overridepublic void invoke(JSONObject value, Context context) throws Exception {// 获取连接DruidPooledConnection connection = druidDataSource.getConnection();// 写出数据(需要知道写出的表名、字段)String sinkTable = value.getString("sinkTable");JSONObject data = value.getJSONObject("data");// 如果插入数据失败 invoke 方法抛出的 Exception 会导致程序停止PhoenixUtil.upsertValues(connection,sinkTable,data);// 归还连接connection.close();}
}

总结

        至此,DIM 层搭建完毕,在离线数仓的 DIM 层中,它需要在 ODS 层的基础上抽取出主维表和相关维表,然后主维表通过 left join 相关维表得到最终的 dim 层的维表;而实时数仓中我们主要是通过 Flink 代码来对数据流进行实时处理,代码的编写确实比 SQL 更有意思;

相关文章:

  • 北京网站建设多少钱?
  • 辽宁网页制作哪家好_网站建设
  • 高端品牌网站建设_汉中网站制作
  • 中介子方程七十九
  • Apache Kylin数据模型设计:从ETL到多维分析
  • 自闭症儿童无法上学?专业康复机构是希望的灯塔
  • OpenCV Python 图像相加与透明色转换
  • Day13--JavaWeb学习之Servlet后端渲染界面
  • 算法学习day28
  • VBA学习(22):动态显示日历
  • 网页UI设计工具全攻略:九大精选
  • 【初阶数据结构题目】9. 链表分割
  • JAVA(IO流)7.31
  • 声临其境!体验阿里云开源音频基座大模型——FunAudioLLM
  • 【LeetCode】33.搜索旋转排序数组
  • Vue学习(三)条件渲染、列表渲染
  • Linux--shell脚本语言—/—<1>
  • 【C++学习第19天】最小生成树(对应无向图)
  • eclipse(luna)创建web工程
  • es6--symbol
  • JS基础篇--通过JS生成由字母与数字组合的随机字符串
  • mysql中InnoDB引擎中页的概念
  • node-glob通配符
  • orm2 中文文档 3.1 模型属性
  • Python 反序列化安全问题(二)
  • spring + angular 实现导出excel
  • SQL 难点解决:记录的引用
  • 关键词挖掘技术哪家强(一)基于node.js技术开发一个关键字查询工具
  • 关于使用markdown的方法(引自CSDN教程)
  • 看图轻松理解数据结构与算法系列(基于数组的栈)
  • 前端技术周刊 2019-01-14:客户端存储
  • 前嗅ForeSpider中数据浏览界面介绍
  • 驱动程序原理
  • 视频flv转mp4最快的几种方法(就是不用格式工厂)
  • 体验javascript之美-第五课 匿名函数自执行和闭包是一回事儿吗?
  • 验证码识别技术——15分钟带你突破各种复杂不定长验证码
  • 优化 Vue 项目编译文件大小
  • 职业生涯 一个六年开发经验的女程序员的心声。
  • 最近的计划
  • JavaScript 新语法详解:Class 的私有属性与私有方法 ...
  • 摩拜创始人胡玮炜也彻底离开了,共享单车行业还有未来吗? ...
  • ​Base64转换成图片,android studio build乱码,找不到okio.ByteString接腾讯人脸识别
  • ​马来语翻译中文去哪比较好?
  • ​软考-高级-信息系统项目管理师教程 第四版【第19章-配置与变更管理-思维导图】​
  • ​虚拟化系列介绍(十)
  • ​总结MySQL 的一些知识点:MySQL 选择数据库​
  • ‌JavaScript 数据类型转换
  • ### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLTr
  • (八)Docker网络跨主机通讯vxlan和vlan
  • (初研) Sentence-embedding fine-tune notebook
  • (附源码)springboot炼糖厂地磅全自动控制系统 毕业设计 341357
  • (过滤器)Filter和(监听器)listener
  • (亲测有效)解决windows11无法使用1500000波特率的问题
  • (深度全面解析)ChatGPT的重大更新给创业者带来了哪些红利机会
  • (太强大了) - Linux 性能监控、测试、优化工具
  • (原创)攻击方式学习之(4) - 拒绝服务(DOS/DDOS/DRDOS)
  • (转)eclipse内存溢出设置 -Xms212m -Xmx804m -XX:PermSize=250M -XX:MaxPermSize=356m
  • (转)Sublime Text3配置Lua运行环境