搜索
首页web前端js教程C#中使用迭代器处理等待任务_基础知识

 介绍

可能你已经阅读 C#5 关于 async 和 await 关键字以及它们如何帮助简化异步编程的,可惜的是在升级VS2010后短短两年时间,任然没有准备好升级到VS2012,在VS2010和C#4中不能使用异步关键字,你可能会想 “如果我能在VS 2010中写看起来同步的方法,但异步执行.我的代码会更清晰.”

看完这篇文章后,您将能够做到这一点。我们将开发一个小的基础结构代码,让我们写"看起来同步的方法,但异步执行"的方法,这个VS2012 异步关键字一样, 享受C#5的特性.

我们必须承认,async 和 await 是非常好的语法糖,我们的方法需要编写更多的"AsyncResultcallback"方法适应这种变化.而当你终于升级到VS2012(或以后),这将是一件微不足道的小事,用C#关键字替换这个方法,只要简单的语法变化,而不是一个艰苦的结构重写。

概要

async/await 是基于异步任务模式的关键字。鉴于 此处已经有了非常完备的文档描述,这里我就不再加以说明。但必须指出的是,TAP简直帅到极点了!通过它你可以创建大量的将在未来某时间完成的小型单元工作(任务);任务可以启动其他的(嵌套)任务 并且/或者 建立一些仅当前置任务完成后才会启动的后续任务。前置与后续任务则可以链接为一对多或是多对一的关系。当内嵌任务完成时,父级任务无需与线程(重量级资源!)相绑定。执行任务时也不必再担心线程的时序安排,只需作出一些小小提示,框架将会自动为你处理这些事情。当程序开始运行,所有的任务将如溪流汇入大海般各自走向终点,又像柏青哥的小铁球一样相互反弹相互作用。

然而在C#4里面我们却没有async和await,不过缺少的也只是这一点点.Net5的新特性而已,这些新特性我们要么可以稍作回避,要么可以自己构建,关键的Task类型还是可用的。


在一个C#5的异步(async)方法里,你要等待一个Task。这不会导致线程等待;而是这个方法返回一个Task给它的调用者,这个Task能够等待(如果它自己是异步的)或者附上后续部分。(它同样能在任务中或它的结果中调用Wait(),但这会和线程耦合,所以避免那样做。)当等待的任务成功完成,你的异步方法会在它中断的地方继续运行。

也许你会知道,C#5的编译器会重写它的异步方法为一个生成的实现了状态机的嵌套类。C#正好还有一个特征(从2.0开始):迭代器(yield return 的方式)。这里的方法是使用一个迭代器方法在C#4中建造状态机,返回一系列在全部处理过程中的等待步骤的Task。我们可以编写一个方法接收一个从迭代器返回的任务的枚举,返回一个重载过的Task来代表全部序列的完成以及提供它的最终结果(如果有)。

最终目标

Stephen Covey 建议我们目标有先后。这就是我们现在做的。已经有大量例子来告诉我们如何使用async/await来实现SLAMs(synchronous-looking asynchronous methods)。那么我们不使用这些关键字如何实现这个功能。我们来做一个C#5 async的例子,看看如何在C#4里实现它。然后我们讨论一下转换这些代码的一般方法。

下面的例子展示了我们在C#5里实现异步读写方法Stream.CopyToAsync()的一种写法。假设这个方法并没有在.NET5里实现。
 

public static async Task CopyToAsync(
  this Stream input, Stream output,
  CancellationToken cancellationToken = default(CancellationToken))
{
  byte[] buffer = new byte[0x1000];  // 4 KiB
  while (true) {
    cancellationToken.ThrowIfCancellationRequested();
    int bytesRead = await input.ReadAsync(buffer, 0, buffer.Length);
    if (bytesRead == 0) break;
 
    cancellationToken.ThrowIfCancellationRequested();
    await output.WriteAsync(buffer, 0, bytesRead);
  }
}


对C#4,我们将分成两块:一个是相同访问能力的方法,另一个是私有方法,参数一样但返回类型不同。私有方法用迭代实现同样的处理,结果是一连串等待的任务(IEnumerable)。序列中的实际任务可以是非泛型或者不同类型泛型的任意组合。(幸运的是,泛型Task类型是非泛型Task类型的子类型)

相同访问能力(公用)方法返回与相应async方法一致的类型:void,Task,或者泛型Task。它将使用扩展方法调用私有迭代器并转化为Task或者Task
 

public static /*async*/ Task CopyToAsync(
  this Stream input, Stream output,
  CancellationToken cancellationToken = default(CancellationToken))
{
  return CopyToAsyncTasks(input, output, cancellationToken).ToTask();
}
private static IEnumerable<Task> CopyToAsyncTasks(
  Stream input, Stream output,
  CancellationToken cancellationToken)
{
  byte[] buffer = new byte[0x1000];  // 4 KiB
  while (true) {
    cancellationToken.ThrowIfCancellationRequested();
    var bytesReadTask = input.ReadAsync(buffer, 0, buffer.Length);
    yield return bytesReadTask;
    if (bytesReadTask.Result == 0) break;
 
    cancellationToken.ThrowIfCancellationRequested();
    yield return output.WriteAsync(buffer, 0, bytesReadTask.Result);
  }
}

异步方法通常以"Async"结尾命名(除非它是事件处理器如startButton_Click)。给迭代器以同样的名字后跟“Tasks”(如startButton_ClickTasks)。如果异步方法返回void值,它仍然会调用ToTask()但不会返回Task。如果异步方法返回Task,那么它就会调用通用的ToTask()扩展方法。对应三种返回类型,异步可替代的方法像下面这样:
 

public /*async*/ void DoSomethingAsync() {
  DoSomethingAsyncTasks().ToTask();
}
public /*async*/ Task DoSomethingAsync() {
  return DoSomethingAsyncTasks().ToTask();
}
public /*async*/ Task<String> DoSomethingAsync() {
  return DoSomethingAsyncTasks().ToTask<String>();
}

成对的迭代器方法不会更复杂。当异步方法等待非通用的Task时,迭代器简单的将控制权转给它。当异步方法等待task结果时,迭代器将task保存在一个变量中,转到该方法,之后再使用它的返回值。两种情况在上面的CopyToAsyncTasks()例子里都有显示。

对包含通用resultTask的SLAM,迭代器必须将控制转交给确切的类型。ToTask()将最终的task转换为那种类型以便提取其结果。经常的你的迭代器将计算来自中间task的结果数值,而且仅需要将其打包在Task中。.NET 5为此提供了一个方便的静态方法。而.NET 4没有,所以我们用TaskEx.FromResult(value)来实现它。

最后一件你需要知道的事情是如何处理中间返回的值。一个异步的方法可以从多重嵌套的块中返回;我们的迭代器简单的通过跳转到结尾来模仿它。

 

// C#5
public async Task<String> DoSomethingAsync() {
  while (…) {
    foreach (…) {
      return "Result";
    }
  }
}
 
// C#4; DoSomethingAsync() is necessary but omitted here.
private IEnumerable<Task> DoSomethingAsyncTasks() {
  while (…) {
    foreach (…) {
      yield return TaskEx.FromResult("Result");
      goto END;
    }
  }
END: ;
}

现在我们知道如何在C#4中写SLAM了,但是只有实现了FromResult()和两个 ToTask()扩展方法才能真正的做到。下面我们开始做吧。

简单的开端

我们将在类System.Threading.Tasks.TaskEx下实现3个方法, 先从简单的那2个方法开始。FromResult()方法先创建了一个TaskCompletionSource(), 然后给它的result赋值,最后返回Task。
 

public static Task<TResult> FromResult<TResult>(TResult resultValue) {
  var completionSource = new TaskCompletionSource<TResult>();
  completionSource.SetResult(resultValue);
  return completionSource.Task;
}

很显然, 这2个ToTask()方法基本相同, 唯一的区别就是是否给返回对象Task的Result属性赋值. 通常我们不会去写2段相同的代码, 所以我们会用其中的一个方法来实现另一个。 我们经常使用泛型来作为返回结果集,那样我们不用在意返回值同时也可以避免在最后进行类型转换。 接下来我们先实现那个没有用泛型的方法。
 

private abstract class VoidResult { }
 
public static Task ToTask(this IEnumerable<Task> tasks) {
  return ToTask<VoidResult>(tasks);
}

目前为止我们就剩下一个 ToTask()方法还没有实现。

第一次天真的尝试

对于我们第一次尝试实现的方法,我们将枚举每个任务的Wait()来完成,然后将最终的任务做为结果(如果合适的话)。当然,我们不想占用当前线程,我们将另一个线程来执行循环该任务。
 

