Skip to content

Instantly share code, notes, and snippets.

@engalar
Last active September 25, 2025 07:44
Show Gist options
  • Select an option

  • Save engalar/ba1128a43779bd5068c3313dbc0abc97 to your computer and use it in GitHub Desktop.

Select an option

Save engalar/ba1128a43779bd5068c3313dbc0abc97 to your computer and use it in GitHub Desktop.
studio pro plugin for tutorial soc dip ioc principle

架构知识库:Mendix 前后端 RPC 控制面板

1. 总体架构概述 (High-Level Architecture)

本系统是一个基于Mendix Studio Pro扩展API构建的Web应用,包含一个前端UI(index.html)和一个后端脚本(main.py)。

  • 核心模式: 客户端-服务器 (Client-Server)
  • 通信协议: 基于 window.postMessage异步RPC(远程过程调用)
  • 核心设计原则:
    1. 关注点分离 (SoC): 前后端、UI、业务逻辑、通信等模块各自独立,职责单一。
    2. 依赖倒置 (DIP): 组件依赖于稳定的抽象(接口/契约),而非不稳定的具体实现。
    3. 控制反转 (IoC): 通过依赖注入(前端redi)和调度器(后端AppController)来管理和解耦组件。

2. 集成契约:RPC 通信协议 (The Contract)

这是前后端之间必须遵守的“法律”,是整个系统中最稳定的部分。

2.1 请求 (Request)

从前端发送到后端的每个消息都必须是一个包含以下字段的JSON对象:

字段名 类型 描述 示例
type string 必须。命令的类型,用于后端路由。 "OPEN_EDITOR"
payload object 必须。与命令相关的数据。 {"moduleName": "Admin"}
correlationId string 必须。请求的唯一ID,用于匹配响应。 "req-123"
timestamp string 可选。ISO 8601格式的客户端时间戳。 "2023-10-27T10:00:00Z"

2.2 响应 (Response)

从后端返回到前端的每个消息都必须是一个包含以下字段的JSON对象:

字段名 类型 描述 示例
status "success"/"error" 必须。表示操作是否成功。 "success"
data any 可选。statussuccess时返回的数据。 {"opened": true}
message string 可选。statuserror时的错误信息。 "Module not found."
correlationId string 必须。必须与触发此响应的请求ID完全相同。 "req-123"

2.3 已定义的命令 (Defined Commands)

type payload 结构 成功时 data 结构 描述
ECHO { "content": any } { "echo_response": { "content": any } } 后端将收到的 payload 原样返回,用于测试连通性。
OPEN_EDITOR { "moduleName": string, "entityName": string} { "opened": boolean, ... } 请求后端在Mendix Studio Pro中打开指定实体的编辑器。

3. 前端知识库 (Frontend Knowledge Base)

关注点 1: RPC 通信 (RPC Communication)

  • 契约/接口 (DIP): IMessageService

    • 职责: 定义一个标准的、与实现无关的后端通信接口。系统中任何需要与后端交互的组件都必须依赖此接口。
    • API:
      • call(type: string, payload: object): Promise<any>: 发起一个RPC调用,返回一个Promise,该Promise会用后端的data来解决,或在后端返回错误时被拒绝。
  • 实现: BrowserMessageService

    • 职责: IMessageService接口的具体实现。它封装了所有window.postMessage的复杂性。
    • 内部机制:
      1. 生成唯一的correlationId
      2. 维护一个pendingRequests的Map,用于存储correlationId和其对应的Promise resolve/reject函数。
      3. 监听message事件,当收到响应时,使用correlationId查找并处理对应的Promise。
      4. 管理请求超时。

关注点 2: 视图组合与布局 (View Composition & Layout)

  • 契约/接口 (DIP): IView

    • 职责: 这是一个“标记接口”或“标识符”。任何希望被主应用动态渲染为独立UI块的React组件,都应注册为IView的提供者。
  • 管理器: ViewManagementService

    • 职责: 通过依赖注入收集所有注册为IView的组件。
    • 协作: App根组件依赖此服务来获取所有视图,并按注册顺序将它们渲染出来。这使得添加或移除UI模块无需修改App组件本身。

