Multiinštancie windows .NET servicov

27.03.2011 15:44 | blackhole

Prednedávnom som bol postavený pred problém – bolo potrebné nainštalovať viacero inštancií service-u na jednom windows stroji. Toto za normálnych okolností nie je možné a bez možnosti service meniť je pravdepodobne jediným riešením virtualizácia. Keďže mi toto riešenie pripadalo ako extrémne nevhodné, hľadal som ďaľšie možnosti.

V zbytku článku popíšem process, ktorým sa mi podarilo v jednom projekte docieliť za nasledovných okolností:

  1. Existovala iba jediná skompilovaná verzia service-u
    (dôsledok: zjednodušená práca okolo nasadzovania)
  2. Celá aplikácia sa nachádzala na FS iba raz
    (dôsledok: zjednodušená konfigurácia a nasadzovanie)
  3. Samostatný manažment inštalovania, odinštalovania a spúšťania service-u
    (dôsledok: prevencia chýb operátora pri konfigurácii a nasadzovaní)

Ak Vás teda zaujíma aj hociktorá podčasť môjho problému, verím, že Vám tento článok pomôže.

Hlavným problémom, prečo sa service nedá jednoducho nainštalovať viackrát je to, že windows nedovoľuje zaregistrovať viacero service-ov s rovnakým ServiceName alebo DisplayName.

Najjednoduchším riešením je teda skompilovať viacero verzií service-u, vždy s iným ServiceName a DisplayName. Toto riešenie má ale niekoľko veľmi zreteľných nevýhod:

  • Potreba kompilovať projekt viac krát
  • Ak má na jednom systéme byť nainštalovaných N inštancií, je nutné skompilovať N verzií service-u. (Toto sa dá zautomatizovať rôznymi technikami, ale omnoho elegantnejším riešením je tento problém obísť.)
  • Zťažená správa (update na nové verzie, zmeny v konfigurácii)

Prvým problémom, ktorý si vezmem na mušku, je práve potreba viacero skompilovaných verzií service-u. Keďže chceme, aby existovala práve jedna verzia service-u, musíme určiť ServiceName a DisplayName (teda identifikátory, pod ktorými sa service registruje do windowsu) počas jeho registrácie.

Toto je naštastie v .NET možné. Ak ste už robili service v .NET, určite ste použili ProjectInstaller. V skratke je to control, ktorý sa používa pri inštalovaní a odinštalovaní projektu (v našom prípade service-u). Zaujímavé sú najmä jeho eventy BeforeInstall a BeforeUninstall. Myšlienkou je v týchto eventoch premenovať ServiceName (a DisplayName). Problémom je zistiť hodnoty, ktoré sa majú na tento účel použiř. Jednou možnosťou je zisťovať, či je service s daným menom už zaregistrovaný alebo nie a podľa toho zvoliť meno práve inštalovaného service-u. Trochu problematické môže byť takto service-y odinštalúvať, hlavným problémom je ale nesystémovosť takéhoto riešenia.

Správnejším riešením je posielať tieto hodnoty do procesu inštalácie od usera. Pre teraz môžeme predpokladať, že na inštalovanie a odinštalovanie budeme používať utilitu InstallUtil. Táto poskytuje všetky svoje commandline parametre (formátu /kluc=hodnota) triede ProjectInstaller pomocou jej property Context.

Teda je možné, aby user mohol zadávať parametre ovplyvňujúce inštaláciu alebo odstraňovanie service-u. Pri tomto môže byť problémom používanie štandardnej .NET konfigurácie service-u, pretože aj napriek tomu, že je tento kód súčasťou Vášho projektu, v skutočnosti je spúšťaný z procesu utility InstallUtil. Hoci je aj tento problém relatívne jednoduché vyriešiť, vďaka mnou neskôr zvolenému postupu (inštalovanie priamo pomocou aplikácie service-u) je tento problém implicitne obídený.

