做 .NET 的人,多半都碰到过这种别扭场面。
业务算法同事甩过来一坨 Python;或者某个开源模型、报表脚本、数据处理流水线,早就在 .py 里跑得好好的。你总不能为了对接,连夜重写成 C# 吧。重写一次,后面每次算法一改,你还得再跟一遍——这活儿不叫开发,更像转译苦力。
于是有人会问:C# 里能不能直接把某个目录下的 Python 文件“引用”进来?用 DotNetPy 的时候,系统是不是自动给 Python 开了个沙盒?
这两个问题,听起来简单,实战里却老踩坑。今天就顺着真实用法往下拆。
不少同学一听“.NET 调用 Python”,脑子里会自动浮出两种画面:
一种是:跟引用 DLL 似的,项目里右键添加,目录一挂,方法直接蹦出来。
另一种是:跟浏览器沙箱似的,Python 再怎么作,也伤不到宿主进程。
抱歉,这两种想象,和 DotNetPy 的真实脾气差得有点远。
DotNetPy 做的事情,本质上更朴素——它把 CPython 嵌进你的 C# 进程,走 Python C API 交互,顺带把 GIL、类型转换这些脏活兜住。你写的不是“程序集引用”,而是“在同一进程里,让 Python 解释器执行代码、导入模块”。
一句话:
它能用目录里的 .py,但靠的是 Python 自己的导入机制;它能管环境,但管的不是安全沙盒。
C# 调 Python,真正磨人的往往不是“能不能跑起来”,而是这三件事:
utils.py、models/、config/ 互相 import,路径稍有偏差,全线 ModuleNotFoundError。我在项目里见过最典型的翻车:
Demo 阶段用字符串执行两行 Python,上线时把整个算法目录塞进来,结果本地跑得通,服务器 import 全挂——因为工作目录变了,相对路径全废。
所以别急着堆 API,先把加载模型和隔离边界想明白。
可以把它想成一座桥,而不是一个保险箱。
C# 进程
└── DotNetPy
└── 嵌入的 CPython
├── 执行代码字符串 / 文件内容
├── 按 sys.path 规则 import 模块
└── 使用当前解释器对应的依赖包几个关键点,建议直接记:
如果你要的是“不可信脚本扔进来也没事”,DotNetPy 不是那把钥匙。
如果你要的是“可信业务脚本、算法模块,低摩擦嵌进 .NET 服务”,它就顺手多了。
这里说的“引用”,请立刻从 C# 项目系统里跳出来。
更准确的说法是:让 Python 找到并加载这些文件。
下面给三条由浅入深、能直接落地的路。
适合脚本独立、没有复杂包结构的场景。比如一段特征清洗、一个临时计算函数。
using DotNetPy;
namespaceAppDotNetPyDemo
{
internalclassProgram
{
staticvoidMain(string[] args)
{
Python.Initialize();
var executor = Python.GetInstance();
var pyFile = Path.GetFullPath(@"python_scripts\pricing.py");
var code = File.ReadAllText(pyFile);
// 执行脚本,将 calc_price 函数定义注入全局命名空间
executor.Execute(code);
// 调用函数并获取结果
var result = executor.Evaluate("calc_price(12, 9.5)");
// 根据 pricing.py 的实际返回类型选择对应方法
Console.WriteLine($"计算结果: {result?.GetDouble()}");
}
}
}
pricing.py 可以简单点:
defcalc_price(qty, base):
# 量越大,折扣越明显
discount = 0.95if qty >= 10else1.0
returnround(qty * base * discount, 2)优点:直观,调试快。
缺点:文件之间一旦互相依赖,这条路很快变堵。
踩坑点:Execute 只是执行文本,不会自动帮你把该文件所在目录塞进 sys.path。旁边模块一 import,容易挂。
这是目录场景里最常用、也最稳的做法。差不多可以把它理解成“运行期手动加引用路径”。
using DotNetPy;
namespaceAppDotNetPyDemo
{
internalclassProgram
{
staticvoidMain(string[] args)
{
Python.Initialize();
var scriptDir = Path.GetFullPath("python_scripts");
// 路径处理务必用原始字符串思维,避免反斜杠被吞
Python.Execute($@"
import sys
from pathlib import Path
root = r'{scriptDir}'
if root not in sys.path:
sys.path.insert(0, root)
");
// 之后就像普通 Python 项目一样用
Python.Execute("import billing");
var result = Python.Evaluate("billing.make_invoice(3, 128.0)");
Console.WriteLine(result);
Console.WriteLine($"计算结果: {result?.GetDouble()}");
}
}
}
对应目录:
python_scripts/
billing.py
utils.pybilling.py:
from utils import with_tax
defmake_invoice(qty, unit_price):
raw = qty * unit_price
return with_tax(raw)utils.py:
defwith_tax(amount, rate=0.06):
returnround(amount * (1 + rate), 2)为什么这个方案香?
因为 Python 开发者本来就生活在 import 体系里。你把目录口径对齐后,算法侧几乎不用为 .NET 改结构,协作成本会小一截。
真实业务里我会固定三件事:
sys.path 只 insert 一次,避免越插越乱test、string 这类易冲突名字runpy.run_path,按文件路径拉命名空间有时你只想跑某个文件,又希望拿到它的函数对象,不想污染太多全局命名。可以试试:
using DotNetPy;
namespaceAppDotNetPyDemo
{
internalclassProgram
{
staticvoidMain(string[] args)
{
Python.Initialize();
var scriptDir = Path.GetFullPath(@"python_scripts").Replace(@"\", @"\\");
Python.Execute($@"
import sys
sys.path.insert(0, r'{scriptDir}')
import runpy
ns = runpy.run_path(r'{scriptDir}\\billing.py')
result = ns['make_invoice'](5, 20)
");
var result = Python.Evaluate("result");
Console.WriteLine($"计算结果: {result?.GetDouble()}");
}
}
}这条路适合“工具脚本入口明确、但不想整理成正规包”的灰区场景。
不过文件内部如果还 import 同目录兄弟模块,你依旧得保证 sys.path 正确。它不是万能钥匙。
短答案:没有,至少不是安全意义上的沙盒。
长一点说,要把“环境隔离”和“安全隔离”掰开。
尤其你用 uv 管 Python 版本和依赖时,体验会接近“项目级虚拟环境”:
这解决的是:
可重复、可部署、少车祸。
因为默认模型是进程内嵌入:
所以可以这么记:
DotNetPy 帮你做的是前者,不是后者。
若脚本来源不可信,优先考虑:
别指望“嵌进去就自动安全”。
下面这段更接近我实际会放进服务里的骨架:初始化一次、路径挂靠一次、业务多次调用。
using DotNetPy;
using System;
using System.Collections.Generic;
using System.Text;
namespaceAppDotNetPyDemo
{
publicsealedclassPyBillingGateway : IDisposable
{
privatebool _ready;
publicvoidInitialize(string scriptRoot)
{
if (_ready) return;
Python.Initialize();
var root = Path.GetFullPath(scriptRoot);
if (!Directory.Exists(root))
thrownew DirectoryNotFoundException($"Python目录不存在: {root}");
Python.Execute($@"
import sys
root = r'{root}'
if root not in sys.path:
sys.path.insert(0, root)
import billing
");
_ready = true;
}
publicdoubleMakeInvoice(int qty, decimal unitPrice)
{
if (!_ready) thrownew InvalidOperationException("先初始化");
// 简单场景可用 Evaluate;复杂对象建议做一层明确封装
var result = Python.Evaluate($"billing.make_invoice({qty}, {unitPrice})");
return result?.GetDouble() ?? 0;
}
publicvoidDispose()
{
// 按你使用的 DotNetPy 版本补充释放逻辑
// 重点是:生命周期要和宿主服务对齐,别来回反复初始化
}
}
}调用侧:
usingvar py = new PyBillingGateway();
py.Initialize("./python_scripts");
var fee = py.MakeInvoice(qty: 4, unitPrice: 99.0m);
Console.WriteLine($"发票金额: {fee}");这个封装看起来土,但管用。
它把“Python 细节”关在门后,业务代码只看到清晰的 C# 方法。后面你要换加载策略、加日志、加超时,也有地方下手。
Python.Initialize()、挂 sys.path、import 大模块,都应该在启动阶段做完。
请求进来再初始化,延迟会被用户一眼看见。
能 import 一次再调函数,就别每次把整文件读进来执行。
前者像把工具箱放桌上;后者像每用一次螺丝刀都重开一遍仓库门。
多线程 C# 服务里,Python 段不一定按你想的那样并行。
CPU 密集算法,要么接受限制,要么把重活丢到独立进程池。
sys.path这三者不是一回事。很多人混着排障,会越查越晕。
能让 Python 吃基础类型、吐基础类型,就别一上来跨一堆复杂对象图。
边界越瘦,两端越不容易在类型转换上打架。
误区 1:把脚本目录当项目引用
C# 的引用是编译期概念;Python 模块是运行期查找。两者别硬套。
误区 2:以为读了文件就自动解决依赖ReadAllText + Execute 不等于 pip install,也不等于包导入完成。
误区 3:把虚拟环境当成沙箱
venv/uv 防的是依赖污染,不是恶意行为。
误区 4:开发机绝对路径写死进生产
今天能跑,明天换磁盘布局就全灭。路径收敛到配置,启动时解析。
误区 5:错误信息只看 C# 异常表层
很多问题要连着 Python traceback 一起看。桥两端的日志都要留。
比较适合:
谨慎或换方案:
技术选型不是追新,是匹配约束。
DotNetPy 解决的是“嵌得顺”,不是“防得死”。
AppHost/
Program.cs
python_scripts/
billing.py
utils.py
requirements.txt原则:
够用了。很多系统毁在“两端都太聪明”,不毁在“能力不够”。
sys.path + import,不是项目引用魔法。回到开头那两个问题:
DotNetPy 能不能直接引用一个目录下的 py 文件?
能使用,方式是把目录纳入 Python 模块搜索路径,再正常 import;或读取文件执行。它不是 C# 那种“添加引用”体验。
在 C# 中调用时,是不是给对应 Python 创建了沙盒环境?
如果你指依赖与解释器环境,可以管得比较干净;
如果你指安全沙箱,默认不是。它是进程内协作,不是囚笼。
我自己的建议很现实:
sys.path 方案做主路径C# 擅长把系统搭结实,Python 擅长把表达式写痛快。两者接在一起时,别追求“全自动神奇”,先把路径、生命周期、边界这三块砖铺平。砖铺平了,后面反而少加班。
相关关键词也能顺着挖:pythonnet 对比、GIL 与线程池、uv 在 CI 里的锁定策略、进程外 Python Worker。
核心收获就三句:
按模块导入,不要幻想项目引用;按环境管理,不要误认安全沙盒;按生命周期设计,不要每次请求现场搭台。
学习路线上,你可以先熟悉 CPython 嵌入和模块导入规则,再补 .NET 侧宿主生命周期与并发模型,最后才是部署链路里的依赖锁定。循这个顺序,少走弯路。
觉得有用的话,欢迎微信打赏鼓励一下,让我有动力继续输出这类实战内容。完整源码结构已在各节逐一呈现,可结合项目实际直接落地;如需完整源码,可在公众号聊天窗口获取。
标签:C#开发DotNetPyPython互操作编程技巧.NET实战