关注点 3: 用户界面视图 (UI Views)

每个视图都是一个独立的React组件,具有单一职责。

  • InputView:

    • 职责: 提供一个输入框和一个按钮,用于发送ECHO命令。
    • 依赖: IMessageService
  • EditorControlView:

    • 职责: 提供一个按钮,用于发送OPEN_EDITOR命令。
    • 依赖: IMessageService
  • MessageLogView:

    • 职责: 显示所有RPC请求和响应的日志,用于调试和监控。
    • 依赖: MessageStore

关注点 4: 状态管理 (State Management)

  • 管理器: MessageStore
    • 职责: 作为RPC日志的唯一真实来源。它存储所有请求、响应和错误的记录。
    • 模式: 发布/订阅(Observer)。MessageLogView订阅其变化以重新渲染。

关注点 5: 依赖注入配置 (Dependency Injection Setup)

  • 配置器: AppWithDependencies
    • 职责: 这是IoC容器的配置中心。它负责将抽象(如IMessageService)绑定到具体的实现(如BrowserMessageService),并注册所有的服务和视图。

4. 后端知识库 (Backend Knowledge Base)

关注点 1: 请求入口与调度 (Request Entry & Dispatching)

  • 入口点: onMessage(e) 函数

    • 职责: 作为Mendix环境消息的唯一入口。它的职责仅限于:
      1. 验证消息来源 (e.Message == "frontend:message")。
      2. 反序列化消息数据。
      3. 将解析后的请求对象传递给AppController进行处理。
      4. AppController返回的响应序列化并发回前端。
  • 调度器: AppController

    • 职责: 系统的核心路由器,实现了控制反转(IoC)。
    • 内部机制:
      1. 维护一个命令type到具体处理函数(handler)的映射。
      2. dispatch方法根据请求的type字段,调用相应的handler。
      3. 它不包含任何具体的业务逻辑,只负责委托。
      4. 统一封装成功和失败的响应结构,并确保correlationId被回传。

关注点 2: 业务逻辑 (Business Logic)

这是具体命令的实现,每个handler都是一个独立的逻辑单元。

  • handle_echo(payload):

    • 职责: 实现ECHO命令的逻辑。
  • handle_open_editor(payload):

    • 职责: 实现OPEN_EDITOR命令的逻辑。
    • 外部依赖: currentApp (用于访问Mendix模型) 和 dockingWindowService (用于执行打开编辑器的操作)。

关注点 3: Mendix环境交互 (Mendix Environment Interaction)

  • 全局变量: 后端脚本在一个特殊的Mendix环境中运行,并可以访问以下全局变量:
    • currentApp: 代表当前Mendix项目的模型API入口。
    • dockingWindowService: Mendix Studio Pro提供的服务,用于控制UI面板和编辑器。
    • PostMessage(type, data): 向前端发送消息的全局函数。
    • 职责: 这些是后端与宿主环境(Mendix Studio Pro)交互的唯一手段。任何与Mendix模型或UI相关的操作都必须通过它们进行。

service