Teda ako na to:
V ProjectInstalleri je potrebné reagovať na eventy BeforeInstall a BeforeUninstall. Pozor, naozaj musíte reagovať na eventy ProjectInstaller-a, tak isto nazvané eventy majú aj ServiceInstaller a ServiceProcessInstaller, ale jedine reakciou na eventy ProjectInstallera sa mi podarilo service-y správne nainštalovať aj odinštalovať.

Budeme predpokladať, že budeme mať dostupné správne parametre ServiceName a DisplayName (pomocou ProjectInstaller.Context.Parameters):

private void ProjectInstaller_BeforeInstall(object sender, InstallEventArgs e)
{
  if (!Context.Parameters.ContainsKey("ServiceName")
  || !Context.Parameters.ContainsKey("DisplayName"))
  {
    throw new ApplicationException("Required install parameters not present.");
  }
  serviceInstaller1.ServiceName = Context.Parameters["ServiceName"];
  serviceInstaller1.DisplayName = Context.Parameters["DisplayName"];
}
private void ProjectInstaller_BeforeUninstall(object sender, InstallEventArgs e)
{
  if (!Context.Parameters.ContainsKey("ServiceName"))
  {
    throw new ApplicationException("Required install parameters not present.");
  }
  serviceInstaller1.ServiceName = Context.Parameters["ServiceName"];
}

Táto jednoduchá úprava nám už umožnila nainštalovať jednu verziu service-u na počítač viac krát. Nainštalovať, resp odinštalovať môžeme service nasledovne:
InstallUtil serviceassembly.exe /ServiceName=BlaBla /DisplayName=BlaBlaBla
InstallUtil /u serviceassembly.exe /ServiceName=BlaBla

BTW pri odinštalúvaní service-u je jediným dôležitým parametrom ServiceName, takže nemusíme kontrolovať ani posúvať ďalej parameter DisplayName.

Toto riešenie ešte stále nespĺňa druhý bod mojich požiadaviek, teda že chcem, aby sa service fyzicky nachádzal na počítači iba raz. Aj keď teraz je možné service nainštalovať viackrát aj z toho istého miesta vo FS, spustené inštancie sa zatiaľ od seba vôbec neodlišujú. V praxi by sme chceli, aby tieto service-y mali odlišnú konfiguráciu. Toto môžeme docieliť podstrčením štartovacých parametrov. Budeme teda chcieť, aby rôzne inštancie service-u mali rôzne parametre, použité pri spúštaní procesu. Jediný spôsob, ktorý som našiel by sa dal nazvať commandline injectionom, ale funguje spoľahlivo.

V metóde ProjectInstaller_BefireInstall budeme očakávať ďaľší parameter – StartupParam.

private void ProjectInstaller_BeforeInstall(object sender, InstallEventArgs e)
{
  if (!Context.Parameters.ContainsKey("ServiceName")
  || !Context.Parameters.ContainsKey("DisplayName")
  || !Context.Parameters.ContainsKey("StartupParam"))
  {
    throw new ApplicationException("Required install parameters not present.");
  }
  serviceInstaller1.ServiceName = Context.Parameters["ServiceName"];
  serviceInstaller1.DisplayName = Context.Parameters["DisplayName"];
  string assemblyPathWParams = Context.Parameters["assemblypath"] + "\" \"" + Context.Parameters["StartupParam"];
  Context.Parameters["assemblypath"] = assemblyPathWParams;
}

Táto jednoduchá úprava nám zabezpečí, že nám hodnota parametra StartupParam príde pri štarte danej inštancie service-u ako štandardný commandline parameter. Na to je ale treba upraviť metódu Program.Main – aby sme sa k tomuto parametru dostali a ideálne aj konštruktor service-u, aby sme tento parameter mohli postúpiť ďalej dovnútra service-u.

