MFC 작업자
MfcClientWorker가 네이티브 클라이언트를CWinThread에서 실행하고 UI 스레드로 이벤트를 넘기는 구조를 이해합니다.- MFC 소비자 예제의 코드를 읽습니다.
- Windows에서 MFC 응용 프로그램에 작업자를 넣어 빌드하는 설정을 확인합니다.
준비물
섹션 제목: “준비물”- Windows 10 이상, MFC 구성 요소를 설치한 Visual Studio 2022(MSVC)
- PDComm OPC UA SDK Windows
native패키지(pdcomm-opcua-native-windows-AMD64.zip):include\pdcomm\opcua\,lib\pdcomm_opcua_ffi.dll.lib,bin\의 DLL, MFC 작업자 헤더bindings\mfc\PdcommOpcUa.hpp. MFC 헤더는 Windows 패키지에만 들어 있습니다.
1. 작업자의 구조
섹션 제목: “1. 작업자의 구조”UI thread (receiver window) Worker thread (CWinThread)--------------------------- --------------------------Submit(request, correlation) --- queue ---> begin / poll / takeRequestStop(Drain or Abort) ------------> close, release ^ | | PostMessage(receiver, message) | events into a bounded mailbox +---- Drain() returns MfcClientEvent <-----+- 작업자는
CWnd에 손대지 않습니다. 이벤트를 크기가 정해진 메일함(mailbox)에 넣고, 수신 창(receiver window)에 등록 메시지를 하나 보냅니다. - 수신 창의 스레드가 그 메시지를 받으면
MfcClientWorker::Drain으로 쌓인 이벤트를 꺼냅니다. 이벤트 처리는 항상 UI 스레드에서 일어납니다. Submit과RequestStop은 어느 스레드에서 불러도 됩니다.Start와Wait은 작업자를 소유한 스레드에서 부릅니다.- 작업자는 한 번만 시작할 수 있습니다. 두 번째
Start는 거짓을 돌려줍니다.
MfcClientEvent의 종류는 다음과 같습니다.
kind |
의미 | 주요 필드 |
|---|---|---|
Ready |
세션이 활성화되었습니다. | ownerThread |
Pending |
요청을 보냈습니다. | correlation, requestId |
Completed |
검증한 응답이 왔습니다. ServiceFault일 수 있습니다. | correlation, metadata, value |
Failed |
요청이나 작업자가 실패했습니다. | correlation(작업자 실패는 0), status, phase, text |
Stopped |
작업자가 끝났습니다. 항상 마지막입니다. | orderly, aborted, accepted, completed, discarded, reason |
Failed의 phase는 Qt 작업자와 같습니다. begin과 response 실패는 그 요청만 끝내고, worker 실패는 작업자를 끝내며 남은 요청을 abort로 보고합니다. 핸들 레지스트리가 가득 차면 작업자는 응답을 보류하고, 응용 프로그램이 응답을 해제하면 전달합니다. timeoutMs 동안 계속 가득 차 있으면 PDCOMM_OPCUA_LIMIT으로 끝납니다.
MfcClientConfiguration의 기본값은 제한 시간 5000 ms, PDCOMM_OPCUA_USER_SECRET_HEADER_POLICY_ONLY 플래그, Aes256_Sha256_RsaPss, SignAndEncrypt, 사용자 이름 활성화입니다. 익명으로 활성화하려면 anonymous를 참으로 설정합니다. 문자열 필드(endpoint, applicationUri, userName)는 UTF-8 CStringA입니다.
2. 예제 코드 읽기
섹션 제목: “2. 예제 코드 읽기”examples/opcua-mfc/main.cpp는 작업자의 규칙을 하나씩 확인하는 MFC 소비자 프로그램입니다. 같은 프로세스에서 SDK 메모리 서버를 None 보안으로 실행하고, 메시지 전용 창(message-only window)을 수신 창으로 씁니다.
#include "PdcommOpcUa.hpp"#include "response_fault_proxy.hpp"#include <afxwin.h>#include <chrono>#include <cstdio>#include <memory>#include <stdexcept>#include <string>#include <vector>CWinApp application;using namespace pdcomm::opcua;
// Part 6 UA Binary ReadRequest (encoding i=631) for ServerState (i=2259) Value.static const std::vector<uint8_t> kRead = { 0x01, 0x00, 0x77, 0x02, 0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0, 0x00, 0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0, 0, 0, 0x01, 0x00, 0xd3, 0x08, 0x0d, 0, 0, 0, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff};
static void Require(bool condition, const char *what) { if (!condition) throw std::runtime_error(what);}
// The UI thread polls the in-process server and pumps the receiver window until the// worker reports its terminal event. Worker events are handled only on this thread.template <typename Handler>static void Pump(server &host, MfcClientWorker &worker, UINT message, Handler &&handle, bool &stopped) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); while (!stopped) { Require(std::chrono::steady_clock::now() < deadline, "MFC worker deadline"); host.poll(); MSG msg; while (::PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { if (msg.message == message) { for (auto &event : worker.Drain()) handle(event); } else { ::TranslateMessage(&msg); ::DispatchMessageW(&msg); } } ::Sleep(1); }}
// Pump that also runs `idle` on the UI thread after every pass, for checks that no worker// event triggers.template <typename Handler, typename Idle>static void PumpWithIdle(server &host, MfcClientWorker &worker, UINT message, Handler &&handle, Idle &&idle, bool &stopped) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); while (!stopped) { Require(std::chrono::steady_clock::now() < deadline, "MFC worker deadline"); host.poll(); MSG msg; while (::PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { if (msg.message == message) { for (auto &event : worker.Drain()) handle(event); } else { ::TranslateMessage(&msg); ::DispatchMessageW(&msg); } } idle(); ::Sleep(1); }}
// Fills the process-wide handle registry (1024 handles) with small buffers until it reports LIMIT.static void FillRegistry(server &host, std::vector<buffer> &filler) { for (int attempt = 0; attempt != 2048; ++attempt) { try { filler.push_back(host.local_address()); } catch (const error &e) { Require(e.status() == PDCOMM_OPCUA_LIMIT && !filler.empty(), "Registry filled up to LIMIT"); return; } } Require(false, "Handle registry was not filled");}
int wmain() { if (!AfxWinInit(GetModuleHandle(nullptr), nullptr, GetCommandLineW(), 0)) return 2; try { auto config = server_options(); const std::string url = "opc.tcp://127.0.0.1:0/pdcomm"; config.endpoint = bytes(url); config.flags = PDCOMM_OPCUA_ALLOW_NONE; auto host = server::create("127.0.0.1:0", config); const auto local = host.local_address().copy(); CStringA endpoint("opc.tcp://"); endpoint.Append(reinterpret_cast<const char *>(local.data()), static_cast<int>(local.size())); endpoint += "/pdcomm"; HWND receiver = ::CreateWindowExW(0, L"STATIC", L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, nullptr, nullptr); Require(receiver != nullptr, "Message-only receiver window"); const UINT message = ::RegisterWindowMessageW(L"PDComm.OpcUa.Mfc.Consumer"); MfcClientConfiguration configuration; configuration.endpoint = endpoint; configuration.anonymous = true; configuration.policy = PDCOMM_OPCUA_POLICY_NONE; configuration.mode = PDCOMM_OPCUA_MODE_NONE; configuration.flags = PDCOMM_OPCUA_ALLOW_NONE;
// Normal drain: valid and invalid requests, owned response after worker destruction. std::shared_ptr<const response> kept; std::vector<uint8_t> keptBytes; { auto worker = std::make_unique<MfcClientWorker>(configuration, receiver, message); Require(worker->Submit({}, 1) == PDCOMM_OPCUA_INVALID_INPUT, "Empty request rejected"); Require(worker->Submit(kRead, 0) == PDCOMM_OPCUA_INVALID_INPUT, "Zero correlation rejected"); Require(worker->Submit(std::vector<uint8_t>(MfcClientWorker::MaximumQueuedBytes + 1, 1), 9) == PDCOMM_OPCUA_LIMIT, "Oversize request rejected"); Require(worker->Submit(kRead, 1) == PDCOMM_OPCUA_OK, "Read admitted"); Require(worker->Submit(kRead, 1) == PDCOMM_OPCUA_BUSY, "Duplicate correlation rejected"); Require(worker->Submit(std::vector<uint8_t>(kRead.begin(), kRead.end() - 1), 2) == PDCOMM_OPCUA_OK, "Truncated request admitted"); Require(worker->Start() && !worker->Start(), "One-shot worker start"); const DWORD uiThread = ::GetCurrentThreadId(); DWORD ownerThread = 0; bool stopped = false, completed = false, invalid = false; Pump(host, *worker, message, [&](const MfcClientEvent &event) { Require(::GetCurrentThreadId() == uiThread, "Events are handled on the receiver thread"); switch (event.kind) { case MfcClientEventKind::Ready: ownerThread = event.ownerThread; break; case MfcClientEventKind::Pending: break; case MfcClientEventKind::Completed: Require(event.correlation == 1 && event.metadata.binary_id == 634 && event.metadata.service_status == 0, "Read response metadata"); kept = event.value; keptBytes = kept->copy(); completed = true; break; case MfcClientEventKind::Failed: Require(event.correlation == 2 && event.status == PDCOMM_OPCUA_INVALID_INPUT && event.phase == L"begin", "Invalid request fails alone"); invalid = true; break; case MfcClientEventKind::Stopped: Require(event.orderly && !event.aborted && event.reason == L"drained-and-closed" && event.accepted == 2 && event.completed == 2 && event.discarded == 0, "Orderly drained stop"); stopped = true; break; } if (completed && invalid) worker->RequestStop(MfcClientStop::Drain); }, stopped); Require(ownerThread != 0 && ownerThread != uiThread, "Native client runs on the worker thread"); Require(worker->Wait(10000), "Worker joined"); Require(worker->Submit(kRead, 3) == PDCOMM_OPCUA_CLOSED, "Submit after stop rejected"); } Require(kept && kept->copy() == keptBytes, "Owned response survives worker destruction");
// Abort: queued requests are discarded and reported, never claimed as an orderly close. { MfcClientWorker worker(configuration, receiver, message); Require(worker.Submit(kRead, 11) == PDCOMM_OPCUA_OK, "Queued before abort"); worker.RequestStop(MfcClientStop::Abort); Require(worker.Start(), "Abort worker start"); bool stopped = false, discarded = false; Pump(host, worker, message, [&](const MfcClientEvent &event) { if (event.kind == MfcClientEventKind::Failed) discarded = event.correlation == 11 && event.status == PDCOMM_OPCUA_CLOSED && event.phase == L"abort"; if (event.kind == MfcClientEventKind::Stopped) { Require(!event.orderly && event.aborted && event.discarded == 1, "Aborted stop"); stopped = true; } }, stopped); Require(discarded && worker.Wait(10000), "Abort discarded and joined"); }
// Response faults through an in-process proxy that changes the response to the first Read // after Arm(). A failed response ends only its request; a closed connection or a registry // that stays full ends the worker. const std::string upstream(reinterpret_cast<const char *>(local.data()), local.size()); const auto proxied = [&](const ResponseFaultProxy &proxy, uint32_t timeoutMs) { MfcClientConfiguration result = configuration; result.endpoint = ("opc.tcp://127.0.0.1:" + std::to_string(proxy.Port()) + "/pdcomm").c_str(); result.timeoutMs = timeoutMs; return result; }; const auto none = [] {};
// Aborted response: only that request fails, and the same worker serves the next one. { ResponseFaultProxy proxy(upstream, ResponseFaultProxy::Mode::Abort); MfcClientWorker worker(proxied(proxy, 5000), receiver, message); Require(worker.Start(), "Aborted-response worker start"); bool stopped = false, failed = false, answered = false; PumpWithIdle(host, worker, message, [&](const MfcClientEvent &event) { switch (event.kind) { case MfcClientEventKind::Ready: proxy.Arm(); Require(worker.Submit(kRead, 21) == PDCOMM_OPCUA_OK, "Read before the abort admitted"); break; case MfcClientEventKind::Pending: break; case MfcClientEventKind::Failed: Require(!failed && event.correlation == 21 && event.status == PDCOMM_OPCUA_RUNTIME_ERROR && event.phase == L"response" && event.text.GetLength() > 0, "Aborted response fails alone"); failed = true; Require(worker.Submit(kRead, 22) == PDCOMM_OPCUA_OK, "Read after the aborted response admitted"); break; case MfcClientEventKind::Completed: Require(failed && event.correlation == 22 && event.metadata.binary_id == 634 && event.metadata.service_status == 0, "Same worker serves the next request"); answered = true; worker.RequestStop(MfcClientStop::Drain); break; case MfcClientEventKind::Stopped: Require(event.orderly && !event.aborted && event.reason == L"drained-and-closed" && event.accepted == 2 && event.completed == 2 && event.discarded == 0, "Orderly drain after an aborted response"); stopped = true; break; } }, none, stopped); Require(failed && answered && proxy.Changed() == 1 && worker.Wait(10000), "Aborted-response worker joined"); }
// Invalid response: its request fails, the closed connection ends the worker and the // remaining request is discarded. { ResponseFaultProxy proxy(upstream, ResponseFaultProxy::Mode::Invalid); MfcClientWorker worker(proxied(proxy, 5000), receiver, message); Require(worker.Start(), "Invalid-response worker start"); bool stopped = false, failed = false, closed = false, discarded = false; PumpWithIdle(host, worker, message, [&](const MfcClientEvent &event) { switch (event.kind) { case MfcClientEventKind::Ready: proxy.Arm(); Require(worker.Submit(kRead, 31) == PDCOMM_OPCUA_OK && worker.Submit(kRead, 32) == PDCOMM_OPCUA_OK, "Reads before the invalid response admitted"); break; case MfcClientEventKind::Pending: break; case MfcClientEventKind::Failed: if (event.correlation == 31) { Require(!failed && event.status == PDCOMM_OPCUA_RUNTIME_ERROR && event.phase == L"response", "Invalid response fails its request"); failed = true; } else if (event.correlation == 0) { Require(failed && event.status == PDCOMM_OPCUA_CLOSED && event.phase == L"worker", "Closed connection ends the worker"); closed = true; } else { Require(closed && event.correlation == 32 && event.status == PDCOMM_OPCUA_CLOSED && event.phase == L"abort", "Remaining request discarded"); discarded = true; } break; case MfcClientEventKind::Completed: Require(false, "No response after an invalid response"); break; case MfcClientEventKind::Stopped: Require(!event.orderly && event.aborted && event.reason == L"native-error" && event.accepted == 2 && event.completed == 1 && event.discarded == 1, "Invalid response ends the worker"); stopped = true; break; } }, none, stopped); Require(failed && closed && discarded && proxy.Changed() == 1 && worker.Wait(10000), "Invalid-response worker joined"); }
// Full handle registry: the worker holds the response and delivers it after the release. { ResponseFaultProxy proxy(upstream, ResponseFaultProxy::Mode::Observe); MfcClientWorker worker(proxied(proxy, 5000), receiver, message); std::vector<buffer> filler; std::chrono::steady_clock::time_point forwarded{}; Require(worker.Start(), "Registry worker start"); bool stopped = false, released = false, first = false, second = false; PumpWithIdle(host, worker, message, [&](const MfcClientEvent &event) { switch (event.kind) { case MfcClientEventKind::Ready: FillRegistry(host, filler); // After connect: the worker's client already holds its handle. proxy.Arm(); Require(worker.Submit(kRead, 41) == PDCOMM_OPCUA_OK, "Read into a full registry admitted"); break; case MfcClientEventKind::Pending: break; case MfcClientEventKind::Completed: if (event.correlation == 41) { Require(released, "No response while the registry is full"); first = true; Require(worker.Submit(kRead, 42) == PDCOMM_OPCUA_OK, "Read after the held response admitted"); } else { Require(first && event.correlation == 42, "Same worker serves the next request"); second = true; worker.RequestStop(MfcClientStop::Drain); } break; case MfcClientEventKind::Failed: Require(false, "Held response failed"); break; case MfcClientEventKind::Stopped: Require(event.orderly && event.accepted == 2 && event.completed == 2 && event.discarded == 0, "Orderly drain after a held response"); stopped = true; break; } }, [&] { // The response reached the client while the registry was full, so the worker holds it. // Release the registry only after the worker had time to try to take it. if (released || proxy.Changed() == 0) return; const auto now = std::chrono::steady_clock::now(); if (forwarded == std::chrono::steady_clock::time_point{}) forwarded = now; if (now - forwarded < std::chrono::milliseconds(200)) return; filler.clear(); released = true; }, stopped); Require(first && second && worker.Wait(10000), "Registry worker joined"); }
// Registry that stays full: the worker ends with LIMIT after its timeout. { ResponseFaultProxy proxy(upstream, ResponseFaultProxy::Mode::Observe); MfcClientWorker worker(proxied(proxy, 1000), receiver, message); std::vector<buffer> filler; std::chrono::steady_clock::time_point forwarded{}, limited{}; Require(worker.Start(), "Registry timeout worker start"); bool stopped = false, discarded = false; PumpWithIdle(host, worker, message, [&](const MfcClientEvent &event) { switch (event.kind) { case MfcClientEventKind::Ready: FillRegistry(host, filler); proxy.Arm(); Require(worker.Submit(kRead, 51) == PDCOMM_OPCUA_OK, "Read into a full registry admitted"); break; case MfcClientEventKind::Pending: break; case MfcClientEventKind::Failed: if (event.correlation == 0) { Require(event.status == PDCOMM_OPCUA_LIMIT && event.phase == L"worker", "Full registry ends the worker"); limited = std::chrono::steady_clock::now(); } else { Require(event.correlation == 51 && event.status == PDCOMM_OPCUA_CLOSED && event.phase == L"abort", "Held request discarded"); discarded = true; } break; case MfcClientEventKind::Completed: Require(false, "No response while the registry is full"); break; case MfcClientEventKind::Stopped: Require(!event.orderly && event.aborted && event.reason == L"native-error" && event.accepted == 1 && event.completed == 0 && event.discarded == 1, "Registry timeout ends the worker"); stopped = true; break; } }, [&] { if (forwarded == std::chrono::steady_clock::time_point{} && proxy.Changed() == 1) forwarded = std::chrono::steady_clock::now(); }, stopped); Require(discarded && forwarded != std::chrono::steady_clock::time_point{} && limited - forwarded >= std::chrono::milliseconds(500) && worker.Wait(10000), "Held response kept until the timeout"); } host.close(); ::DestroyWindow(receiver); std::puts("OPC UA MFC consumer passed"); return 0; } catch (const std::exception &e) { std::fprintf(stderr, "OPC UA MFC consumer failed: %s\n", e.what()); return 1; }}응용 프로그램에 옮길 부분은 다음과 같습니다.
- 수신 창과 메시지.
RegisterWindowMessageW로 메시지 번호를 받고, 이벤트를 받을 창의HWND와 함께 작업자 생성자에 넘깁니다. 대화 상자나 주 창을 그대로 써도 됩니다. - 요청 제출.
Submit(request, correlation)은 UA Binary 요청과 0이 아닌 상관 번호를 받습니다. 빈 요청과 0 상관 번호는PDCOMM_OPCUA_INVALID_INPUT, 같은 상관 번호의 중복은PDCOMM_OPCUA_BUSY, 4 MiB를 넘는 요청은PDCOMM_OPCUA_LIMIT입니다. - 메시지 처리. 예제의
Pump처럼 등록 메시지를 받으면Drain()을 부르고 이벤트를 처리합니다. 일반 MFC 응용 프로그램에서는ON_REGISTERED_MESSAGE처리기에서Drain()을 부릅니다. - 종료.
RequestStop(MfcClientStop::Drain)은 받은 요청을 모두 끝내고 세션을 닫습니다.Stopped이벤트를 받은 뒤Wait으로 스레드를 기다리고 작업자를 해제합니다. - 응답 수명.
Completed의value는 작업자가 사라진 뒤에도 유효합니다. 마지막 참조가 사라질 때 네이티브 핸들이 해제됩니다.
예제의 나머지 부분은 응답 중단, 잘못된 응답, 가득 찬 핸들 레지스트리를 일부러 만들어 실패 규칙을 확인합니다. 이 시험은 같은 폴더의 response_fault_proxy.cpp를 함께 빌드합니다.
3. Windows에서 빌드 설정
섹션 제목: “3. Windows에서 빌드 설정”MFC 응용 프로그램 프로젝트에 다음을 설정합니다.
| 설정 | 값 |
|---|---|
| MFC 사용 | 공유 DLL에서 MFC 사용 |
| 문자 집합 | 유니코드(UNICODE, _UNICODE) |
| C++ 언어 표준 | ISO C++17 이상 |
| 추가 포함 디렉터리 | SDK의 include와 bindings\mfc |
| 추가 종속성 | SDK의 lib\pdcomm_opcua_ffi.dll.lib |
| 실행 파일 옆에 복사 | SDK의 bin\pdcomm_opcua_ffi.dll과 OpenSSL 3 DLL |
CMake를 쓴다면 같은 설정을 다음처럼 표현합니다. PDCOMM_OPCUA_SDK는 SDK를 푼 폴더입니다.
set(CMAKE_MFC_FLAG 2)add_executable(my_opcua_app WIN32 main.cpp)target_compile_features(my_opcua_app PRIVATE cxx_std_17)target_compile_definitions(my_opcua_app PRIVATE UNICODE _UNICODE)target_include_directories(my_opcua_app PRIVATE "${PDCOMM_OPCUA_SDK}/include" "${PDCOMM_OPCUA_SDK}/bindings/mfc")target_link_libraries(my_opcua_app PRIVATE "${PDCOMM_OPCUA_SDK}/lib/pdcomm_opcua_ffi.dll.lib")file(GLOB pdcomm_runtime "${PDCOMM_OPCUA_SDK}/bin/*.dll")add_custom_command(TARGET my_opcua_app POST_BUILD COMMAND "${CMAKE_COMMAND}" -E copy_if_different ${pdcomm_runtime} "$<TARGET_FILE_DIR:my_opcua_app>")“x64 Native Tools Command Prompt for VS 2022”에서 빌드합니다.
cmake -S . -B build -G "Visual Studio 17 2022" -A x64 -DPDCOMM_OPCUA_SDK=C:\PDComm\pdcomm-opcua-native-windows-AMD64cmake --build build --config Release예제 main.cpp를 그대로 빌드해 실행하면 모든 규칙을 확인한 뒤 다음 줄을 출력하고 0으로 끝납니다.
OPC UA MFC consumer passed자주 생기는 오류
섹션 제목: “자주 생기는 오류”| 증상 | 원인과 조치 |
|---|---|
| 이벤트 처리기가 호출되지 않음 | 등록 메시지 처리기에서 Drain()을 부르지 않았습니다. Drain()을 부르기 전까지 이벤트는 작업자에 남습니다. |
Submit이 PDCOMM_OPCUA_CLOSED를 돌려줌 |
작업자가 이미 끝났습니다. 작업자는 다시 시작할 수 없으므로 새 작업자를 만듭니다. |
| 프로그램 시작 시 DLL을 찾지 못함 | pdcomm_opcua_ffi.dll과 OpenSSL DLL이 실행 파일 옆에 없습니다. 빌드 후 복사 단계를 확인합니다. |
다음 단계
섹션 제목: “다음 단계”- 구독
- C++ API 레퍼런스:
MfcClientWorker의 모든 멤버