[BitSail] Connector開發詳解系列三:SourceReader

更多技術交流、求職機會,歡迎關注字節跳動數據平臺微信公眾號,回復【1】進入官方交流群

Source Connector

本文將主要介紹負責數據讀取的組件SourceReader:

SourceReader

每個SourceReader都在獨立的線程中執行,只要我們保證SourceSplitCoordinator分配給不同SourceReader的切片沒有交集,在SourceReader的執行周期中,我們就可以不考慮任何有關并發的細節。

SourceReader接口

public interface SourceReader<T, SplitT extends SourceSplit> extends Serializable, AutoCloseable {void start();void pollNext(SourcePipeline<T> pipeline) throws Exception;void addSplits(List<SplitT> splits);/*** Check source reader has more elements or not.*/boolean hasMoreElements();/*** There will no more split will send to this source reader.* Source reader could be exited after process all assigned split.*/default void notifyNoMoreSplits() {}/*** Process all events which from {@link SourceSplitCoordinator}.*/default void handleSourceEvent(SourceEvent sourceEvent) {}/*** Store the split to the external system to recover when task failed.*/List<SplitT> snapshotState(long checkpointId);/*** When all tasks finished snapshot, notify checkpoint complete will be invoked.*/default void notifyCheckpointComplete(long checkpointId) throws Exception {}interface Context {TypeInfo<?>[] getTypeInfos();String[] getFieldNames();int getIndexOfSubtask();void sendSplitRequest();}
}

構造方法

這里需要完成和數據源訪問各種配置的提取,比如數據庫庫名表名、消息隊列cluster和topic、身份認證的配置等等。

示例

public RocketMQSourceReader(BitSailConfiguration readerConfiguration,Context context,Boundedness boundedness) {this.readerConfiguration = readerConfiguration;this.boundedness = boundedness;this.context = context;this.assignedRocketMQSplits = Sets.newHashSet();this.finishedRocketMQSplits = Sets.newHashSet();this.deserializationSchema = new RocketMQDeserializationSchema(readerConfiguration,context.getTypeInfos(),context.getFieldNames());this.noMoreSplits = false;cluster = readerConfiguration.get(RocketMQSourceOptions.CLUSTER);topic = readerConfiguration.get(RocketMQSourceOptions.TOPIC);consumerGroup = readerConfiguration.get(RocketMQSourceOptions.CONSUMER_GROUP);consumerTag = readerConfiguration.get(RocketMQSourceOptions.CONSUMER_TAG);pollBatchSize = readerConfiguration.get(RocketMQSourceOptions.POLL_BATCH_SIZE);pollTimeout = readerConfiguration.get(RocketMQSourceOptions.POLL_TIMEOUT);commitInCheckpoint = readerConfiguration.get(RocketMQSourceOptions.COMMIT_IN_CHECKPOINT);accessKey = readerConfiguration.get(RocketMQSourceOptions.ACCESS_KEY);secretKey = readerConfiguration.get(RocketMQSourceOptions.SECRET_KEY);
}

start方法

初始化數據源的訪問對象,例如數據庫的執行對象、消息隊列的consumer對象或者文件系統的連接。

示例

消息隊列

public void start() {try {if (StringUtils.isNotEmpty(accessKey) && StringUtils.isNotEmpty(secretKey)) {AclClientRPCHook aclClientRPCHook = new AclClientRPCHook(new SessionCredentials(accessKey, secretKey));consumer = new DefaultMQPullConsumer(aclClientRPCHook);} else {consumer = new DefaultMQPullConsumer();}consumer.setConsumerGroup(consumerGroup);consumer.setNamesrvAddr(cluster);consumer.setInstanceName(String.format(SOURCE_READER_INSTANCE_NAME_TEMPLATE,cluster, topic, consumerGroup, UUID.randomUUID()));consumer.setConsumerPullTimeoutMillis(pollTimeout);consumer.start();} catch (Exception e) {throw BitSailException.asBitSailException(RocketMQErrorCode.CONSUMER_CREATE_FAILED, e);}
}

數據庫

