در این مقاله سعی داریم یک نرم افزار بسیار ساده بر پایه معماری میکروسرویس با Asp.net Core و روی بستر docker آماده و راه اندازی کنیم. سطح مطالب این مقاله مناسب توسعه دهندگان بوده و هدف این است که محصول تولیدی آنها برای deploy در محیطهای container ای مناسب باشد.
مقدمه
گاهی با پروژه هایی سروکار داریم که از قسمت هایی با مرزهای حدودا مشخص تشکیل شده است. این مرزبندی ها را اغلب اوقات میتوان در فرایندهای سازمان مشتری مشاهده کرد. به بخش های مختلفی که توسط این مرزبندی ها تشکیل میشوند اصطلاحا Bounded Context یا BC میگویند. در معماری میکروسرویس معمولا به ازای یک یا چند BC (بر اساس اندازه یا اهمیت) یک میکروسرویس ایجاد میشود.
در این مقاله میخواهیم سیستم یک آموزشگاه را تعریف کرده و راه حلی (Solution) برای آن ایجاد کنیم. BC اصلی در این سیستم آموزشگاه (School) و BC دیگر مربوط به مالی یا درآمد (Income) میباشد. برای هرکدام از BC ها میکروسرویسی به همان نام وجود داشته و هر دو میکروسرویس به اطلاعات یکدیگر وابستگی دارند. در ابتدا این دو میکروسرویس را بصورت مستقل راه اندازی میکنیم و در مرحله بعدی میکروسرویسها را پشت یک دروازه (gateway) قرار میدهیم که توسط تنها یک آدرس قابل دسترس شوند.
پیشنیاز ها
برای شروع فرض بر این است که شما با برنامه نویسی در Asp.net Core، زبان C# و مفاهیم اولیه داکر آشنایی داشته و داکر را روی سیستم عامل خود یا یک ماشین دیگر نصب کرده باشید. درصورتی که با موارد یاد شده آشنایی ندارید این مقاله مناسب شما نخواهد بود و درصورتی که با مراحل نصب داکر آشنایی ندارید میتوانید ابتدا با جستجو در اینترنت نحوه نصب آن را فراگرفته و نصب کنید.
کدهای مقاله
تمامی کدهای نمونه این مقاله در آدرس زیر قرار دارد:
https://github.com/MostafaTech/MicroservicesOnDocker/tree/part1_exposed-services
تعریف ساختار پروژه در ویژوال استودیو
سیستم ما Solution ای با نام MicroservicesOnDocker و حاوی پروژه های زیر است:
در همین ابتدا CORS را در سرویسهای خود فعال کنید که درصورتی که از ajax برای ارسال درخواستها استفاده میکنید دچار مشکل نشوید. در کلاس Startup هر دو پروژه api:
public void ConfigureServices(IServiceCollection services) { ... services.AddCors(); ... }
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { ... app.UseCors(c => c.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin().Build()); ... }
در پروژه MicroservicesOnDocker.School.Api یک api برای خواندن لیست دانشجویان قرار دارد که بصورت زیر عمل میکند:
Request => [GET] http://localhost:57801/api/students Response => [{"id":1,"name":"Johnny Depp"},{"id":2,"name":"Brad Pitt"},{"id":3,"name":"Leonardo DiCaprio"},{"id":4,"name":"Michael Jackson"},{"id":5,"name":"Freddy Mercury"}]
و در پروژه MicroservicesOnDocker.Income.Api یک api برای خواندن لیست پرداختهای دانشجویان قرار دارد که بصورت زیر عمل میکند:
Request: [GET] http://localhost:57802/api/payments Response => [{"studentId":1,"studentName":"Johnny Depp","courseId":1,"courseName":"Acting Course","payAmount":2000.0},{"studentId":2,"studentName":"Brad Pitt","courseId":1,"courseName":"Acting Course","payAmount":2000.0},{"studentId":4,"studentName":"Michael Jackson","courseId":2,"courseName":"Music Course","payAmount":3000.0}]
تا اینجا میکروسرویسهای ما بصورت مستقل و کاملا جداگانه عمل میکنند. برای اینکه بتونیم این سرویسهارو روی داکر به اجرا در بیاریم از docker-compose استفاده میکنیم. به این صورت که در شاخه اصلی پروژه یک فایل با نام docker-compose.yml با محتویات زیر اضافه میکنیم:
version: '3' services: school_service: image: mcr.microsoft.com/dotnet/core/aspnet:2.2 volumes: - ./school_service:/app working_dir: /app command: dotnet "MicroservicesOnDocker.School.Api.dll" ports: - 5001:80 income_service: image: mcr.microsoft.com/dotnet/core/aspnet:2.2 volumes: - ./income_service:/app working_dir: /app command: dotnet "MicroservicesOnDocker.Income.Api.dll" ports: - 5001:80
نکاتی درباره محتویات فایل بالا:
یک پوشه با نام publish_ ایجاد کنید و از دو پروژه api خود پابلیش بگیرید و در مسیرهای زیر فایلهای پابلیش شده رو قرار بدید:
project MicroservicesOnDocker.School.Api => ..\_publish\school_service project MicroservicesOnDocker.Income.Api => ..\_publish\income_service
میتونید ویژوال استودیو رو طوری تنظیم کنید که موقع پابلیش در مسیرهای بالا فایلهارو پابلیش کنه. ضمنا تنظیمات پابلیش رو هم بصورت پیشفرض نگه دارید که بصورت زیر هستن:
Deployment Mode: Framework-Dependent Target Runtime: Portable
فایل docker-compose.yml را در شاخه اصلی پوشه publish_ کپی کنید و یک terminal یا cmd در پوشه مذکور باز کرده و با دستور زیر پروژه ها را راه اندازی کنید:
~ docker-compose up
نکته: اگر سیستم عامل شما ویندوز هست همین دستور اکتفا میکنه ولی اگر در لینوکس هستید کافیه قبل از دستور فوق sudo را نیز وارد کنید. اگر شما هم مثل من سیستم عامل ویندوز دارید ولی داکر رو روی ماشین مجازی لینوکسی نصب کردید میتونید با استفاده از پروتکل samba یک پوشه share ویندوزی روی سیستم عامل لینوکس خود راه بندازید که پابلیش هایی که میگیرید رو روی ماشین مجازی خود کپی کنید و در اون ماشین دستورات داکر رو اجرا کنید. فقط قبلش یادتون باشه ip ماشین رو بدونید که در browser بتونید سرویسهارو باز کنید.
حالا میتونید یک browser باز کرده و درخواست های زیر رو ارسال کنید:
[GET] http://localhost:5001/api/students [GET] http://localhost:5002/api/payments
و پروژه شما روی داکر جواب مناسب رو ارسال خواهد کرد. برای خاموش کردن سرویسها میتونید از Ctrl+C استفاده کنید.
ارتباط سرویسها با یکدیگر
یکی از مزیت های داکر این است که میتواند بین چند container یک شبکه مجازی ایجاد کرده که مجهز به DNS اختصاصی میباشد. در docker-compose تنظیمات این شبکه مجازی بطور اتوماتیک انجام میشود و در حالات ساده نیاز به هیچ تنظیم اضافه ای نداریم. روش کار به این صورت است که تمام سرویسهایی که در docker-compose تعریف میشوند مشابه ماشینهایی درون یک شبکه ایزوله قرار میگیرند که توسط نام هر سرویس توسط سرویسهای دیگر قابل دسترس میباشند. ارتباطات درونی کاربردهای زیادی از جمله ارتباط با سرویس database، سرویس cache، سرویس messaging و ... دارند. با استفاده از این امکان میخواهیم بین دو میکروسرویس خودمان ارتباطی ایجاد نماییم.
ابتدا به پروژه MicroservicesOnDocker.Infrastructure پکیج نوگت RestSharp را اضافه میکنیم. پکیج RestSharp این امکان را به ما میدهد که برای یک آدرس خاص درخواست http ارسال کنیم و جواب آن را بصورت serialize شده تحویل بگیریم. سپس کلاس زیر را به آن پروژه اضافه میکنیم:
public class ServiceHelpers { #if DEBUG public const string SchoolService = "localhost:57801" public const string IncomeService = "localhost:57802" #else public const string SchoolService = "school_service" public const string IncomeService = "income_service" #endif public static async Task<T> GetServiceData<T>(string service, string action) { var restClient = new RestSharp.RestClient($"http://{service}/api/"); var restRequest = new RestSharp.RestRequest(action, RestSharp.Method.GET, RestSharp.DataFormat.Json); var restResponse = await restClient.ExecuteAsync<T>(restRequest); if (restResponse.IsSuccessful) { return restResponse.Data; } return default(T); } }
در ابتدا دو constant برای هر سرویس تعریف کردیم که در حالت های Debug (یعنی موقعی که در ویژوال استودیو کلید F5 را میزنیم) و Release (یعنی وقتی پابلیش میگیریم) مقادیر متفاوت خواهند داشت. در حالت دیباگ پورت هایی که برای سرویسهایمان در زمان توسعه اختصاص داده ایم در ثابتها قرار خواهند گرفت و در حالت release به نامی که برای هر سرویس در docker-compose تعیین کرده ایم و پورت 80 آنها در ثابتها قرار میگیرند. اگر به متنی که در docker-compose نوشته بودیم توجه کنید میبینید که هر سرویس درواقع روی یک کانتینر با نام آن سرویس و پورت 80 بالا می آید و ما برای دسترسی بیرونی پورت 80 هر کانتینر را به پورت های 5001 و 5002 در ماشینی که داکر روی آن اجرا شده تبدیل کرده ایم. به این عمل expose کردن سرویس میگویند. ولی وقتی که ما از درون سرویسهای داکر قصد ارتباط با دیگر سرویسها را داشته باشیم به پورتهای بیرونی نیازی نداریم و میتوانیم با کانتینری که سرویس مورد نظر را اجرا کرده بصورت مستقیم ارتباط برقرار کنیم. حتی اگر آن سرویس expose نشده باشد.
حال میخواهیم سرویس school را به سرویس income متصل کنیم. برای همین api دریافت اطلاعات دانشجویان را به شکل زیر تغییر میدهیم:
[HttpGet] public asyncTask<ActionResult<IEnumerable<Dtos.StudentDto>>> Get() { var students = _ds.GetStudents(); var payments = awaitInfrastructure.ServiceHelpers.GetServiceData<List<Dtos.StudentPaymentDto>>(Infrastructure.ServiceHelpers.IncomeService, "payments"); return students.Select(x => new Dtos.StudentDto { StudentId = x.Id, StudentName = x.Name, hasPaid = payments.Any(y => y.StudentId == x.Id) }).ToList(); }
در خط زیر ما از طریق http به یک سرویس داخلی داکر متصل شدیم:
var payments = awaitInfrastructure.ServiceHelpers.GetServiceData<List<Dtos.StudentPaymentDto>>(Infrastructure.ServiceHelpers.IncomeService, "payments");
نکته: روشی که در این مثال برای پر کردن لیست دانشجویان استفاده شده از نظر پرفورمنس کاملا اشتباهه و در برنامه واقعی از این روش استفاده نکنید!
الان میتونیم دوباره از سرویسها پابلیش بگیریم و پروژه رو روی داکر اجرا کنیم. برای اجرا و ساختن مجدد image ها از دستور زیر استفاده کنید:
~ docker-compose up --build
نکته: درصورتی که میخاهید هنگام اجرای پروژه از ترمینال همچنان استفاده کنید در جلوی دستور بالا از سوییچ d- و برای خاموش کردن پروژه از docker-compose stop استفاده کنید.
تست api دانشجویان:
Request => [GET] http://localhost:5001/api/students Response => [{"id":1,"name":"Johnny Depp","hasPaid":true},{"id":2,"name":"Brad Pitt","hasPaid":true},{"id":3,"name":"Leonardo DiCaprio","hasPaid":false},{"id":4,"name":"Michael Jackson","hasPaid":true},{"id":5,"name":"Freddy Mercury","hasPaid":false}]
خسته نباشید!
در این قسمت از مقاله یاد گرفتیم که مجموعه ای از سرویسها که با asp.net core در محیط ویژوال استودیو را روی داکر اجرا کنیم و ارتباطی بین آنها برقرار کنیم. در قسمت بعدی میخواهیم تمام میکروسرویسها رو پشت یک gateway قرار دهیم و از expose کردن تک تک سرویسها جلوگیری کنیم.