extensionFileService logService microflowActivitiesService microflowExpressionService microflowService untypedModelAccessService dockingWindowService domainModelService backgroundJobService configurationService extensionFeaturesService httpClientService nameValidationService navigationManagerService pageGenerationService appService https://github.com/mendix/ExtensionAPI-Samples/blob/main/API%20Reference/Mendix.StudioPro.ExtensionsAPI.UI.Services/IAppService.md dialogService entityService findResultsPaneService localRunConfigurationsService notificationPopupService runtimeService selectorDialogService versionControlService messageBoxService

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="assets/tailwindcss.js"></script>
</head>
<body class="bg-gray-100 font-sans">
<div id="app"></div>
<script src="assets/vendor-bundle.umd.js"></script>
<script src="assets/babel.min.js"></script>
<script type="text/babel">
const { React, ReactDOM, redi, rediReact } = globalThis.__tmp;
delete globalThis.__tmp;
// keep unused import for redi and rediReact
const { Inject, Injector, LookUp, Many, Optional, Quantity, RediError, Self, SkipSelf, WithNew, createIdentifier, forwardRef, isAsyncDependencyItem, isAsyncHook, isClassDependencyItem, isCtor, isDisposable, isFactoryDependencyItem, isValueDependencyItem, setDependencies } = redi;
const { RediConsumer, RediContext, RediProvider, WithDependency, connectDependencies, connectInjector, useDependency, useInjector, useObservable, useUpdateBinder } = rediReact;
const { useState, useEffect, useReducer, Fragment, useCallback } = React;
// ===================================================================
// 1. STATE & COMMUNICATION SERVICES
// ===================================================================
// 1.1. STATE MANAGEMENT (Now logs RPC calls)
class MessageStore {
constructor() { this.log = []; this.listeners = new Set(); }
addLogEntry(entry) { this.log = [entry, ...this.log]; this.notify(); }
subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
notify() { this.listeners.forEach(listener => listener()); }
}
// 1.2. COMMUNICATION SERVICE (REFACTORED for RPC)
const IMessageService = createIdentifier('IMessageService');
class BrowserMessageService {
constructor(messageStore) {
this.messageStore = messageStore;
this.requestId = 0;
this.pendingRequests = new Map();
this.initializeListener();
this.RPC_TIMEOUT = 10000; // 10 seconds
}
// The core RPC 'call' method
async call(type, payload) {
const correlationId = `req-${this.requestId++}`;
const command = { type, payload, correlationId, timestamp: new Date().toISOString() };
this.messageStore.addLogEntry({ type: 'request', correlationId, command });
return new Promise((resolve, reject) => {
// Set a timeout for the request
const timeoutId = setTimeout(() => {
if (this.pendingRequests.has(correlationId)) {
this.pendingRequests.delete(correlationId);
const error = new Error(`RPC call timed out for type '${type}'`);
this.messageStore.addLogEntry({ type: 'timeout', correlationId, error: error.message });
reject(error);
}
}, this.RPC_TIMEOUT);
// Store the promise handlers
this.pendingRequests.set(correlationId, { resolve, reject, timeoutId });
// Send the command to the backend
window.parent.sendMessage("frontend:message", command);
});
}
initializeListener() { window.addEventListener('message', this.handleBackendResponse); }
handleBackendResponse = (event) => {
if (!(event.data && event.data.type === 'backendResponse')) return;
try {
const response = JSON.parse(event.data.data);
const { correlationId } = response;
if (!correlationId || !this.pendingRequests.has(correlationId)) {
console.warn("Received response for an unknown or timed-out request:", response);
return;
}
const { resolve, reject, timeoutId } = this.pendingRequests.get(correlationId);
clearTimeout(timeoutId); // Clear the timeout
this.pendingRequests.delete(correlationId); // Clean up
this.messageStore.addLogEntry({ type: 'response', correlationId, response });
if (response.status === 'success') {
resolve(response.data);
} else {
reject(new Error(response.message || 'An unknown backend error occurred.'));
}
} catch (e) {
console.error("Fatal error parsing backend response:", e, event.data.data);
this.messageStore.addLogEntry({ type: 'error', error: "Frontend failed to parse backend response", raw: event.data.data });
}
};
dispose() { window.removeEventListener('message', this.handleBackendResponse); }
}
setDependencies(BrowserMessageService, [MessageStore]);
// ===================================================================
// 2. VIEW ABSTRACTION AND MANAGEMENT (Unchanged)
// ===================================================================
const IView = createIdentifier('IView');
class ViewManagementService {
constructor(views) { this.views = views; }
getViews() { return this.views; }
}
setDependencies(ViewManagementService, [[new Many, IView]]);
// ===================================================================
// 3. UI COMPONENTS (ADAPTED for Async/Await and RPC)
// ===================================================================
// Reusable hook for handling RPC calls in components
const useRpc = () => {
const messageService = useDependency(IMessageService);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const execute = useCallback(async (type, payload) => {
setIsLoading(true);
setError(null);
setData(null);
try {
const result = await messageService.call(type, payload);
setData(result);
return result;
} catch (err) {
setError(err.message);
// allow component to handle the error too
throw err;
} finally {
setIsLoading(false);
}
}, [messageService]);
return { execute, isLoading, error, data };
};
const InputView = () => {
const [inputValue, setInputValue] = useState('Hello RPC!');
const { execute, isLoading } = useRpc();
const handleSend = async () => {
if (inputValue.trim() === '' || isLoading) return;
try {
await execute('ECHO', { content: inputValue });
setInputValue(''); // Clear on success
} catch (e) { console.error("Echo failed:", e); }
};
return (
<div className="p-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-700 mb-2">Echo Tool (RPC)</h2>
<div className="flex">
<input type="text" className="flex-grow p-3 border border-gray-300 rounded-l-md focus:ring-blue-500 focus:border-blue-500" value={inputValue} onChange={(e) => setInputValue(e.target.value)} disabled={isLoading} />
<button onClick={handleSend} className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-r-md hover:bg-blue-700 transition-colors disabled:bg-gray-400" disabled={isLoading}>
{isLoading ? 'Echoing...' : 'Send Echo'}
</button>
</div>
</div>
);
};
const EditorControlView = () => {
const { execute, isLoading, error } = useRpc();
const handleOpenEditor = async () => {
try {
await execute('OPEN_EDITOR', { moduleName: 'Administration', entityName: 'Account' });
} catch (e) { console.error("Open Editor failed:", e); }
};
return (
<div className="p-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-700 mb-2">Editor Control (RPC)</h2>
<p className="text-sm text-gray-600 mb-3">Click to send an RPC call to open the 'Administration.Account' entity editor.</p>
<button onClick={handleOpenEditor} className="w-full px-6 py-3 bg-green-600 ... disabled:bg-gray-400" disabled={isLoading}>
{isLoading ? 'Requesting...' : 'Trigger TryOpenEditor'}
</button>
{error && <p className="text-red-600 mt-2 text-sm">{error}</p>}
</div>
);
};
const MessageLogView = () => {
const messageStore = useDependency(MessageStore);
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
const unsubscribe = messageStore.subscribe(forceUpdate);
return () => unsubscribe();
}, [messageStore]);
return (
<div className="p-4">
<h2 className="text-xl font-semibold text-gray-700 mb-3">RPC Call Log:</h2>
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 h-96 overflow-y-auto font-mono text-xs">
{messageStore.log.length === 0 ? <p>No calls made yet.</p> :
messageStore.log.map((entry, index) => (
<div key={index} className="mb-2 p-2 bg-white rounded shadow-sm border-l-4 border-gray-300">
<pre className="whitespace-pre-wrap">{JSON.stringify(entry, null, 2)}</pre>
</div>
))}
</div>
</div>
);
};
// ===================================================================
// 4. MAIN APP & IOC CONFIGURATION
// ===================================================================
const App = () => {
const viewManager = useDependency(ViewManagementService);
return (
<div className="p-4 max-w-3xl mx-auto bg-white shadow-lg rounded-lg mt-8">
<h1 className="text-3xl font-bold text-gray-800 mb-6 text-center">Mendix Backend Control Panel (RPC)</h1>
{viewManager.getViews().map((ViewComponent, index) => <Fragment key={index}><ViewComponent /></Fragment>)}
</div>
);
};
const AppWithDependencies = connectDependencies(App, [
[MessageStore],
[ViewManagementService],
[IMessageService, { useClass: BrowserMessageService }],
[IView, { useValue: InputView }],
[IView, { useValue: EditorControlView }],
[IView, { useValue: MessageLogView }], // Renamed from MessageListView
]);
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<AppWithDependencies />);
</script>
</body>
</html>
from Mendix.StudioPro.ExtensionsAPI.Model.DomainModels import IDomainModel, IEntity
from Mendix.StudioPro.ExtensionsAPI.Model.Projects import IModule
import clr
from System.Text.Json import JsonSerializer
import json
import traceback
from typing import Any, Dict, Callable
from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide
# pythonnet库嵌入C#代码
clr.AddReference("System.Text.Json")
clr.AddReference("Mendix.StudioPro.ExtensionsAPI")
# 运行时环境提供的工具
PostMessage("backend:clear", '') # 清理IDE控制台日志
ShowDevTools() # 打开前端开发者工具
# 运行时环境提供的上下文变量如下
# currentApp:mendix model
# root:untyped model
# dockingWindowService
# region Utilities (Unchanged)
def serialize_json_object(json_object: Any) -> str:
import System.Text.Json
return System.Text.Json.JsonSerializer.Serialize(json_object)
def deserialize_json_string(json_string: str) -> Any:
return json.loads(json_string)
class TransactionManager:
"""with TransactionManager(currentApp, f"your transaction name"):"""
def __init__(self, app, transaction_name):
self.app = app
self.name = transaction_name
self.transaction = None
def __enter__(self):
self.transaction = self.app.StartTransaction(self.name)
return self.transaction
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.transaction.Commit()
else:
self.transaction.Rollback()
self.transaction.Dispose()
return False
# endregion
# ===================================================================
# 1. ABSTRACTIONS AND SERVICES (THE NEW ARCHITECTURE)
# ===================================================================
class MendixEnvironmentService:
"""
A service that abstracts away the Mendix host environment's global variables.
Any part of the application needing access to `currentApp`, `dockingWindowService`,
or `PostMessage` should depend on this service, not the globals themselves.
This adheres to the Dependency Inversion Principle (DIP).
"""
def __init__(self, app_context, window_service, post_message_func: Callable):
self.app = app_context
self.window_service = window_service
self.post_message = post_message_func
class EchoService:
"""Handles the business logic for the 'ECHO' command."""
def __init__(self, mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
def echo(self, payload: Dict) -> Dict:
self._mendix_env.post_message(
"backend:info", f"Received ECHO command with payload: {payload}")
return {"echo_response": payload}
class EditorService:
"""Handles the business logic for the 'OPEN_EDITOR' command."""
def __init__(self, mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
def open_editor(self, payload: Dict) -> Dict:
module_name = payload.get("moduleName")
entity_name = payload.get("entityName")
if not module_name or not entity_name:
raise ValueError(
"Payload must contain 'moduleName' and 'entityName'.")
self._mendix_env.post_message(
"backend:info", f"Attempting to open editor for {module_name}.{entity_name}")
target_module = next(
(m for m in self._mendix_env.app.Root.GetModules() if m.Name == module_name), None)
if not target_module:
raise FileNotFoundError(f"Module '{module_name}' not found.")
target_entity = next(
(e for e in target_module.DomainModel.GetEntities() if e.Name == entity_name), None)
if not target_entity:
raise FileNotFoundError(
f"Entity '{entity_name}' not found in module '{module_name}'.")
was_opened = self._mendix_env.window_service.TryOpenEditor(
target_module.DomainModel, target_entity)
return {"moduleName": module_name, "entityName": entity_name, "opened": was_opened}
class AppController:
"""
Handles routing of commands from the frontend to specific business logic services.
It depends on abstract services, not concrete implementations of logic.
"""
def __init__(self, echo_service: EchoService, editor_service: EditorService, mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
self._command_handlers: Dict[str, Callable[[Dict], Any]] = {
"ECHO": echo_service.echo,
"OPEN_EDITOR": editor_service.open_editor,
}
def dispatch(self, request: Dict) -> Dict:
"""Dispatches a request and ensures the response includes the correlationId."""
command_type = request.get("type")
payload = request.get("payload", {})
correlation_id = request.get("correlationId")
handler = self._command_handlers.get(command_type)
if not handler:
return self._create_error_response(f"No handler found for command type: {command_type}", correlation_id)
try:
result = handler(payload)
return self._create_success_response(result, correlation_id)
except Exception as e:
error_message = f"Error executing command '{command_type}': {e}"
self._mendix_env.post_message(
"backend:info", f"{error_message}\n{traceback.format_exc()}")
return self._create_error_response(error_message, correlation_id, {"traceback": traceback.format_exc()})
def _create_success_response(self, data: Any, correlation_id: str) -> Dict:
return {"status": "success", "data": data, "correlationId": correlation_id}
def _create_error_response(self, message: str, correlation_id: str, data: Any = None) -> Dict:
return {"status": "error", "message": message, "data": data or {}, "correlationId": correlation_id}
# ===================================================================
# 2. IOC CONTAINER CONFIGURATION
# ===================================================================
class Container(containers.DeclarativeContainer):
"""
The Inversion of Control (IoC) container for the backend application.
It defines how to create and wire all the services together.
"""
# Configuration provider for injecting external values like the Mendix globals
config = providers.Configuration()
# Provides a singleton instance of the MendixEnvironmentService.
# It's configured with the actual global variables provided by the host.
mendix_env = providers.Singleton(
MendixEnvironmentService,
app_context=config.app_context,
window_service=config.window_service,
post_message_func=config.post_message_func,
)
# Provides business logic services, injecting the environment service.
echo_service = providers.Singleton(EchoService, mendix_env=mendix_env)
editor_service = providers.Singleton(EditorService, mendix_env=mendix_env)
# Provides the main AppController, injecting all necessary business logic services.
app_controller = providers.Singleton(
AppController,
echo_service=echo_service,
editor_service=editor_service,
mendix_env=mendix_env,
)
# ===================================================================
# 3. APPLICATION ENTRYPOINT
# ===================================================================
# Create the container instance
container = Container()
# **IMPORTANT**: Inject the actual Mendix global variables into the container's configuration.
# This is the one and only place where globals are accessed directly.
container.config.from_dict({
"app_context": currentApp,
"window_service": dockingWindowService,
"post_message_func": PostMessage,
})
def onMessage(
e: Any
):
"""
Entry point for all messages. Now a thin layer that delegates to the controller.
"""
controller = container.app_controller()
if e.Message != "frontend:message":
return
try:
request_string = JsonSerializer.Serialize(e.Data)
request_object = json.loads(request_string)
if "correlationId" not in request_object:
PostMessage(
"backend:info", f"Received message without correlationId: {request_object}")
return
response = controller.dispatch(request_object)
PostMessage("backend:response", json.dumps(response))
except Exception as ex:
PostMessage(
"backend:info", f"Fatal error in onMessage: {ex}\n{traceback.format_exc()}")
correlation_id = "unknown"
try:
request_string = JsonSerializer.Serialize(e.Data)
request_object = json.loads(request_string)
correlation_id = request_object.get("correlationId", "unknown")
except:
pass
# Use the controller to create a consistently formatted error response
error_response = controller._create_error_response(
f"A fatal error occurred in the Python backend: {ex}",
correlation_id,
{"traceback": traceback.format_exc()}
)
PostMessage("backend:response", json.dumps(error_response))
{
"name": "tutorial-soc",
"author": null,
"email": null,
"ui": "index.html",
"plugin": "main.py",
"home": "https://gist.github.com/engalar/ba1128a43779bd5068c3313dbc0abc97",
"deps": [
"pythonnet",
"dependency-injector"
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment