title | description | keywords | author | manager | ms.date | ms.topic | ms.prod | ms.technology | ms.devlang | ms.assetid |
---|---|---|---|---|---|---|---|---|---|---|
.NET Core CLI extensibility model |
.NET Core CLI extensibility model |
.NET, .NET Core |
mairaw |
wpickett |
06/20/2016 |
article |
.net-core |
.net-core-technologies |
dotnet |
1bebd25a-120f-48d3-8c25-c89965afcbcd |
This document will cover the main ways how to extend the CLI tools and explain the scenarios that drive each of them. It will the outline how to consume the tools as well as provide short notes on how to build both types of tools.
该文件将涵盖如何扩展 CLI 工具主要途径,并说明推动它们每个的场景。
The CLI tools can be extended in two main ways:
- Via NuGet packages on a per-project basis
- Via the system's PATH
The two extensibility mechanisms outlined above are not exclusive; you can use both or just one. Which one to pick depends largely on what is the goal you are trying to achieve with your extension.
Per-project tools are portable console applications that are distributed as NuGet packages. Tools are only available in the context of the project that references them and for which they are restored; invocation outside of the context of the project (for example, outside of the directory that contains the project) will fail as the command will not be able to be found.
These tools are perfect for build servers as well, since nothing outside of project.json
is needed. The build process
runs restore for the project it builds and tools will be available. Language projects, such as F#, are also in this
category; after all, each project can only be written in one specific language.
Finally, this extensibility model provides support for creation of tools that need access to the built output of the project. For instance, various Razor view tools in ASP.NET MVC applications fall into this category.
Consuming these tools requires you to add a tools
node to your project.json
. Inside the tools
node, you reference
the package in which the tool resides. After running dotnet restore
, the tool and its dependencies are restored.
For tools that need to load the build output of the project for execution, there is usually another dependency which is listed under the regular dependencies in the project file. This means that tools that load project's code have two components:
- The "tools" main invoker
- Any number of other tools that contain the logic to work with
Why two things? Tools that need to load the build output of a project need to have unified dependency graph with the
project they are working. By adding the dependency bit, we enable NuGet to resolve these dependencies as a unified
graph. The invoker is there because it needs to reason about the location as well as the frameworks of the dependency
tool. The invoker can accept all of the redirection arguments (-c
, -o
, -b
) that the user specifies and finds the
dependency tool; it can also implement any policies for cases where multiple dependency tools exist for multiple
frameworks (that is, does it run all of them, just one, etc.) In general, logic can be shared between these two tools any way
that is needed.
Let's review an example of adding a simple tools-only tool to a simple project. Given an example command called
dotnet-api-search
that allows you to search through the NuGet packages for the specified
API, here is a console application's project.json
file that uses that tool:
{
"version": "1.0.0",
"compilationOptions": {
"emitEntryPoint": true
},
"dependencies": {
"Microsoft.NETCore.App": {
"type": "platform",
"version": "1.0.0"
}
},
"tools": {
"dotnet-api-search": {
"version": "1.0.0",
"imports": ["dnxcore50"]
}
},
"frameworks": {
"netcoreapp1.0": {}
}
}
The tools
node is structured in a similar way as the dependencies
node. It needs the package ID of the package
containing the tool and its version at the very least. In the example above, we can see that there is another statement,
the imports
one. This influences the tool's restore process and specifies that the tool is also compatible, in
addition to any targeted frameworks the tools has, with dnxcore50
target. For more information you can
consult the project.json reference.
As mentioned, tools are just portable console applications. You would build one as you would build any console application.
After you build it, you would use dotnet pack
command to create a NuGet package (nupkg) that contains
your code, information about its dependencies and so on. The package name can be whatever the author wants, but the
application inside, the actual tool binary, has to conform to the convention of dotnet-<command>
in order for dotnet
to be able to invoke it.
Since tools are portable applications, the user consuming the tool has to have the version of the .NET Core libraries that the tool was built against in order to run the tool. Any other dependency that the tool uses and that is not contained within the .NET Core libraries is restored and placed in the NuGet cache. The entire tool is, therefore, run using the assemblies from the .NET Core libraries as well as assemblies from the NuGet cache.
These kind of tools have a dependency graph that is completely separate from the dependency graph of the project that uses them. The restore process will first restore the project's dependencies, and will then restore each of the tools and their dependencies.
You can find richer examples and different combinations of this in the .NET Core CLI repo. You can also see the implementation of tools used in the same repo.
Building tools that load project's build outputs for execution is slightly different. As stated, for these kinds of tools there are two components:
- A dispatcher tool that the user invokes
- A framework-specific dependency that contains the logic on how to find the build outputs and what to do with it
A prime example of this are Entity Framework (EF) commands as well as the dotnet test
command. In both
cases, there is a tool that is referenced in the tools
node of the project.json
and that is the main dispatcher. The
user invokes this tool on the command line. The second piece of the puzzle is the dependency that is given in the
project's main dependencies (either root ones or framework-specific ones). This package contains the actual logic of
the tool. The package is a normal dependency, thus it will be restored as part of the restore process for the project.
Unlike the previous kind of tools, these tool are actually part of the graph of the project that consumes them. This is because they need access to the project's code and potentially all of its dependencies. For instance, the EF tools need this because they need to scan the assemblies to find the code they need, such as migrations.
Another reason why this two-pronged solution exists is to allow a cleaner invocation model. Most CLI commands that
drop certain artifacts on disk (for example, dotnet build
, dotnet publish
) allow users to redirect the outputs to a different
path using the --output
argument or --build-base-path
argument or --configuration
argument. For EF tools, for
example, to be able to find the build output of your project, you would have to provide the same arguments with the same
values to both dotnet
driver as well as the ef
command. With the invocation model, the users pass any arguments to
the dispatcher tool which can then use that to find the needed binary that contains the logic in the output directories.
A good example of this approach can be found in the .NET Core CLI repo:
- Sample project.json file
- Implementation of the dispatcher
- Implementation of the framework-specific dependency
PATH-based extensibility is usually used for development machines where you need a tool that conceptually covers more than a single project. The main drawback of this extensions mechanism is that it is tied to the machine where the tool exists. If you need it on another machine, you would have to deploy it.
This pattern of CLI toolset extensibility is very simple. As covered in the .NET Core CLI overview, dotnet
driver
can run any command that is named after the dotnet <command>
convention. The default resolution logic will first
probe several locations and will finally fall to the system PATH. If the requested command exists in the system PATH
and is a binary that can be invoked, dotnet
driver will invoke it.
The binary can be pretty much anything that the operating system can execute. On Unix systems, this means anything that
has the execute bit set via chmod +x
. On Windows it means anything that Windows knows how to run.
As an example, let's take a look at a very simple implementation of a dotnet clean
command. We will use bash
to
implement this command. The command will simply delete the bin/
and obj/
directories in the current directory. If
the --lock
argument is passed to it, it will also delete project.lock.json
file. The entirety of the command is
given below.
#!/bin/bash
# Delete the bin and obj dirs
rm -rf bin/ obj/
LOCK_FILE=$1
if [[ "$LOCK_FILE" = "--lock" ]]; then
rm project.lock.json
fi
echo "Cleaning complete..."
On macOS, we can save this script as dotnet-clean
and set its executable bit with chmod +x dotnet-clean
. We can then
create a symbolic link to it in /usr/local/bin
using the command ln -s dotnet-clean /usr/local/bin/
. This will make
it possible to invoke the clean command using the dotnet clean
syntax. You can test this by creating an app, running
dotnet build
on it and then running dotnet clean
.
The .NET Core CLI tools allow two main extensibility points. The per-project tools are contained within the project's context, but they allow easy installation through restoration. PATH-based tools are good for general, cross-project tools that are usable on a single machine.