public void start() {this.connection = connectionHolder.connect();// Construct statement.String baseSql = ClickhouseJdbcUtils.getQuerySql(dbName, tableName, columnInfos);String querySql = ClickhouseJdbcUtils.decorateSql(baseSql, splitField, filterSql, maxFetchCount, true);try {this.statement = connection.prepareStatement(querySql);} catch (SQLException e) {throw new RuntimeException("Failed to prepare statement.", e);}LOG.info("Task {} started.", subTaskId);
}

FTP

public void start() {this.ftpHandler.loginFtpServer();if (this.ftpHandler.getFtpConfig().getSkipFirstLine()) {this.skipFirstLine = true;}
}

addSplits方法

將SourceSplitCoordinator給當前Reader分配的Splits列表添加到自己的處理隊列(Queue)或者集合(Set)中。

示例

public void addSplits(List<RocketMQSplit> splits) {LOG.info("Subtask {} received {}(s) new splits, splits = {}.",context.getIndexOfSubtask(),CollectionUtils.size(splits),splits);assignedRocketMQSplits.addAll(splits);
}

hasMoreElements方法

在無界的流計算場景中,會一直返回true保證Reader線程不被銷毀。

在批式場景中,分配給該Reader的切片處理完之后會返回false,表示該Reader生命周期的結束。

public boolean hasMoreElements() {if (boundedness == Boundedness.UNBOUNDEDNESS) {return true;}if (noMoreSplits) {return CollectionUtils.size(assignedRocketMQSplits) != 0;}return true;
}

pollNext方法

在addSplits方法添加完成切片處理隊列且hasMoreElements返回true時,該方法調用,開發者實現此方法真正和數據交互。

開發者在實現pollNext方法時候需要關注下列問題:

  • 切片數據的讀取

    • 從構造好的切片中去讀取數據。

  • 數據類型的轉換

    • 將外部數據轉換成BitSail的Row類型

示例

以RocketMQSourceReader為例:

從split隊列中選取split進行處理,讀取其信息,之后需要將讀取到的信息轉換成BitSail的Row類型,發送給下游處理。

public void pollNext(SourcePipeline<Row> pipeline) throws Exception {for (RocketMQSplit rocketmqSplit : assignedRocketMQSplits) {MessageQueue messageQueue = rocketmqSplit.getMessageQueue();PullResult pullResult = consumer.pull(rocketmqSplit.getMessageQueue(),consumerTag,rocketmqSplit.getStartOffset(),pollBatchSize,pollTimeout);if (Objects.isNull(pullResult) || CollectionUtils.isEmpty(pullResult.getMsgFoundList())) {continue;}for (MessageExt message : pullResult.getMsgFoundList()) {Row deserialize = deserializationSchema.deserialize(message.getBody());pipeline.output(deserialize);if (rocketmqSplit.getStartOffset() >= rocketmqSplit.getEndOffset()) {LOG.info("Subtask {} rocketmq split {} in end of stream.",context.getIndexOfSubtask(),rocketmqSplit);finishedRocketMQSplits.add(rocketmqSplit);break;}}rocketmqSplit.setStartOffset(pullResult.getNextBeginOffset());if (!commitInCheckpoint) {consumer.updateConsumeOffset(messageQueue, pullResult.getMaxOffset());}}assignedRocketMQSplits.removeAll(finishedRocketMQSplits);
}

轉換為BitSail Row類型的常用方式

自定義RowDeserializer類

對于不同格式的列應用不同converter,設置到相應Row的Field。

