diff --git a/src/Microsoft.Kusto.ServiceLayer/Utility/ServiceLayerCommandOptions.cs b/src/Microsoft.Kusto.ServiceLayer/Utility/ServiceLayerCommandOptions.cs index 6ac02c70..0922d0b5 100644 --- a/src/Microsoft.Kusto.ServiceLayer/Utility/ServiceLayerCommandOptions.cs +++ b/src/Microsoft.Kusto.ServiceLayer/Utility/ServiceLayerCommandOptions.cs @@ -4,7 +4,7 @@ // using System.Globalization; -using Microsoft.SqlTools.Hosting.Utility; +using Microsoft.SqlTools.Utility; namespace Microsoft.Kusto.ServiceLayer.Utility { diff --git a/src/Microsoft.SqlTools.Credentials/Program.cs b/src/Microsoft.SqlTools.Credentials/Program.cs index 8d2e00c2..9cea8ba9 100644 --- a/src/Microsoft.SqlTools.Credentials/Program.cs +++ b/src/Microsoft.SqlTools.Credentials/Program.cs @@ -5,7 +5,6 @@ using System; using System.Diagnostics; using Microsoft.SqlTools.Credentials.Utility; -using Microsoft.SqlTools.Hosting.Utility; using Microsoft.SqlTools.ServiceLayer.SqlContext; using Microsoft.SqlTools.Utility; @@ -40,7 +39,7 @@ namespace Microsoft.SqlTools.Credentials Logger.Initialize(tracingLevel: commandOptions.TracingLevel, logFilePath: logFilePath, traceSource: "credentials", commandOptions.AutoFlushLog); - // set up the host details and profile paths + // set up the host details and profile paths var hostDetails = new HostDetails( name: "SqlTools Credentials Provider", profileId: "Microsoft.SqlTools.Credentials", diff --git a/src/Microsoft.SqlTools.Hosting/Extensibility/ExtensionServiceHost.cs b/src/Microsoft.SqlTools.Hosting/Extensibility/ExtensionServiceHost.cs new file mode 100644 index 00000000..5d0f6cc0 --- /dev/null +++ b/src/Microsoft.SqlTools.Hosting/Extensibility/ExtensionServiceHost.cs @@ -0,0 +1,294 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using Microsoft.SqlTools.Hosting; +using Microsoft.SqlTools.Hosting.Contracts; +using Microsoft.SqlTools.Hosting.Protocol; +using Microsoft.SqlTools.Hosting.Protocol.Channel; +using Microsoft.SqlTools.ServiceLayer.SqlContext; +using Microsoft.SqlTools.Utility; + +namespace Microsoft.SqlTools.Extensibility +{ + public class ExtensionServiceHost : ServiceHostBase + { + + private ExtensibleServiceHostOptions options; + private static bool isLoaded; + public ExtensionServiceProvider serviceProvider; + private List initializedServices = new List(); + + public ExtensionServiceHost( + ExtensibleServiceHostOptions options + ) : base(new StdioServerChannel()) + { + this.options = options; + + this.Initialize(); + + // Start the service only after all request handlers are setup. This is vital + // as otherwise the Initialize event can be lost - it's processed and discarded before the handler + // is hooked up to receive the message + this.Start().Wait(); + isLoaded = true; + + } + + private void Initialize() + { + base.Initialize(); + this.serviceProvider = ExtensionServiceProvider.CreateFromAssembliesInDirectory(options.ExtensionServiceAssemblyDirectory, options.ExtensionServiceAssemblyDllFileNames); + var hostDetails = new HostDetails( + name: options.HostName, + profileId: options.HostProfileId, + version: options.HostVersion); + + SqlToolsContext sqlToolsContext = new SqlToolsContext(hostDetails); + serviceProvider.RegisterSingleService(sqlToolsContext); + serviceProvider.RegisterSingleService(this); + this.InitializeHostedServices(); + this.InitializeRequestHandlers(); + } + + private void InitializeRequestHandlers() + { + // Register the requests that this service host will handle + this.SetRequestHandler(InitializeRequest.Type, this.HandleInitializeRequest); + this.SetRequestHandler(ShutdownRequest.Type, this.HandleShutdownRequest); + this.SetRequestHandler(VersionRequest.Type, this.HandleVersionRequest); + } + + private void InitializeHostedServices() + { + // Pre-register all services before initializing. This ensures that if one service wishes to reference + // another one during initialization, it will be able to safely do so + foreach (IHostedService service in this.serviceProvider.GetServices()) + { + if(IsServiceInitialized(service)) + { + continue; + } + Logger.Verbose("Registering service: " + service.GetType()); + this.RegisterService(service); + } + + foreach (IHostedService service in this.serviceProvider.GetServices()) + { + if(IsServiceInitialized(service)) + { + continue; + } + Logger.Verbose("Initializing service: " + service.GetType()); + // Initialize all hosted services, and register them in the service provider for their requested + // service type. This ensures that when searching for the ConnectionService you can get it without + // searching for an IHostedService of type ConnectionService + this.InitializeService(service); + } + } + + private bool IsServiceInitialized(IHostedService service) + { + return this.initializedServices.Any(s => s.GetType() == service.GetType()); + } + + /// + /// Delegate definition for the host shutdown event + /// + public delegate Task ShutdownCallback(object shutdownParams, RequestContext shutdownRequestContext); + + /// + /// Delegate definition for the host initialization event + /// + public delegate Task InitializeCallback(InitializeRequest startupParams, RequestContext requestContext); + + private readonly List shutdownCallbacks = new List(); + + private readonly List initializeCallbacks = new List(); + + private readonly Version serviceVersion = Assembly.GetEntryAssembly().GetName().Version; + + /// + /// Adds a new callback to be called when the shutdown request is submitted + /// + /// Callback to perform when a shutdown request is submitted + public void RegisterShutdownTask(ShutdownCallback callback) + { + shutdownCallbacks.Add(callback); + } + + /// + /// Add a new method to be called when the initialize request is submitted + /// + /// Callback to perform when an initialize request is submitted + public void RegisterInitializeTask(InitializeCallback callback) + { + initializeCallbacks.Add(callback); + } + + + /// + /// Handles the shutdown event for the Language Server + /// + private async Task HandleShutdownRequest(object shutdownParams, RequestContext requestContext) + { + Logger.Write(TraceEventType.Information, "Service host is shutting down..."); + + // Call all the shutdown methods provided by the service components + Task[] shutdownTasks = shutdownCallbacks.Select(t => t(shutdownParams, requestContext)).ToArray(); + TimeSpan shutdownTimeout = TimeSpan.FromSeconds(options.ShutdownTimeoutInSeconds); + // shut down once all tasks are completed, or after the timeout expires, whichever comes first. + await Task.WhenAny(Task.WhenAll(shutdownTasks), Task.Delay(shutdownTimeout)).ContinueWith(t => Environment.Exit(0)); + } + + /// + /// Handles the initialization request + /// + private async Task HandleInitializeRequest(InitializeRequest initializeParams, RequestContext requestContext) + { + try + { + // Call all tasks that registered on the initialize request + var initializeTasks = initializeCallbacks.Select(t => t(initializeParams, requestContext)); + await Task.WhenAll(initializeTasks); + + // Send back what this server can do + await requestContext.SendResult( + new InitializeResult + { + Capabilities = options.ServerCapabilities + }); + } + catch (Exception e) + { + await requestContext.SendError(e.Message); + } + } + + /// + /// Handles the version request. Sends back the server version as result. + /// + private async Task HandleVersionRequest(object versionRequestParams, RequestContext requestContext) + { + await requestContext.SendResult(serviceVersion.ToString()); + } + + + /// + /// Loads and initializes the services from the given assemblies + /// + /// path of the dll files + public void LoadAndIntializeServicesFromAssesmblies(string[] assemblyPaths) + { + this.serviceProvider.AddAssemblies(options.ExtensionServiceAssemblyDirectory, assemblyPaths); + this.InitializeHostedServices(); + } + + /// + /// Registers and initializes the given service + /// + /// service to be initialized + protected void RegisterService(IHostedService service) + { + this.serviceProvider.RegisterSingleService(service.GetType(), service); + + } + + protected void InitializeService(IHostedService service) + { + service.InitializeService(this); + this.initializedServices.Add(service); + this.options.InitializeServiceCallback(this, service); + } + + /// + /// Registers and initializes the given services + /// + /// services to be initalized + public void RegisterAndInitializedServices(IEnumerable services) + { + foreach (IHostedService service in services) + { + this.RegisterService(service); + this.InitializeService(service); + } + } + + /// + /// Register and initializes the given service + /// + /// service to be initialized + public void RegisterAndInitializeService(IHostedService service) + { + this.RegisterService(service); + this.InitializeService(service); + } + } + + + + + public class ExtensibleServiceHostOptions + { + /// + /// The folder where the extension service assemblies are located. By default it is + /// the folder where the current server assembly is located. + /// + public string ExtensionServiceAssemblyDirectory { get; set; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + /// + /// The dlls that contain the extension services. + /// + public string[] ExtensionServiceAssemblyDllFileNames { get; set; } = new string[0]; + + /// + /// Host name for the services. + /// + public string HostName { get; set; } = HostDetails.DefaultHostName; + + /// + /// Gets the profile ID of the host, used to determine the + /// host-specific profile path. + /// + public string HostProfileId { get; set; } = HostDetails.DefaultHostProfileId; + + /// + /// Gets the version of the host. + /// + public Version HostVersion { get; set; } = HostDetails.DefaultHostVersion; + + /// + /// Data protocol capabilities that the server supports. + /// + public ServerCapabilities ServerCapabilities { get; set; } = new ServerCapabilities + { + DefinitionProvider = false, + ReferencesProvider = false, + DocumentFormattingProvider = false, + DocumentRangeFormattingProvider = false, + DocumentHighlightProvider = false, + HoverProvider = false + }; + + /// + /// Timeout in seconds for the shutdown request. Default is 120 seconds. + /// + public int ShutdownTimeoutInSeconds { get; set; } = 120; + + public delegate void InitializeService(ExtensionServiceHost serviceHost, IHostedService service); + + /// + /// Service initialization callback. The caller must define this callback to initialize the service. + /// + /// + public InitializeService InitializeServiceCallback { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.SqlTools.Hosting/Extensibility/ExtensionServiceProvider.cs b/src/Microsoft.SqlTools.Hosting/Extensibility/ExtensionServiceProvider.cs index d9c07ac5..44d913af 100644 --- a/src/Microsoft.SqlTools.Hosting/Extensibility/ExtensionServiceProvider.cs +++ b/src/Microsoft.SqlTools.Hosting/Extensibility/ExtensionServiceProvider.cs @@ -119,16 +119,89 @@ namespace Microsoft.SqlTools.Extensibility /// /// Merges in new assemblies to the existing container configuration. /// - public void AddAssembliesToConfiguration(IEnumerable assemblies) + /// Type of the service present in the assemblies + public void AddAssembliesToConfiguration(IEnumerable assemblies) { Validate.IsNotNull(nameof(assemblies), assemblies); var previousConfig = config; - this.config = conventions => { + this.config = conventions => + { // Chain in the existing configuration function's result, then include additional // assemblies ContainerConfiguration containerConfig = previousConfig(conventions); return containerConfig.WithAssemblies(assemblies, conventions); }; + ExtensionStore store = new ExtensionStore(typeof(T), config); + + // If the service type is already registered, replace the existing registration with the new one + if (this.services.ContainsKey(typeof(T))) + { + this.services[typeof(T)] = () => store.GetExports(); + } + else + { + base.Register(() => store.GetExports()); + } + } + + /// + /// Creates a service provider by loading a set of named assemblies, expected to be + /// + /// Directory to search for included assemblies + /// full DLL names, case insensitive, of assemblies to include + /// instance + public static ExtensionServiceProvider CreateFromAssembliesInDirectory(string directory, IList inclusionList) + { + Logger.Verbose("Loading service assemblies from ..."+ directory); + var assemblyPaths = Directory.GetFiles(directory, "*.dll", SearchOption.TopDirectoryOnly); + + List assemblies = LoadAssemblies(directory, inclusionList); + return Create(assemblies); + } + + public void AddAssemblies(string directory, IList inclusionList) + { + this.AddAssembliesToConfiguration(LoadAssemblies(directory, inclusionList)); + } + + private static List LoadAssemblies(string directory, IList inclusionList) + { + Logger.Verbose("Loading service assemblies from ..."+ directory); + //AssemblyLoadContext context = new AssemblyLoader(directory); + var assemblyPaths = Directory.GetFiles(directory, "*.dll", SearchOption.TopDirectoryOnly); + + List assemblies = new List(); + foreach (var path in assemblyPaths) + { + // skip DLL files not in inclusion list + bool isInList = false; + foreach (var item in inclusionList) + { + if (path.EndsWith(item, StringComparison.OrdinalIgnoreCase)) + { + isInList = true; + break; + } + } + + if (!isInList) + { + continue; + } + + try + { + Logger.Verbose("Loading service assembly: " + path); + assemblies.Add(AssemblyLoadContext.Default.LoadFromAssemblyPath(path)); + Logger.Verbose("Loaded service assembly: " + path); + } + catch (Exception ex) + { + // we expect exceptions trying to scan all DLLs since directory contains native libraries + Logger.Error(ex); + } + } + return assemblies; } } @@ -193,7 +266,7 @@ namespace Microsoft.SqlTools.Extensibility { // Define exports as matching a parent type, export as that parent type var builder = new ConventionBuilder(); - builder.ForTypesDerivedFrom(contractType).Export(exportConventionBuilder => exportConventionBuilder.AsContractType(contractType)); + builder.ForTypesDerivedFrom(contractType).Export(exportConventionBuilder => exportConventionBuilder.AsContractType(contractType)); return builder; } } diff --git a/src/Microsoft.SqlTools.Hosting/README.md b/src/Microsoft.SqlTools.Hosting/README.md new file mode 100644 index 00000000..a1b32fb8 --- /dev/null +++ b/src/Microsoft.SqlTools.Hosting/README.md @@ -0,0 +1,135 @@ +# SqlTools.Hosting + +## Description +This library contains the necessary classes for implementing an Azure Data Studio [language server](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide). It provides a simple and easy-to-use API for handling JSON-RPC requests and responses, and supports adding LSP messages and notifications. + +## Usage +Example of using extension service host to implement a JSON rpc server and registering a service with it. + +```cs +using System; +using System.Diagnostics; +using System.IO; +using Microsoft.SqlTools.Utility; +using Microsoft.SqlTools.Extensibility; +using Microsoft.SqlTools.Hosting; + +namespace Microsoft.SqlTools.SampleService +{ + internal class Program + { + private const string ServiceName = "MicrosoftSqlToolsSampleService.exe"; + + internal static void Main(string[] args) + { + try + { + // reading command-line arguments + CommandOptions commandOptions = new CommandOptions(args, ServiceName); + + + // Using the command-line arguments to initialize the logger included in the library + string logFilePath = "MicrosoftSqlToolsSampleService"; + if (!string.IsNullOrWhiteSpace(commandOptions.LogFilePath)) + { + logFilePath = Path.Combine(commandOptions.LogFilePath, logFilePath); + } else + { + logFilePath = Logger.GenerateLogFilePath(logFilePath); + + } + + Logger.Initialize( + tracingLevel: SourceLevels.Verbose, + logFilePath: logFilePath, "MicrosoftSqlToolsSampleService", + commandOptions.AutoFlushLog + ); + + Logger.Verbose("Starting SqlTools Sample Services....."); + + // Setting up the options for the extension service host + ExtensibleServiceHostOptions serverOptions = new ExtensibleServiceHostOptions() + { + // Name of the server + HostName = "Sample Server", + // Unique identifier for the server + HostProfileId = "Microsoft.SqlTools.SampleServer", + // Version of the server + HostVersion = new Version(1, 0, 0, 0), + // Directory where the service assemblies are located + ExtensionServiceAssemblyDirectory = Path.GetDirectoryName(typeof(Program).Assembly.Location), + // Names of the service assemblies + ExtensionServiceAssemblyDllFileNames = new string[] { + "Microsoft.SqlTools.SampleServer.Service1MEF.dll", + } + }; + + // Creating the extension service host + ExtensionServiceHost serviceHost = new ExtensionServiceHost(serverOptions); + + // Registering the service with the extension service host + serviceHost.RegisterAndInitializedServices(SampleService.Instance); + serviceHost.RegisterAndInitializeService(SampleService.Instance); + + // Adding more assemblies to the extension service host + serviceHost.LoadAndIntializeServicesFromAssesmblies( + new string[] { + "Microsoft.SqlTools.SampleServer.Services2MEF.dll" + } + ); + + serviceHost.WaitForExit(); + + Logger.Verbose("SqlTools Sample Services stopped."); + + } + catch (Exception e) + { + Logger.Write(TraceEventType.Error, string.Format("An unhandled exception occurred: {0}", e)); + Environment.Exit(1); + } + } + } +} + +``` + +### Implementing a service for Extension Service Host for MEF based consumption. + +```cs +namespace Microsoft.SqlTools.SampleServer.Service1MEF +{ + [Export(typeof(IHostedService))] // This is required for MEF to discover the service + public class SampleServiceMEF1 : IHostedService + { + } +} +``` + +```cs +namespace Microsoft.SqlTools.SampleServer.Service2MEF +{ + [Export(typeof(IHostedService))] // This is required for MEF to discover the service + public class SampleServiceMEF2 : IHostedService + { + } +} +``` + +### Implementing a service for direct consumption. +```cs +namespace Microsoft.SqlTools.SampleServer.Service +{ + public class SampleService : IHostedService + { + } +} +``` + +## Compatibility + +The library has been tested with and is compatible with following other JSON-RPC library: + +* [sqlops-dataprotocolclient](https://github.com/microsoft/sqlops-dataprotocolclient) + + diff --git a/src/Microsoft.SqlTools.Hosting/Utility/CommandOptions.cs b/src/Microsoft.SqlTools.Hosting/Utility/CommandOptions.cs index eebc6651..0095cc8c 100644 --- a/src/Microsoft.SqlTools.Hosting/Utility/CommandOptions.cs +++ b/src/Microsoft.SqlTools.Hosting/Utility/CommandOptions.cs @@ -6,7 +6,7 @@ using System; using System.Globalization; -namespace Microsoft.SqlTools.Hosting.Utility +namespace Microsoft.SqlTools.Utility { /// /// The command-line options helper class. diff --git a/src/Microsoft.SqlTools.ResourceProvider/Program.cs b/src/Microsoft.SqlTools.ResourceProvider/Program.cs index 5fd39d2e..0e05d760 100644 --- a/src/Microsoft.SqlTools.ResourceProvider/Program.cs +++ b/src/Microsoft.SqlTools.ResourceProvider/Program.cs @@ -5,7 +5,6 @@ using System; using System.Diagnostics; -using Microsoft.SqlTools.Hosting.Utility; using Microsoft.SqlTools.ServiceLayer.SqlContext; using Microsoft.SqlTools.Utility; diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs index b3215de2..305206e3 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs @@ -314,7 +314,7 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices var serviceProvider = (ExtensionServiceProvider)ServiceHostInstance.ServiceProvider; var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(param.AssemblyPath); var assemblies = new Assembly[] { assembly }; - serviceProvider.AddAssembliesToConfiguration(assemblies); + serviceProvider.AddAssembliesToConfiguration(assemblies); foreach (var ext in serviceProvider.GetServices()) { var cancellationTokenSource = new CancellationTokenSource(ExtensionLoadingTimeout); diff --git a/src/Microsoft.SqlTools.ServiceLayer/Utility/ServiceLayerCommandOptions.cs b/src/Microsoft.SqlTools.ServiceLayer/Utility/ServiceLayerCommandOptions.cs index d85d338e..0c7f473c 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Utility/ServiceLayerCommandOptions.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/Utility/ServiceLayerCommandOptions.cs @@ -6,7 +6,7 @@ using System; using System.Globalization; using System.Linq; -using Microsoft.SqlTools.Hosting.Utility; +using Microsoft.SqlTools.Utility; namespace Microsoft.SqlTools.ServiceLayer.Utility { diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/Credentials/SrTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/Credentials/SrTests.cs index 70931758..d96d389e 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/Credentials/SrTests.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/Credentials/SrTests.cs @@ -3,7 +3,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // -using Microsoft.SqlTools.Hosting.Utility; +using Microsoft.SqlTools.Utility; using NUnit.Framework; using CredSR = Microsoft.SqlTools.Credentials.SR;