65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 IronPython 脚本化 .NET 应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (21投票s)

2013 年 6 月 4 日

CPOL

10分钟阅读

viewsIcon

140125

downloadIcon

3369

通过 IronPython 添加脚本支持来扩展 .NET 应用程序

介绍  

在本文中,我将展示一个示例,说明如何将 IronPython 添加到现有的企业 .NET 软件系统中,以及为什么我认为这样做很有用。  这不是一个新想法,但希望能带来一些新东西。

背景   

来自 Python.org 

"Python 是一种编程语言,可让您更快地工作并更有效地集成您的系统。您可以学习使用 Python,并几乎立即获得生产力提升和降低维护成本。" 

来自 IronPython.net  

"IronPython 是 Python 编程语言的开源实现,它与 .NET Framework 紧密集成。IronPython 可以使用 .NET Framework 和 Python 库,其他 .NET 语言也可以轻松使用 Python 代码。" 

如果您有兴趣了解更多关于 Python / IronPython 的信息,以下是一些 IronPython 资源。

会 Python 吗?   

您不必了解 Python 即可阅读本文。IronPython 能将 Python 和 .NET 非常好地融合在一起。对于 C# 开发人员来说,学习曲线并不算太陡峭。以下是一些 IronPython 的示例,以及对应的 C# 等效代码。  

IronPython  

from System import DateTime, String 
blurb = String.Format("{0} {1}", "Hello World! The current date and time is ", DateTime.Now) 
print blurb    

C#  

using System; 
namespace IronPython.Example
{
    class Program
    {
        static void Main(string[] args)
        {
            string blurb = String.Format("{0} {1}", "Hello World! The current date and time is ", DateTime.Now);
            Console.WriteLine(blurb); 
        }
    }
}     

除了显而易见的语法差异外,这些代码示例之间的关键区别在于它们的执行方式。IronPython 脚本使用 IronPython 解释器执行,而 C# 代码必须先编译,然后通过 .NET 运行时运行。

长期以来,我一直以传统的编写、编译、测试、修复、重新编译、重新测试、重复的方式开发 .NET 应用程序。 脚本(如 Python、Perl 等)一直是我想要学习的东西,但我从未找到(或抽出)时间去做。IronPython 是我通往 Python 脚本的桥梁。

对于那些没有接触过 IronPython 的 .NET 开发人员来说,IronPython 算是一种节奏上的改变。但是,如果您已经编写了多年的 C# 代码,那么节奏上的改变是一件好事。 

为什么使用脚本? 

以下是我认为使用脚本是个好选择的一些原因。

  • 我想让其他开发人员能够使用现有的业务逻辑和数据访问库来查询数据存储,并能像编写 SQL 查询一样方便。
  • 我想要一种方法来快速测试现有 API 的设计更改。编写临时的 C# 代码来测试 API 非常低效,而且最终会产生大量一次性的代码。对我来说,以交互方式编写脚本似乎更具生产力,而且我甚至不需要将查询保存到磁盘。
  • 让其他开发人员编写查询来访问 API 将有助于 API 的设计,因为他们会想到新的查询类型。如果 API 不支持其用户所需的特性,那么可以随着时间的推移添加这些特性。
  • 我想在运行的应用程序中进行一些操作。我想能够访问内部对象,查询它们的属性,甚至可以实时替换新的逻辑。在设计应用程序时,不可能设想到用户使用您构建的应用程序的所有方式。虽然我认为非开发人员不会编写 IronPython 脚本,但我认为其他开发人员可以利用这一点来更快地诊断生产环境中的问题,如果存在这样的接口。
  • 我不想构建一个与 API 交互的用户界面,无论是网页、桌面应用程序还是控制台应用程序。对我来说,使用 UI 来测试 API 的重新设计太过局限。脚本可以避免编写 UI 代码来测试 API。
  • 通过网页添加脚本接口,开发人员就不需要使用 Visual Studio 来编写代码测试 API 了。相反,他们可以在浏览器中输入代码。这对于无法访问开发机器的情况以及用于演示目的很有用。

免责声明

我想提前说明,在生产环境中添加脚本接口可能不合适,尤其是面向公众的 Web 应用程序。因为存在被滥用的巨大风险。但是,在您自己的开发机器或受限的暂存服务器上,这可能是可以接受的。

Python 工具

在使用 IronPython 脚本时,安装 Visual Studio Python Tools 会很方便。我最初是在 Notepad++ 中编写 IronPython 的,但我发现 Visual Studio 中的 IntelliSense 使我更具生产力,并且避免了我需要打开另一个编辑器。