public class ClickhouseRowDeserializer {interface FiledConverter {Object apply(ResultSet resultSet) throws SQLException;}private final List<FiledConverter> converters;private final int fieldSize;public ClickhouseRowDeserializer(TypeInfo<?>[] typeInfos) {this.fieldSize = typeInfos.length;this.converters = new ArrayList<>();for (int i = 0; i < fieldSize; ++i) {converters.add(initFieldConverter(i + 1, typeInfos[i]));}}public Row convert(ResultSet resultSet) {Row row = new Row(fieldSize);try {for (int i = 0; i < fieldSize; ++i) {row.setField(i, converters.get(i).apply(resultSet));}} catch (SQLException e) {throw BitSailException.asBitSailException(ClickhouseErrorCode.CONVERT_ERROR, e.getCause());}return row;}private FiledConverter initFieldConverter(int index, TypeInfo<?> typeInfo) {if (!(typeInfo instanceof BasicTypeInfo)) {throw BitSailException.asBitSailException(CommonErrorCode.UNSUPPORTED_COLUMN_TYPE, typeInfo.getTypeClass().getName() + " is not supported yet.");}Class<?> curClass = typeInfo.getTypeClass();if (TypeInfos.BYTE_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getByte(index);}if (TypeInfos.SHORT_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getShort(index);}if (TypeInfos.INT_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getInt(index);}if (TypeInfos.LONG_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getLong(index);}if (TypeInfos.BIG_INTEGER_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> {BigDecimal dec = resultSet.getBigDecimal(index);return dec == null ? null : dec.toBigInteger();};}if (TypeInfos.FLOAT_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getFloat(index);}if (TypeInfos.DOUBLE_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getDouble(index);}if (TypeInfos.BIG_DECIMAL_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getBigDecimal(index);}if (TypeInfos.STRING_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getString(index);}if (TypeInfos.SQL_DATE_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getDate(index);}if (TypeInfos.SQL_TIMESTAMP_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getTimestamp(index);}if (TypeInfos.SQL_TIME_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getTime(index);}if (TypeInfos.BOOLEAN_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> resultSet.getBoolean(index);}if (TypeInfos.VOID_TYPE_INFO.getTypeClass() == curClass) {return resultSet -> null;}throw new UnsupportedOperationException("Unsupported data type: " + typeInfo);}
}
實現DeserializationSchema接口

相對于實現RowDeserializer,我們更希望大家去實現一個繼承DeserializationSchema接口的實現類,將一定類型格式的數據對數據比如JSON、CSV轉換為BitSail Row類型。?

在具體的應用時,我們可以使用統一的接口創建相應的實現類

public class TextInputFormatDeserializationSchema implements DeserializationSchema<Writable, Row> {private BitSailConfiguration deserializationConfiguration;private TypeInfo<?>[] typeInfos;private String[] fieldNames;private transient DeserializationSchema<byte[], Row> deserializationSchema;public TextInputFormatDeserializationSchema(BitSailConfiguration deserializationConfiguration,TypeInfo<?>[] typeInfos,String[] fieldNames) {this.deserializationConfiguration = deserializationConfiguration;this.typeInfos = typeInfos;this.fieldNames = fieldNames;ContentType contentType = ContentType.valueOf(deserializationConfiguration.getNecessaryOption(HadoopReaderOptions.CONTENT_TYPE, HadoopErrorCode.REQUIRED_VALUE).toUpperCase());switch (contentType) {case CSV:this.deserializationSchema =new CsvDeserializationSchema(deserializationConfiguration, typeInfos, fieldNames);break;case JSON:this.deserializationSchema =new JsonDeserializationSchema(deserializationConfiguration, typeInfos, fieldNames);break;default:throw BitSailException.asBitSailException(HadoopErrorCode.UNSUPPORTED_ENCODING, "unsupported parser type: " + contentType);}}@Overridepublic Row deserialize(Writable message) {return deserializationSchema.deserialize((message.toString()).getBytes());}@Overridepublic boolean isEndOfStream(Row nextElement) {return false;}
}

也可以自定義當前需要解析類專用的DeserializationSchema:

