PowerShell Core使用C#实现的多线程Exchange Online。

5
我正在编写一个 C# 应用程序,需要使用 PowerShell V2 模块 从 Exchange Online 收集数据。客户在进行管理员同意后,我将使用运行在 Windows 虚拟机上的多线程 C# 应用程序通过 PowerShell 连接到他们的环境。我使用的是 Net 5.0 和 PowerShell 7.x。由于从单个租户收集数据可能需要很长时间,所以我需要使用多个线程。
问题在于,尽管应用程序运行良好,但如果我尝试同时使用多个线程为两个租户运行应用程序,则会发生冲突。该模块似乎不支持多线程。
我已经创建了一个服务,并通过 .Net DI 注入为短暂对象。该服务创建 HostedRunspace 类,用于执行 PowerShell 的状态管理。
public class HostedRunspace : IDisposable
{
    private Runspace runspace;
 
    public void Initialize(string[] modulesToLoad = null)
    {

        InitialSessionState defaultSessionState = InitialSessionState.CreateDefault();
        defaultSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.RemoteSigned;
        
        if (modulesToLoad != null)
        {
            foreach (string moduleName in modulesToLoad)
            {
                defaultSessionState.ImportPSModule(moduleName);
            }
        }

        runspace = RunspaceFactory.CreateRunspace(defaultSessionState);
        
        runspace.ThreadOptions = PSThreadOptions.UseNewThread;
        runspace.ApartmentState = ApartmentState.STA;

        runspace.Open();
    }

    public async Task<List<PSObject>> RunScript(string scriptContents, Dictionary<string, object> scriptParameters = null)
    {
        if (runspace == null)
        {
            throw new ApplicationException("Runspace must be initialized before calling RunScript().");
        }

        PSDataCollection<PSObject> pipelineObjects;
        
        using (System.Management.Automation.PowerShell ps = System.Management.Automation.PowerShell.Create(runspace))
        {
            ps.AddScript(scriptContents);

            if (scriptParameters != null)
            {
                ps.AddParameters(scriptParameters);
            }

            ps.Streams.Error.DataAdded += Error_DataAdded;
            ps.Streams.Warning.DataAdded += Warning_DataAdded;
            ps.Streams.Information.DataAdded += Information_DataAdded;

            // execute the script and await the result.
            pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false);
            
            // print the resulting pipeline objects to the console.
            Console.WriteLine("----- Pipeline Output below this point -----");
            foreach (PSObject item in pipelineObjects)
            {
                Console.WriteLine(item.BaseObject.ToString());
            }
        }

        List<PSObject> psObjects = new List<PSObject>();
        foreach (PSObject pipelineObject in pipelineObjects)
        {
            psObjects.Add(pipelineObject);
        }

        return psObjects;
    }

当收集租户的PowerShell数据时,会创建一个新线程,如下所示:

IOnlineDataTaskRunner taskRunner = serviceProvider.GetRequiredService<IOnlineDataTaskRunner>();
Thread thread = new Thread(() => taskRunner.RunAsync(dataTask));
thread.Start();

这里我获取了我的PowerShell服务的短暂版本,它本身将新建一个HostedRunspace。 我创建了一个新线程,提供了一些配置并启动该线程。

当线程运行时,我首先必须使用证书连接到Exchange Online。

string command = $"Connect-ExchangeOnline -CertificateThumbprint \"{Thumbprint}\" -AppId \"{ClientId}\" -ShowBanner:$false -Organization {tenant}"; 
await runspace.RunScript(command);

接着,我使用PowerShell模块执行各种其他数据检索任务,包括获取邮箱信息、存储大小等。这些任务也是通过执行的。

await runspace.RunScript(command);

如上所述,如果我一次只运行一个线程,就不会出现问题。但是,如果将线程1连接到租户A,将线程2连接到租户B,则最初的Connect-ExchangeOnline将不会出现任何问题。

但是,如果检索邮箱信息,例如,两个线程都将为最后连接的任何租户获取数据。这表明该模块或我的实现可能存在线程问题。