配置 IronPython

首先,您需要从 这里 安装 IronPython(当前版本为 2.7.3)。然后,您需要从 Iron Python 安装文件夹中添加对 Microsoft.Scripting.dllMicrosoft.Dynamic.dllIronPython.dll 的引用。

下面是一个创建 Python 引擎并执行脚本的示例代码片段。

using System;
using System.IO;
using IronPython.Hosting;
using Microsoft.Scripting;
using Microsoft.Scripting.Hosting;

string script = ""; // TODO - get Iron Python script
var engine = Python.CreateEngine();
var scope =  engine.CreateScope();
var source = engine.CreateScriptSourceFromString(script, SourceCodeKind.Statements); 
var compiled = source.Compile();
var result = compiled.Execute(scope);   

应用程序支持多个引擎,但我只是在应用程序启动时初始化一次。

这段代码足以让应用程序支持 Iron Python 脚本。当然,您需要某种方式将脚本加载到 Python 引擎中,这可以通过在应用程序中提供文本输入来实现。本文中的代码提供了实现此目的的 Web 界面和 Winforms 界面。

哦,对了,这在 ASP.NET 应用程序中也同样有效。

IronPython / .NET 集成

我对 IronPython 最感兴趣的功能是在应用程序中操作 .NET 对象的能力。为了快速访问内部对象,我只是将主要对象添加为 ScriptScope 的变量,如下所示:

var scope = engine.CreateScope();
string variableName = "myObject";
object myObject = new Object(); 
scope.SetVariable(name, value);  

通过这种方式添加变量后,就可以直接从 IronPython 访问该变量。假设 "myObject" 已如上所示注册到 ScriptScope,您可以通过执行类似以下操作的代码从 IronPython 脚本中访问它:

def getMyObject():
    return myObject; 
obj = getMyObject()
print obj.ToString() 

对于刚接触 IronPython 的开发人员(例如我),这应该足够入门了。我的想法是注册抽象工厂来创建 Unit of Work / Repositories,以及主要的业务逻辑对象。虽然这些实体可以通过调用其构造函数从 IronPython 脚本中创建,但脚本可能会因为初始化代码而变得混乱,尤其是当您需要连接大量小型类时(这通常发生在您使用 SOLID 方法设计松耦合代码时)。注册应用程序中的主要对象可以帮助减少需要编写的 IronPython 代码行数。

重定向脚本输出

如果您想显示 IronPython 脚本的结果,则必须重定向脚本引擎的输出。有几种方法可以做到这一点。

outputStream = new MemoryStream();
outputStreamWriter = new StreamWriter(outputStream);
engine.Runtime.IO.SetOutput(outputStream, outputStreamWriter); 

另一种方法是将输出重定向到控制台,然后按如下方式重定向控制台:

var textWriter = null; // TODO
engine.Runtime.IO.RedirectToConsole();
Console.SetOut(TextWriter.Synchronized(textWriter)); 

起初,我使用了第二种方法,当时应用程序只支持编辑单个 Iron Python 脚本。在添加了支持多个脚本(每个脚本都有自己的输出窗口)后,我决定改用第一种方法。

过滤数据

在使用 .NET ORM 时,您会像这样编写 Linq 查询:

var query = Customers.Where(c => c.Id > 10); 

这是一个非常简单的查询,但其中有很多内容。下面是一个包装了对 Where 方法调用的类示例。GetCustomers 方法有两个重载,您在快速浏览完代码后将对其进行讨论。

public class CustomerRepository : Repository<Customer>
{ 
    private DbSet<Customer> Customers { get;  set; }
    
    public IQueryable<Customer> All()
    { 
        IQueryable<Customer> customers = from c in Customers select c;
        return customers;
    }
    
    public IQueryable<Customer> GetCustomers(Expression<Func<Customer, bool>> predicate) 
    { 
        IQueryable<Customer> customers = All().Where(predicate); 
        return customers;
    } 
    
    public IEnumerable<Customer> GetCustomers(Func<Customer, bool> predicate)
    {  
        IEnumerable<Customer> customers = All().Where(predicate); 
        return customers;
    }   
}    

假设您在 IronPython 中编写了一个 lambda 表达式来过滤 customers 列表,例如:

repo = CustomerRepository()
customers = repo.GetCustomers(lambda c: c.Id > 10)  

将调用接受 Func<Customer, bool> 作为参数的 GetCustomers 重载。据我所知,IronPython 不支持将 lambda 表达式转换为 Expression Trees。在 IronPython CodePlex 站点 上有一个 未解决的问题,请求支持此功能。