public class MapredParquetInputFormatDeserializationSchema implements DeserializationSchema<Writable, Row> {private final BitSailConfiguration deserializationConfiguration;private final transient DateTimeFormatter localDateTimeFormatter;private final transient DateTimeFormatter localDateFormatter;private final transient DateTimeFormatter localTimeFormatter;private final int fieldSize;private final TypeInfo<?>[] typeInfos;private final String[] fieldNames;private final List<DeserializationConverter> converters;public MapredParquetInputFormatDeserializationSchema(BitSailConfiguration deserializationConfiguration,TypeInfo<?>[] typeInfos,String[] fieldNames) {this.deserializationConfiguration = deserializationConfiguration;this.typeInfos = typeInfos;this.fieldNames = fieldNames;this.localDateTimeFormatter = DateTimeFormatter.ofPattern(deserializationConfiguration.get(CommonOptions.DateFormatOptions.DATE_TIME_PATTERN));this.localDateFormatter = DateTimeFormatter.ofPattern(deserializationConfiguration.get(CommonOptions.DateFormatOptions.DATE_PATTERN));this.localTimeFormatter = DateTimeFormatter.ofPattern(deserializationConfiguration.get(CommonOptions.DateFormatOptions.TIME_PATTERN));this.fieldSize = typeInfos.length;this.converters = Arrays.stream(typeInfos).map(this::createTypeInfoConverter).collect(Collectors.toList());}@Overridepublic Row deserialize(Writable message) {int arity = fieldNames.length;Row row = new Row(arity);Writable[] writables = ((ArrayWritable) message).get();for (int i = 0; i < fieldSize; ++i) {row.setField(i, converters.get(i).convert(writables[i].toString()));}return row;}@Overridepublic boolean isEndOfStream(Row nextElement) {return false;}private interface DeserializationConverter extends Serializable {Object convert(String input);}private DeserializationConverter createTypeInfoConverter(TypeInfo<?> typeInfo) {Class<?> typeClass = typeInfo.getTypeClass();if (typeClass == TypeInfos.VOID_TYPE_INFO.getTypeClass()) {return field -> null;}if (typeClass == TypeInfos.BOOLEAN_TYPE_INFO.getTypeClass()) {return this::convertToBoolean;}if (typeClass == TypeInfos.INT_TYPE_INFO.getTypeClass()) {return this::convertToInt;}throw BitSailException.asBitSailException(CsvFormatErrorCode.CSV_FORMAT_COVERT_FAILED,String.format("Csv format converter not support type info: %s.", typeInfo));}private boolean convertToBoolean(String field) {return Boolean.parseBoolean(field.trim());}private int convertToInt(String field) {return Integer.parseInt(field.trim());}
}

snapshotState方法

生成并保存State的快照信息,用于ckeckpoint。

示例

public List<RocketMQSplit> snapshotState(long checkpointId) {LOG.info("Subtask {} start snapshotting for checkpoint id = {}.", context.getIndexOfSubtask(), checkpointId);if (commitInCheckpoint) {for (RocketMQSplit rocketMQSplit : assignedRocketMQSplits) {try {consumer.updateConsumeOffset(rocketMQSplit.getMessageQueue(), rocketMQSplit.getStartOffset());LOG.debug("Subtask {} committed message queue = {} in checkpoint id = {}.", context.getIndexOfSubtask(),rocketMQSplit.getMessageQueue(),checkpointId);} catch (MQClientException e) {throw new RuntimeException(e);}}}return Lists.newArrayList(assignedRocketMQSplits);
}

hasMoreElements方法

每次調用pollNext方法之前會做sourceReader.hasMoreElements()的判斷,當且僅當判斷通過,pollNext方法才會被調用。

示例

public boolean hasMoreElements() {if (noMoreSplits) {return CollectionUtils.size(assignedHadoopSplits) != 0;}return true;
}

notifyNoMoreSplits方法

當Reader處理完所有切片之后,會調用此方法。

示例

public void notifyNoMoreSplits() {LOG.info("Subtask {} received no more split signal.", context.getIndexOfSubtask());noMoreSplits = true;
}

【關于BitSail】:

?? Star 不迷路 https://github.com/bytedance/bitsail

提交問題和建議:https://github.com/bytedance/bitsail/issues

貢獻代碼:https://github.com/bytedance/bitsail/pulls

BitSail官網:https://bytedance.github.io/bitsail/zh/

訂閱郵件列表:bitsail+subscribe@googlegroups.com

加入BitSail技術社群

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/40216.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/40216.shtml
英文地址,請注明出處:http://en.pswp.cn/news/40216.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Jmeter進階使用:BeanShell實現接口前置和后置操作