static class Program
{
  static void Main(string[] args)
  {
    if (args.Length != 1)
    {
      throw new ApplicationException("No start-up parameter passed.");
    }
    ServiceBase[] ServicesToRun;
    ServicesToRun = new ServiceBase[]
    {
      new Service1(args[0])
    }
  }
}

Súčasný stav riešenia je teraz už plne funkčný, pre jednoduchú manipuláciu je ale vhodné zapracovať aj posledný bod mojich požiadaviek, teda manažment inštalovania, odinštalovania a spúšťania service-u. Tu použijem fintu a tento zapracujem priamo do programu samotného service-u. Ak sa normálne pokúsite spustiť exečko service-u, od windowsu dostanete vynadané, že ho nie je možné pustiť pod prihláseným používateľom, ale iba pomocou manažmentu service-ov (services.msc).

Pri inštalovaní alebo odinštalovaní ale service nechceme púšťať. Kód je lepší ako 1000 slôv a teda vám priamo ukážem riešenie:

static class Program
{
  static void Main(string[] args)
  {
    if (Environment.UserInteractive)
    {
      if (args.Length != 2)
      {
        Console.WriteLine(„ERROR: Incorrect number of input parameters passed.");
        printUsage();
        return;
      }
      switch (args[0])
      {
        case "install":
        case "uninstall":
        case "start":
        case "stop":
          break;
        default:
          Console.WriteLine("ERROR: unknown argument: " + args[0]);
          printUsage();
          return;
      }
      switch (args[0])
      {
        case "install":
        {
          TransactedInstaller ti = new TransactedInstaller();
          ti.Installers.Add(new ProjectInstaller());
          ti.Context = new InstallContext("", null);
          ti.Context.Parameters["assemblypath"] = Assembly.GetExecutingAssembly().Location;
          ti.Context.Parameters["ServiceName"] = getServiceName(args[1]);
          ti.ContextParameters["DisplayName"] = getDisplayName(args[1]);
          ti.Install(new Hashtable());
          return;
        }
        case "uninstall":
        {
          TransactedInstaller ti = new TransactedInstaller();
          ti.Installers.Add(new ProjectInstaller());
          ti.Context = new InstallContext("", null);
          ti.Context.Parameters["assemblypath"] = Assembly.GetExecutingAssembly().Location;
          ti.Context.Parameters["ServiceName"] = getServiceName(args[1]);
          ti.Uninstall(null);
          return;
        }
        case "start":
          break;
        case "stop":
          break;
      }
    }
    else
    {
      // normalne spustenie service-u (povodny obsah metody Program.Main)
    }
  }
}

V tomto kóde si môžete všimnúť, že rozlišujem, či service spustil používateľ cez príkazový riadok, alebo windows cez management service-ov. V druhom prípade chceme spustiť service, v prvom prípade chceme nainštalovať, odinštalovať, spustiť alebo stopnúť service. Predpokladám existenciu v článku nepopísaných metód printUsage (vypíše do konzoly spôsob používania tohoto managementu), getServiceName a getDisplayName (podľa druhého parametra odvodia ServiceName resp DisplayName pre práve inštalovanú inštanciu service-u).

Aby bolo možné zobrazovať používateľovi výstup konzoly, je ešte potrebné nastaviť property output type pre projekt na console application.

V praktickom nasadení je možné toto riešenie samozrejme ďalej rozšíriť, ja som napríklad zadefinoval vlastnú konfiguračnú sekciu, ktorá obsahovala nastavenia pre jednotlivé inštancie service-ov (ktoré mali unikátne kódy), pri inštalovaní, odinštalovaní, spúšťaní a zastavovaní som potom ešte kontroloval, či daná inštancia je zadefinovaná v konfiguračnom súbore, čím som výrazne zredukoval možnosti človekom spôsobených chýb pri inštalácii.

Kompletné zdrojové súbory a C# projekt môžete stiahnuť tu.

Dúfam, že tento článok Vám ušetrí námahu, čas a nervy pri riešení podobného problému, pred ktorým som sa ocitol ja.