这可能有一些您可能没有立即意识到的影响(至少我没有)。

GetCustomers 的第一个重载接受 Expression Tree 作为参数。

Expression<Func<Customer, bool>> predicate   

LINQ Provider 将表达式树翻译成 SQL 语句,在数据库端进行过滤,然后只检索匹配 Expression Tree 的数据。

GetCustomers 的第二个重载接受一个简单的委托 (Func) 作为参数。

Func<Customer, bool> predicate  

由于它只是一个函数,LINQ Provider 无法生成在数据库端进行过滤的 SQL。相反,需要枚举整个数据集,并使用 Func<Customer, bool> 谓词来仅包含匹配的记录。

这是否是问题取决于您的数据集大小。但是,如果您希望通过 IronPython 进行高效/快速的查询,那么您可能需要在存储库中提供执行常见过滤的方法,以利用数据库端的过滤。

代码

本文背后的代码位于我的个人 GitHub 存储库中。这是一个 VS 2010 / .NET 4.0 解决方案。解决方案中有几个项目:

  1. jterry.scripting.api - 业务逻辑和数据访问库
  2. jterry.scripting.api.script.app - 仅提供对 API 的 IronPyton 脚本访问的 WinForms 应用程序
  3. jterry.scripting.host - 包含 Python 引擎包装器类的类库
  4. jterry.scripting.host.editor - 包含可重用 WinForms 脚本编辑器的类库
  5. jterry.scripting.web - 用于访问 API 的 ASP.NET WebForms 前端
  6. jterry.scripting.winforms - 用于访问 API 的桌面 WinForms 应用程序

ASP.NET 脚本编辑器使用 codemirror 在浏览器中提供 Python 语法高亮。我在 StackOverflow 上发现了它,看起来很有趣。我不会在本文中讨论 codemirror 的用法,但您可以查看代码以了解基本用法。

Data Model

对于本文,我使用了 Chinook SQlite 数据库,通过 System.Data.Sqlite 使用 Entity Framework 进行访问。我按照 Brice 的博客上的步骤进行了设置。

这里的 IUnitOfWork 接口用于隐藏 ChinookContext 中 Entity Framework 的实现细节。

using System.Linq;

namespace jterry.scripting.api
{
    public interface IUnitOfWork
    {
        IQueryable<Customer> GetCustomers();
        IQueryable<Employee> GetEmployees();
        IQueryable<Invoice> GetInvoices();
        IQueryable<InvoiceLine> GetInvoiceLines();
        IQueryable<Track> GetTracks();
        IQueryable<Playlist> GetPlaylists();
    }
}

ChinookContext 实现了 IUnitOfWork 接口,并通过 Entity Framework 提供了对 Chinook SQLite 数据库的访问。

using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Entity;
using System.Data.Entity.ModelConfiguration.Conventions;
using System.Linq;

namespace jterry.scripting.api
{
    public class ChinookContext : DbContext, IUnitOfWork
    {
        public DbSet<Customer> Customers { get; set; }
        public DbSet<Employee> Employees { get; set; }
        public DbSet<Invoice> Invoices { get; set; }
        public DbSet<InvoiceLine> InvoiceLines { get; set; }
        public DbSet<Track> Tracks { get; set; }
        public DbSet<Genre> Genres { get; set; }
        public DbSet<MediaType> MediaTypes { get; set; }
        public DbSet<Album> Albums { get; set; }
        public DbSet<Artist> Artists { get; set; }
        public DbSet<Playlist> Playlists { get; set; }
        public DbSet<PlaylistTrack> PlaylistTracks { get; set; }

        public ChinookContext()
        {
            Database.SetInitializer<ChinookContext>(null);
        }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();