一、背景 我們使用Jmeter做壓力測試或者接口測試時&#xff0c;除了最簡單的直接對接口發起請求&#xff0c;很多時候需要對接口進行一些前置操作&#xff1a;比如提前生成測試數據&#xff0c;以及一些后置操作&#xff1a;比如提取接口響應內容中的某個字段的值。舉個最常用…

c語言——拷貝數組

這段代碼是一個簡單的數組拷貝示例。它的功能是將一個原始數組 original 的內容拷貝到另一個數組 copied 中&#xff0c;并輸出兩個數組的元素。 代碼執行過程如下&#xff1a; 首先&#xff0c;在 main() 函數中定義了一個整型數組 original&#xff0c;并初始化了它的元素。…

【ARM 嵌入式 編譯 Makefile 系列 15 - Makefile define 宏與調用宏函數詳細介紹】

文章目錄 Makefile define 宏與調用宏函數帶參數的宏函數帶返回值的宏函數Makefile define 宏與調用宏函數 在Makefile中,可以通過define關鍵字來定義一個多行的宏(也稱為變量)。這種宏定義通常用于定義一個復雜的命令序列,然后在其他地方調用。 以下是定義一個宏的例子:…

物聯網在制造業中的應用

制造業目前正在經歷第四次工業革命&#xff0c;物聯網、人工智能和機器人等技術進步正在推動行業的發展。研究表明&#xff0c;到2024年&#xff0c;全球制造商將在物聯網解決方案上投資700億美元&#xff0c;許多制造商正在實施物聯網設備&#xff0c;以利用預測性維護和復雜的…

接口測試工具——Postman測試工具 Swagger接口測試+SpringBoot整合 JMeter高并發測試工具

目錄 Postman測試工具接口測試工具swaggerKnife4j1.引入依賴2.配置3.常用注解4.接口測試 JMeter什么是JMeter?JMeter安裝配置1.官網下載2.下載后解壓3.漢語設置 JMeter的使用方法1.新建線程組2.設置參數3.添加取樣器4.設置參數&#xff1a;協議&#xff0c;ip&#xff0c;端口…

SDK是什么,SDK和API有什么區別

SDK&#xff08;Software Development Kit&#xff09;是一種開發工具包&#xff0c;通常由軟件開發公司或平臺提供&#xff0c;用于幫助開發人員構建、測試和集成特定平臺或軟件的應用程序。SDK 包含一系列的庫、工具、示例代碼和文檔&#xff0c;旨在簡化開發過程并提供所需的…

基于Mysql+Vue+Django的協同過濾和內容推薦算法的智能音樂推薦系統——深度學習算法應用(含全部工程源碼)+數據集

目錄 前言總體設計系統整體結構圖系統流程圖 運行環境Python 環境MySQL環境VUE環境 模塊實現1. 數據請求和儲存2. 數據處理計算歌曲、歌手、用戶相似度計算用戶推薦集 3. 數據存儲與后臺4. 數據展示 系統測試工程源代碼下載其它資料下載 前言 本項目以豐富的網易云音樂數據為基…

SQLSERVER 查詢語句加with (NOLOCK) 報ORDER BY 報錯 除非另外還指定了 TOP、OFFSET 或 FOR XML

最近有一個項目在客戶使用時發現死鎖問題&#xff0c;用的數據庫是SQLSERVER &#xff0c;死鎖的原因是有的客戶經常去點報表&#xff0c;報表查詢時間又慢&#xff0c;然后又有人在做單導致了死鎖&#xff0c;然后主管要我們用SQLSERVER查詢時要加with (NOLOCK),但是我在加完 …

YOLOv5模型訓練流程

此文章只是記錄使用&#xff0c;以便后續查看&#xff0c;不作為教程&#xff0c;剛接觸&#xff0c;可能有錯誤 YOLOv5模型訓練流程 一、數據集的準備 1.在源碼根目錄新建mydata文件夾&#xff0c;在此文件夾下新建images和labels文件夾 目錄樹如下&#xff1a; ├───…

鏈表---