// BAD CODE !
public static Task<TResult> ToTask<TResult>(this IEnumerable<Task> tasks)
{
  var tcs = new TaskCompletionSource<TResult>();
  Task.Factory.StartNew(() => {
    Task last = null;
    try {
      foreach (var task in tasks) {
        last = task;
        task.Wait();
      }
 
      // Set the result from the last task returned, unless no result is requested.
      tcs.SetResult(
        last == null || typeof(TResult) == typeof(VoidResult)
          &#63; default(TResult) : ((Task<TResult>) last).Result);
 
    } catch (AggregateException aggrEx) {
      // If task.Wait() threw an exception it will be wrapped in an Aggregate; unwrap it.
      if (aggrEx.InnerExceptions.Count != 1) tcs.SetException(aggrEx);
      else if (aggrEx.InnerException is OperationCanceledException) tcs.SetCanceled();
      else tcs.SetException(aggrEx.InnerException);
    } catch (OperationCanceledException cancEx) {
      tcs.SetCanceled();
    } catch (Exception ex) {
      tcs.SetException(ex);
    }
  });
  return tcs.Task;
}


这里有一些好东西,事实上它真的有用,只要不触及用户界面:
它准确的返回了一个TaskCompletionSource的Task,并且通过源代码设置了完成状态。

  •     它显示了我们怎么通过迭代器的最后一个任务设置task的最终Result,同时避免可能没有结果的情况。
  •     它从迭代器中捕获异常并设置Canceled或Faulted状态. 它也传播枚举的task状态 (这里是通过Wait(),该方法可能抛出一个包装了cancellation或fault的异常的集合).

但这里有些主要的问题。最严重的是:

  •     由于迭代器需要实现“异步态的”的诺言,当它从一个UI线程初始化以后,迭代器的方法将能访问UI控件。你能发现这里的foreach循环都是运行在后台;从那个时刻开始不要触摸UI!这种方法没有顾及SynchronizationContext。
  •      在UI之外我们也有麻烦。我们可能想制造大量大量的由SLAM实现的并行运行的Tasks。但是看看循环中的Wait()!当等待一个嵌套task时,可能远程需要一个很长的时间完成,我们会挂起一个线程。我们面临线程池的线程资源枯竭的情况。
  •     这种解包Aggregate异常的方法是不太自然的。我们需要捕获并传播它的完成状态而不抛出异常。
  •     有时SLAM可以立刻决定它的完成状态。那种情形下,C#5的async可以异步并且有效的操作。这里我们总是计划了一个后台task,因此失去了那种可能。

是需要想点办法的时候了!

连续循环

最大的想法是直接从迭代器中获取其所产生的第一个任务。 我们创建了一个延续,使其在完成时能够检查任务的状态并且(如果成功的话)能接收下一个任务和创建另一个延续直至其结束。(如果没有,即迭代器没有需要完成的需求。)

 

// 很牛逼,但是我们还没有。
public static Task<TResult> ToTask<TResult>(this IEnumerable<Task> tasks)
{
  var taskScheduler =
    SynchronizationContext.Current == null
      &#63; TaskScheduler.Default : TaskScheduler.FromCurrentSynchronizationContext();
  var tcs = new TaskCompletionSource<TResult>();
  var taskEnumerator = tasks.GetEnumerator();
  if (!taskEnumerator.MoveNext()) {
    tcs.SetResult(default(TResult));
    return tcs.Task;
  }
 
  taskEnumerator.Current.ContinueWith(
    t => ToTaskDoOneStep(taskEnumerator, taskScheduler, tcs, t),
    taskScheduler);
  return tcs.Task;
}
private static void ToTaskDoOneStep<TResult>(
  IEnumerator<Task> taskEnumerator, TaskScheduler taskScheduler,
  TaskCompletionSource<TResult> tcs, Task completedTask)
{
  var status = completedTask.Status;
  if (status == TaskStatus.Canceled) {
    tcs.SetCanceled();
 
  } else if (status == TaskStatus.Faulted) {
    tcs.SetException(completedTask.Exception);
 
  } else if (!taskEnumerator.MoveNext()) {
    // 设置最后任务返回的结果,直至无需结果为止。
    tcs.SetResult(
      typeof(TResult) == typeof(VoidResult)
        &#63; default(TResult) : ((Task<TResult>) completedTask).Result);
 
  } else {
    taskEnumerator.Current.ContinueWith(
      t => ToTaskDoOneStep(taskEnumerator, taskScheduler, tcs, t),
      taskScheduler);
  }
}

这里有许多值得分享的:


    我们的后续部分(continuations)使用涉及SynchronizationContext的TaskScheduler,如果有的话。这使得我们的迭代器在UI线程初始化以后,立刻或者在一个继续点被调用,去访问UI控件。
    进程不中断的运行,因此没有线程挂起等待!顺便说一下,在ToTaskDoOneStep()中对自身的调用不是递归调用;它是在taskEnumerator.Currenttask结束后调用的匿名函数,当前活动在调用ContinueWith()几乎立刻退出,它完全独立于后续部分。
    此外,我们在继续点中验证每个嵌套task的状态,不是检查一个预测值。


然而,这儿至少有一个大问题和一些小一点的问题。

    如果迭代器抛出一个未处理异常,或者抛出OperationCanceledException而取消,我们没有处理它或设置主task的状态。这是我们以前曾经做过的但在此版本丢失了。
    为了修复问题1,我们不得不在两个方法中调用MoveNext()的地方引入同样的异常处理机制。即使是现在,两个方法中都有一样的后续部分建立。我们违背了“不要重复你自己”的信条。

    如果异步方法被期望给出一个结果,但是迭代器没有提供就退出了会怎么样呢?或者它最后的task是错误的类型呢?第一种情形下,我们默默返回默认的结果类型;第二种情形,我们抛出一个未处理的InvalidCastException,主task永远不会到达结束状态!我们的程序将永久的挂起。

    最后,如果一个嵌套的task取消或者发生错误呢?我们设置主task状态,再也不会调用迭代器。可能是在一个using块,或带有finally的try块的内部,并且有一些清理要做。我们应当遵守过程在中断的时候使它结束,而不要等垃圾收集器去做这些。我们怎么做到呢?当然通过一个后续部分!

为了解决这些问题,我们从ToTask()中移走MoveNext()调用,取而代之一个对ToTaskDoOneStep()的初始化的同步调用。然后我们将在一个提防增加合适的异常处理。

最终版本

这里是ToTask()的最终实现. 它用一个TaskCompletionSource返回主task,永远不会引起线程等待,如果有的话还会涉及SynchronizationContext,由迭代器处理异常,直接传播嵌套task的结束(而不是AggregateException),合适的时候向主task返回一个值,当期望一个结果而SLAM迭代器没有以正确的genericTask类型结束时,用一个友好的异常报错。
 

public static Task<TResult> ToTask<TResult>(this IEnumerable<Task> tasks) {
  var taskScheduler =
    SynchronizationContext.Current == null
      &#63; TaskScheduler.Default : TaskScheduler.FromCurrentSynchronizationContext();
  var taskEnumerator = tasks.GetEnumerator();
  var completionSource = new TaskCompletionSource<TResult>();
 
  // Clean up the enumerator when the task completes.
  completionSource.Task.ContinueWith(t => taskEnumerator.Dispose(), taskScheduler);
 
  ToTaskDoOneStep(taskEnumerator, taskScheduler, completionSource, null);
  return completionSource.Task;
}
 
private static void ToTaskDoOneStep<TResult>(
  IEnumerator<Task> taskEnumerator, TaskScheduler taskScheduler,
  TaskCompletionSource<TResult> completionSource, Task completedTask)
{
  // Check status of previous nested task (if any), and stop if Canceled or Faulted.
  TaskStatus status;
  if (completedTask == null) {
    // This is the first task from the iterator; skip status check.
  } else if ((status = completedTask.Status) == TaskStatus.Canceled) {
    completionSource.SetCanceled();
    return;
  } else if (status == TaskStatus.Faulted) {
    completionSource.SetException(completedTask.Exception);
    return;
  }
 
  // Find the next Task in the iterator; handle cancellation and other exceptions.
  Boolean haveMore;
  try {
    haveMore = taskEnumerator.MoveNext();
 
  } catch (OperationCanceledException cancExc) {
    completionSource.SetCanceled();
    return;
  } catch (Exception exc) {
    completionSource.SetException(exc);
    return;
  }
 
  if (!haveMore) {
    // No more tasks; set the result (if any) from the last completed task (if any).
    // We know it's not Canceled or Faulted because we checked at the start of this method.
    if (typeof(TResult) == typeof(VoidResult)) {    // No result
      completionSource.SetResult(default(TResult));
 
    } else if (!(completedTask is Task<TResult>)) {   // Wrong result
      completionSource.SetException(new InvalidOperationException(
        "Asynchronous iterator " + taskEnumerator +
          " requires a final result task of type " + typeof(Task<TResult>).FullName +
          (completedTask == null &#63; ", but none was provided." :
            "; the actual task type was " + completedTask.GetType().FullName)));
 
    } else {
      completionSource.SetResult(((Task<TResult>) completedTask).Result);
    }
 
  } else {
    // When the nested task completes, continue by performing this function again.
    taskEnumerator.Current.ContinueWith(
      nextTask => ToTaskDoOneStep(taskEnumerator, taskScheduler, completionSource, nextTask),
      taskScheduler);
  }
}

瞧! 现在你会在Visual Studio 2010中用没有async和await的 C#4 (或 VB10)写SLAMs(看起来同步的方法,但异步执行)。

有趣的地方

直到最后那个版本,我一直在给ToTask()传递一个CancellationTokenUp,并且将它传播进后续部分的ToTaskDoOneStep()。(这与本文毫不相关,所以我去掉了它们。你可以在样例代码中看注释掉的痕迹。)这有两个原因。第一,处理OperationCanceledException时,我会检查它的CancellationToken以确认它与这个操作是匹配的。如果不是,它将用一个错误来代替取消动作。虽然技术上没错,但不幸的是取消令牌可能会混淆,在其传递给ToTask()调用和后续部分之间的无关信息使它不值得。(如果你们这些 Task专家能给我一个注释里的可确认发生的好的用例,我会重新考虑)

第二个原因是我会检查令牌是否取消,在每次MoveNext()调用迭代器之前,立即取消主task时,和退出进程的时候。这使你可以不经过迭代器检查令牌,具有取消的行为。我不认为这是要做的正确事情(因为对一个异步进程在yield return处取消是不合适的)——更可能是它完全在迭代器进程控制之下——但我想试试。它无法工作。我发现在某些情形,task会取消而却后续部分不会触发。请看样例代码;我靠继续执行来恢复按钮可用,但它没有发生因此按钮在进程结束之后仍不可用。我在样例代码中留下了注释掉的取消检测;你可以将取消令牌的方法参数放回去并测试它。(如果你们Task专家能解释为什么会是这种情形,我将很感激!)

声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
如何使用C#编写时间序列预测算法如何使用C#编写时间序列预测算法Sep 19, 2023 pm 02:33 PM

如何使用C#编写时间序列预测算法时间序列预测是一种通过分析过去的数据来预测未来数据趋势的方法。它在很多领域,如金融、销售和天气预报中有广泛的应用。在本文中,我们将介绍如何使用C#编写时间序列预测算法,并附上具体的代码示例。数据准备在进行时间序列预测之前,首先需要准备好数据。一般来说,时间序列数据应该具有足够的长度,并且是按照时间顺序排列的。你可以从数据库或者

如何使用Redis和C#开发分布式事务功能如何使用Redis和C#开发分布式事务功能Sep 21, 2023 pm 02:55 PM

如何使用Redis和C#开发分布式事务功能引言分布式系统的开发中,事务处理是一项非常重要的功能。事务处理能够保证在分布式系统中的一系列操作要么全部成功,要么全部回滚。Redis是一种高性能的键值存储数据库,而C#是一种广泛应用于开发分布式系统的编程语言。本文将介绍如何使用Redis和C#来实现分布式事务功能,并提供具体代码示例。I.Redis事务Redis

如何实现C#中的人脸识别算法如何实现C#中的人脸识别算法Sep 19, 2023 am 08:57 AM

如何实现C#中的人脸识别算法人脸识别算法是计算机视觉领域中的一个重要研究方向,它可以用于识别和验证人脸,广泛应用于安全监控、人脸支付、人脸解锁等领域。在本文中,我们将介绍如何使用C#来实现人脸识别算法,并提供具体的代码示例。实现人脸识别算法的第一步是获取图像数据。在C#中,我们可以使用EmguCV库(OpenCV的C#封装)来处理图像。首先,我们需要在项目

C#开发中如何处理跨域请求和安全性问题C#开发中如何处理跨域请求和安全性问题Oct 08, 2023 pm 09:21 PM

C#开发中如何处理跨域请求和安全性问题在现代的网络应用开发中,跨域请求和安全性问题是开发人员经常面临的挑战。为了提供更好的用户体验和功能,应用程序经常需要与其他域或服务器进行交互。然而,浏览器的同源策略导致了这些跨域请求被阻止,因此需要采取一些措施来处理跨域请求。同时,为了保证数据的安全性,开发人员还需要考虑一些安全性问题。本文将探讨C#开发中如何处理跨域请

Redis在C#开发中的应用:如何实现高效的缓存更新Redis在C#开发中的应用:如何实现高效的缓存更新Jul 30, 2023 am 09:46 AM

Redis在C#开发中的应用:如何实现高效的缓存更新引言:在Web开发中,缓存是提高系统性能的常用手段之一。而Redis作为一款高性能的Key-Value存储系统,能够提供快速的缓存操作,为我们的应用带来了不少便利。本文将介绍如何在C#开发中使用Redis,实现高效的缓存更新。Redis的安装与配置在开始之前,我们需要先安装Redis并进行相应的配置。你可以

如何使用C#编写动态规划算法如何使用C#编写动态规划算法Sep 20, 2023 pm 04:03 PM

如何使用C#编写动态规划算法摘要:动态规划是求解最优化问题的一种常用算法,适用于多种场景。本文将介绍如何使用C#编写动态规划算法,并提供具体的代码示例。一、什么是动态规划算法动态规划(DynamicProgramming,简称DP)是一种用来求解具有重叠子问题和最优子结构性质的问题的算法思想。动态规划将问题分解成若干个子问题来求解,通过记录每个子问题的解,

如何实现C#中的遗传算法如何实现C#中的遗传算法Sep 19, 2023 pm 01:07 PM

如何在C#中实现遗传算法引言:遗传算法是一种模拟自然选择和基因遗传机制的优化算法,其主要思想是通过模拟生物进化的过程来搜索最优解。在计算机科学领域,遗传算法被广泛应用于优化问题的解决,例如机器学习、参数优化、组合优化等。本文将介绍如何在C#中实现遗传算法,并提供具体的代码示例。一、遗传算法的基本原理遗传算法通过使用编码表示解空间中的候选解,并利用选择、交叉和

如何使用C#编写背包问题算法如何使用C#编写背包问题算法Sep 19, 2023 am 09:21 AM

如何使用C#编写背包问题算法背包问题(KnapsackProblem)是一个经典的组合优化问题,它描述了一个给定容量的背包以及一系列物品,每个物品都有自己的价值和重量。目标是找到一种最佳策略,使得在不超过背包容量的情况下,装入背包的物品总价值最大。在C#中,可以通过动态规划方法来解决背包问题。具体实现如下:usingSystem;namespace

See all articles

热AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover

AI Clothes Remover

用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool

Undress AI Tool

免费脱衣服图片

Clothoff.io

Clothoff.io

AI脱衣机

AI Hentai Generator

AI Hentai Generator

免费生成ai无尽的。

热门文章

R.E.P.O.能量晶体解释及其做什么(黄色晶体)
3 周前By尊渡假赌尊渡假赌尊渡假赌
R.E.P.O.最佳图形设置
3 周前By尊渡假赌尊渡假赌尊渡假赌
R.E.P.O.如果您听不到任何人,如何修复音频
3 周前By尊渡假赌尊渡假赌尊渡假赌

热工具

SublimeText3汉化版

SublimeText3汉化版

中文版,非常好用

PhpStorm Mac 版本

PhpStorm Mac 版本

最新(2018.2.1 )专业的PHP集成开发工具

EditPlus 中文破解版

EditPlus 中文破解版

体积小,语法高亮,不支持代码提示功能

螳螂BT

螳螂BT

Mantis是一个易于部署的基于Web的缺陷跟踪工具,用于帮助产品缺陷跟踪。它需要PHP、MySQL和一个Web服务器。请查看我们的演示和托管服务。

适用于 Eclipse 的 SAP NetWeaver 服务器适配器

适用于 Eclipse 的 SAP NetWeaver 服务器适配器

将Eclipse与SAP NetWeaver应用服务器集成。