            var customerMap = modelBuilder.Entity<Customer>();
            customerMap.ToTable("Customer");
            customerMap.HasKey(c => c.Id);
            customerMap.Property(c => c.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("CustomerId");
            customerMap.HasOptional(c => c.SupportRep)
                .WithMany().Map(x => x.MapKey("SupportRepId"));

            var employeeMap = modelBuilder.Entity<Employee>();
            employeeMap.ToTable("Employee");
            employeeMap.HasKey(e => e.Id);
            employeeMap.Property(e => e.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("EmployeeId");
            employeeMap.HasOptional(c => c.ReportsTo)
                .WithMany().Map(x => x.MapKey("ReportsTo"));

            var invoiceMap = modelBuilder.Entity<Invoice>();
            invoiceMap.ToTable("Invoice");
            invoiceMap.HasKey(e => e.Id);
            invoiceMap.Property(e => e.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("InvoiceId");
            invoiceMap.HasOptional(i => i.Customer)
                .WithMany().Map(x => x.MapKey("CustomerId"));

            var invoiceLineMap = modelBuilder.Entity<InvoiceLine>();
            invoiceLineMap.ToTable("InvoiceLine");
            invoiceLineMap.HasKey(l => l.Id);
            invoiceLineMap.Property(l => l.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("InvoiceLineId");
            invoiceLineMap.HasRequired(l => l.Invoice)
                .WithMany(i => i.Lines).Map(x => x.MapKey("InvoiceId"));
            invoiceLineMap.HasOptional(l => l.Track)
                .WithMany().Map(x => x.MapKey("TrackId"));

            var trackMap = modelBuilder.Entity<Track>();
            trackMap.ToTable("Track");
            trackMap.HasKey(t => t.Id);
            trackMap.Property(t => t.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("TrackId");
            trackMap.HasOptional(t => t.Genre)
                .WithMany().Map(x => x.MapKey("GenreId"));
            trackMap.HasOptional(t => t.Album)
                .WithMany().Map(x => x.MapKey("AlbumId"));
            trackMap.HasOptional(t => t.MediaType)
                .WithMany().Map(x => x.MapKey("MediaTypeId"));

            var artistMap = modelBuilder.Entity<Artist>();
            artistMap.ToTable("Artist");
            artistMap.HasKey(a => a.Id);
            artistMap.Property(a => a.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("ArtistId");

            var genreMap = modelBuilder.Entity<Genre>();
            genreMap.ToTable("Genre");
            genreMap.HasKey(g => g.Id);
            genreMap.Property(g => g.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("GenreId");

            var albumMap = modelBuilder.Entity<Album>();
            albumMap.ToTable("Album");
            albumMap.HasKey(a => a.Id);
            albumMap.Property(a => a.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("AlbumId");
            albumMap.HasOptional(a => a.Artist)
                .WithMany().Map(x => x.MapKey("ArtistId"));

            var mediaTypeMap = modelBuilder.Entity<MediaType>();
            mediaTypeMap.ToTable("Media");
            mediaTypeMap.HasKey(m => m.Id);
            mediaTypeMap.Property(m => m.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("MediaTypeId");

            var playlistMap = modelBuilder.Entity<Playlist>();
            playlistMap.ToTable("Playlist");
            playlistMap.HasKey(p => p.Id);
            playlistMap.Property(p => p.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("PlaylistId");

            var playlistTrackMap = modelBuilder.Entity<PlaylistTrack>();
            playlistTrackMap.ToTable("PlaylistTrack");
            playlistTrackMap.HasKey(p => p.Id);
            playlistTrackMap.Property(p => p.Id)
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity)
                .HasColumnName("PlaylistTrackId");
            playlistTrackMap.HasOptional(p => p.Playlist)
                .WithMany(p => p.Tracks).Map(x => x.MapKey("PlaylistId"));
            playlistTrackMap.HasOptional(p => p.Track)
                .WithMany().Map(x => x.MapKey("TrackId"));

            base.OnModelCreating(modelBuilder);
        }

        public IQueryable<Customer> GetCustomers()
        {
            var query = from c in Customers select c;
            return query;
        }

        public IQueryable<Employee> GetEmployees()
        {
            var query = from c in Employees select c;
            return query;
        }

        public IQueryable<Invoice> GetInvoices()
        {
            var query = from c in Invoices select c;
            return query;
        }

        public IQueryable<InvoiceLine> GetInvoiceLines()
        {
            var query = from c in InvoiceLines select c;
            return query;
        }

        public IQueryable<Track> GetTracks()
        {
            var query = from c in Tracks select c;
            return query;
        }

        public IQueryable<Playlist> GetPlaylists()
        {
            var query = from c in Playlists select c;
            return query;
        }
    }
} 

示例 IronPython 脚本

下面是一个示例 Iron Python 脚本,说明了如何调用 .NET 代码。您应该阅读 IronPython .NET 文档,以了解 IronPything .NET 集成。

import clr
import System
clr.AddReference("System.Core")
clr.AddReference("System.Windows.Forms")
clr.ImportExtensions(System.Linq)
from System import String

def TestIronPython():
    CountEntities()
    TestCustomers()
    TestInvoices()
    TestPlaylists()
    TestSearchTracks()

def GetAllCustomers():
    return unitOfWork.GetCustomers()

def GetAllEmployees():
    return unitOfWork.GetEmployees()

def GetAllInvoices():
    return unitOfWork.GetInvoices()

def GetAllPlaylists():
    return unitOfWork.GetPlaylists()

def GetAllTracks():
    return unitOfWork.GetTracks()

def CountEntities():
    WriteLine(String.Format("Found {0} customers", GetAllCustomers().Count()))
    WriteLine(String.Format("Found {0} employees", GetAllEmployees().Count()))
    WriteLine(String.Format("Found {0} invoices", GetAllInvoices().Count()))
    WriteLine()

def TestCustomers():
    WriteLine("Testing Customers...")
    customer = GetAllCustomers().FirstOrDefault()
    rep = customer.SupportRep
    reportsTo = rep.ReportsTo
    
    WriteLine(String.Format("Customer: {0} {1}", customer.FirstName, customer.LastName))
    WriteLine(String.Format("Support Rep: {0} {1}", rep.FirstName, rep.LastName))

    if (reportsTo != None):
        WriteLine(String.Format("Reports To: {0} {1}", 
                  reportsTo.FirstName, reportsTo.LastName))
    else:
        WriteLine("Employee has no boss")

    WriteLine("Testing Customers complete");
    WriteLine()

def WriteLine(line = ""):
    print line

def TestPlaylists():
    WriteLine("Testing Playlists...")
    playlist = GetAllPlaylists().FirstOrDefault()
    WriteLine(playlist.Name)
    WriteLine("Testing Playlists complete")
    WriteLine()

def TestInvoices():
    WriteLine("Testing Invoices...")
    invoice = GetAllInvoices().FirstOrDefault()
    WriteLine(String.Format(
      "Invoice Id: {0} Date: {1}", invoice.Id, invoice.InvoiceDate))
    lines = invoice.Lines
    WriteLine(String.Format("Found {0} tracks", lines.Count))
    for l in lines:
        WriteLine(String.Format(
          "Invoice Line Id: {0} Track Id: {1} Track Name: {2} Price: {3}", 
          l.Id, l.Track.Id, l.Track.Name, l.Track.UnitPrice))
    WriteLine("Testing Invoices complete")
    WriteLine()

def TestSearchTracks():
    WriteLine("Testing Search Tracks...");
    SearchTracks("AC/DC")
    WriteLine("Testing Search Tracks Complete");
    WriteLine()

def SearchTracks(composer):
    WriteLine(String.Format("Searching for tracks by '{0}'...", composer))
    tracks = GetAllTracks().Where(lambda t: t.Composer == composer)
    WriteLine(String.Format(
      "Found {0} tracks by '{1}'", tracks.Count(), composer))
    for t in tracks:
        WriteLine(String.Format("Track Id: {0} Name: {1}", t.Id, t.Name))
    WriteLine("Search complete")

TestIronPython()  

选项卡式脚本编辑器

在使用了只有一个 RichTextBox 的简单 Python 编辑器之后,用户体验与完整的代码编辑器完全无法相比。为了使其更易于开发人员使用,我添加了支持同时打开多个选项卡、顶部带有所有操作按钮的工具栏、对代码文件更改的简单检测(以便在文件名旁附加 * 号表示已更改),

我本来希望添加对 Python 语法高亮的支持(类似于 WinForms 的 codemirror),但我找不到快速简单的解决方案。

编辑 - 我找到了我一直在寻找的编辑器。Pavel Torgashov 发布了一篇精彩的文章 "Fast Colored Text Box",我用它替换了 RichTextBox。 只花了一点时间就集成了新控件。 

由于该控件支持代码折叠,我已将编辑器设置为使用 #< 和 #> 作为代码折叠标记。

Web 脚本编辑器

Web 脚本编辑器仍然只支持一次编辑一个脚本。

结论

我们知道软件会发生变化,但通常我们不知道何时、为何或如何变化。我们保持设计的灵活性,但用户界面通常不如代码灵活。提供脚本扩展到您的应用程序可能有助于提供额外的灵活性。

历史

  • 2013 年 6 月 3 日 - 初始发布
  • 2013 年 6 月 4 日 - 添加了 IP / .NET 集成的简要描述、少量代码修复和文章清理
  • 2013 年 6 月 10 日 - 添加了数据过滤的讨论
  • 2013 年 6 月 11 日 - 添加了 Chinook SQLite 数据库
  • 2013 年 6 月 14 日 - 添加了选项卡式脚本编辑器
  • 2013 年 6 月 15 日 - 编辑变更
  • 2013 年 6 月 19 日 - 添加了 Fast Colored Text Box 
  • 2013 年 6 月 20 日 - 添加了 IronPython 入门指南  
  • 2013 年 6 月 24 日 - 添加了 IronPython 的简要背景介绍  
© . All rights reserved.