題目描述 一個學校里老師要將班上 N 個同學排成一列&#xff0c;同學被編號為 1~N&#xff0c;他采取如下的方法&#xff1a; 先將 11 號同學安排進隊列&#xff0c;這時隊列中只有他一個人&#xff1b; 2~N 號同學依次入列&#xff0c;編號為 i 的同學入列方式為&#xff…

2023骨傳導耳機推薦,適合運動骨傳導耳機推薦

相信很多人跟我一樣&#xff0c;隨著現在五花八門的耳機品種增多&#xff0c;選耳機的時候真是眼花繚亂&#xff0c;尤其還是網購&#xff0c;只能看&#xff0c;不能試&#xff0c;所以選擇起來比較困難&#xff0c; 作為一個運動達人&#xff0c;為了讓大家在購買耳機時少走彎…

〔012〕Stable Diffusion 之 中文提示詞自動翻譯插件 篇

? 目錄 &#x1f388; 翻譯插件&#x1f388; 下載谷歌翻譯&#x1f388; 谷歌翻譯使用方法&#x1f388; 谷歌翻譯使用效果 &#x1f388; 翻譯插件 在插件列表中搜索 Prompt Translator可以看到有2個插件選項&#xff1a;一個是基于谷歌翻譯 〔推薦〕、一個基于百度和deepl…

jvm從入門到精通

jvm 1.jvm與java體系結構???????

奧威BI財務數據分析方案:借BI之利,成就智能財務分析

隨著智能技術的發展&#xff0c;各行各業都走上借助智能技術高效運作道路&#xff0c;財務數據分析也不例外。借助BI商業智能技術能夠讓財務數據分析更高效、便捷、直觀立體&#xff0c;也更有助于發揮財務數據分析作為企業經營管理健康晴雨表的作用。隨著BI財務數據分析經驗的…

【RP2040】香瓜樹莓派RP2040之新建工程

本文最后修改時間&#xff1a;2022年09月05日 11:02 一、本節簡介 本節介紹如何新建一個自己的工程。 二、實驗平臺 1、硬件平臺 1&#xff09;樹莓派pico開發板 ①樹莓派pico開發板*2 ②micro usb數據線*2 2&#xff09;電腦 2、軟件平臺 1&#xff09;VS CODE 三、版…

【C++】一文帶你初識C++繼承

食用指南&#xff1a;本文在有C基礎的情況下食用更佳 &#x1f340;本文前置知識&#xff1a; C類 ??今日夜電波&#xff1a;napori—Vaundy 1:21 ━━━━━━?&#x1f49f;──────── 3:23 …

CSS中的calc()函數有什么作用?

聚沙成塔每天進步一點點 ? 專欄簡介? CSS中的calc()函數及其作用? 作用? 示例1. 動態計算寬度&#xff1a;2. 響應式布局&#xff1a;3. 自適應字體大小&#xff1a;4. 計算間距&#xff1a; ? 寫在最后 ? 專欄簡介 前端入門之旅&#xff1a;探索Web開發的奇妙世界 記得點…

KCC@廣州開源讀書會廣州開源建設討論會

親愛的開源讀書會朋友們&#xff0c; 在下個周末我們將舉辦一場令人激動的線下讀書會&#xff0c;探討兩本引人入勝的新書《只是為了好玩》和《開源之迷》。作為一個致力于推廣開源精神和技術創新的社區&#xff0c;這次我們還邀請了圈內大咖前來參與&#xff0c;會給大家提供一…

軟件測試技術之單元測試—工程師 Style 的測試方法(3)

如何設計單元測試&#xff1f; 單元測試設計方法 單元測試用例&#xff0c;和普通測試用例的設計&#xff0c;沒有太多不同&#xff0c;常見的就是等價類劃分、邊界值分析等。而測試用例的設計其實也是開發者應該掌握的基本技能。 等價類劃分 把所有輸入劃分為若干分類&…

[UE4][C++]使用qrencode動態生成二維碼

一、使用CMake編譯x64版本qrencode 下載地址 GitHub - fukuchi/libqrencode: A fast and compact QR Code encoding libraryA fast and compact QR Code encoding library. Contribute to fukuchi/libqrencode development by creating an account on GitHub.https://github.…