1
你为什么要从C#运行PowerShell脚本呢?为什么不直接在C#中实现PowerShell,这样就不会有这个问题了呢? - Neil
@Neil,我不确定我理解你的问题。你所说的“在C#中实现PowerShell”是什么意思?我已经引用了Microsoft.PowerShell.SDK nuget,并使用它来运行PowerShell Core,我相信这是在.net core应用程序中运行PowerShell的方法。除此之外,没有其他Exchange Online API可供我作为替代方案使用。 - ChrisW
有必要使用C#吗?您可以使用Runspace和ThreadJob模块进行多线程处理,而且仅使用PowerShell就不会遇到这个问题。 - Santiago Squarzon
1
我可能错了,但是 PS 库不就是 REST 调用的包装器吗?如果是这样,那么只需从 C# 中进行 REST 调用即可。 - Neil
@Neil,REST调用没有被记录文档。我希望它们有文档,因为我显然不喜欢PowerShell。 - ChrisW
显示剩余2条评论
2个回答

3

我没有第二个租户,但我有一个不同权限的第二个用户帐户,因此我将在我的建议中使用它。我测试了处理多个exo连接的几种方法,以下是有效的方法:

使用Powershell作业通过Start-Job。作业在它们自己的会话中进行,并具有一定程度的隔离:

Start-Job -Name admin01 -ScriptBlock {
    Connect-ExchangeOnline -UserPrincipalName admin01@domain.com
    # Sleep so that second connection happens before this Get-Mailbox
    Start-Sleep -Seconds 10; 
    Get-Mailbox user02@domain.com  ## should succeed as admin
}
Start-Job -Name user01  -ScriptBlock {
    Connect-ExchangeOnline -UserPrincipalName user01@domain.com
    Get-Mailbox user02@domain.com  ## should error due to access as user
}

Receive-Job -Name admin01  ## returns mailbox
Receive-Job -Name user01   ## returns error

看起来 Connect-ExchangeOnline 使用 PSSession 对象来存储您的连接,您可以重新导入它来验证您是否连接到了正确的租户,例如:

Get-PSSession | ft -AutoSize

Id Name                            ComputerName          ComputerType  State  ConfigurationName  Availability
-- ----                            ------------          ------------  -----  -----------------  ------------
 5 ExchangeOnlineInternalSession_1 outlook.office365.com RemoteMachine Opened Microsoft.Exchange    Available
 6 ExchangeOnlineInternalSession_2 outlook.office365.com RemoteMachine Opened Microsoft.Exchange    Available

所以我可以使用Import-PSSession为命令加载特定的连接:

# First, run as admin01
Connect-ExchangeOnline -UserPrincipalName admin01@domain.com
Get-Mailbox user02@domain.com  ## Success

# Then, run as user01
Connect-ExchangeOnline -UserPrincipalName user01@domain.com
Get-Mailbox user02@domain.com  ## Error

# Then, run as admin01 again:
Import-PSSession (Get-PSSession 5) -AllowClobber
Get-Mailbox user02@domain.com  ## Success

最后,运行两个独立的powershell实例也可以。

我对.net不是很熟悉,但我的猜测是你当前要么在启动新线程时重新使用SessionState,要么在线程之间共享你的runspace


非常感谢您的回答。我本来希望得到更多基于C#的解决方案,但是您的回答为我提供了一个框架,可以用来解决我的问题。我会将您的回答标记为“已采纳”。此外,您的回答也非常清晰明了,并且提供了相关的示例。 - ChrisW

0

我不是100%确定,但你可以尝试以下方法:

使用Task.Run并在其中创建对InitialSessionState的引用。

原因(猜测): InitialSessionState.CreateDefault()方法是静态的,但不是线程静态的(source)。这意味着您只创建了一个InitialSessionState并将其共享在所有异步Task实例之间(请记住,异步Task不一定是新的Thread)。这意味着相同的InitialSessionState在任务之间共享。这可能解释了行为。

免责声明:我读到了这篇文章,并且这是我想到的第一件事。我没有以任何方式测试过这个方法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接