콘텐츠로 이동
무료 평가 시작

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 패키지에만 들어 있습니다.
UI thread (receiver window) Worker thread (CWinThread)
--------------------------- --------------------------
Submit(request, correlation) --- queue ---> begin / poll / take
RequestStop(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입니다.

examples/opcua-mfc/main.cpp는 작업자의 규칙을 하나씩 확인하는 MFC 소비자 프로그램입니다. 같은 프로세스에서 SDK 메모리 서버를 None 보안으로 실행하고, 메시지 전용 창(message-only window)을 수신 창으로 씁니다.

examples/opcua-mfc/main.cpp
#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;
}
}

응용 프로그램에 옮길 부분은 다음과 같습니다.

  1. 수신 창과 메시지. RegisterWindowMessageW로 메시지 번호를 받고, 이벤트를 받을 창의 HWND와 함께 작업자 생성자에 넘깁니다. 대화 상자나 주 창을 그대로 써도 됩니다.
  2. 요청 제출. Submit(request, correlation)은 UA Binary 요청과 0이 아닌 상관 번호를 받습니다. 빈 요청과 0 상관 번호는 PDCOMM_OPCUA_INVALID_INPUT, 같은 상관 번호의 중복은 PDCOMM_OPCUA_BUSY, 4 MiB를 넘는 요청은 PDCOMM_OPCUA_LIMIT입니다.
  3. 메시지 처리. 예제의 Pump처럼 등록 메시지를 받으면 Drain()을 부르고 이벤트를 처리합니다. 일반 MFC 응용 프로그램에서는 ON_REGISTERED_MESSAGE 처리기에서 Drain()을 부릅니다.
  4. 종료. RequestStop(MfcClientStop::Drain)은 받은 요청을 모두 끝내고 세션을 닫습니다. Stopped 이벤트를 받은 뒤 Wait으로 스레드를 기다리고 작업자를 해제합니다.
  5. 응답 수명. Completed의 value는 작업자가 사라진 뒤에도 유효합니다. 마지막 참조가 사라질 때 네이티브 핸들이 해제됩니다.

예제의 나머지 부분은 응답 중단, 잘못된 응답, 가득 찬 핸들 레지스트리를 일부러 만들어 실패 규칙을 확인합니다. 이 시험은 같은 폴더의 response_fault_proxy.cpp를 함께 빌드합니다.

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-AMD64
cmake --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이 실행 파일 옆에 없습니다. 빌드 후 복사 단계를 확인합니다.