حوصله ی خوندن نداری؟
اگه حوصله نداری پست رو کامل بخونی، چکیدش به این قراره: برای ایجاد یک وب سایت که بعضی صفحاتش React باشه و بعضی Razor چند تا مسئله رو باید در نظر گرفت. یکی اینکه خروجیِ build ِ پروژه ی React باید در اختیار ASP.NET Core قرار بگیره تا مثل فایل های static اون ها رو در اختیار client بذاره. نکته ی بعد هم مربوط می شه به پیکربندی routing. باید مسیرها رو طوری تنظیم کرد تا فایل های خروجیِ React طبق مسیرهای تعریف شده در اختیار client قرار بگیرن. هر دوی این ها در فایل Startup.cs مشخص می شن. مسیر خروجی React در مشخصه ی RootPath تعریف شده و URL ِ دسترسی به آن ها هم توسط متدِ ()Map مشخص می شه.
مقدمه
این روزا تعداد سایت هایی که در سمتِ front-end مبتنی بر Javascript نوشته می شه مثل قارچ زیاد شده. دلایل مختلفی داره. مثلاً اینکه توقع آدما از front-end بیش تر شده. خیلی از کارهایی که این روزا سمتِ front-end انجام می شه قبلاً اصلا انجام نمی شد، یا کاملاً سمتِ سرور انجام می شد. اما اگه پیچیدگی و نیازهای خاص رو بذاریم کنار بازم با کلی سایت طرفیم که این طوری ساخته شدن. خیلی از این سایت ها حتی نیاز چندانی هم به اینکه مثلا از React استفاده کنن ندارن. کلی وبلاگ یا سایت هایی با محتوای ساده می تونید پیدا کنید که اینجوری هستن. به نظرم این به خاطر علاقه ی برنامه نویساست. این تکنولوژی ها (React, Vue, Angular و باقی) از محبوبیت بالایی برخوردارن. محبوبیت زیاد هم باعث استفاده ی زیاد می شه.
اما گاهی هم پیش میاد که دوست داریم سمتِ front-end مثلاً با React ساخته بشه، اما امکانش نیست! مثل پروژه های قدیمی. مثل پروژه هایی که فقط قسمتی ازشون باید با Javascript پیاده بشه. در این پست یک مثال کوچیک از همچین حالتی رو اجرا کردم. البته انجام این کار اصلاً پیچیده نیست. اما شاید برای شروع یه مقدار گیج کننده باشه. حداقل برای من که این طور بود. در این پست یک مثال ساده رو با استفاده از React و ASP.NET Core MVC (یا همون Razor) انجام دادم.
راه حل
قبل از شروع به کد نوشتن یه کم توضیح درباره ی مشکل و راه حال های موجود بدم؛ برعکس صفحاتِ Razor که سرور اون ها رو هر وقت که درخواستی دریافت می کنه می سازه، صفحه/صفحاتِ React این جوری نیستن. در حقیقت محلِ render برای صفحاتِ ASP.NET Core سمتِ سروره و برای React سمتِ client. البته امکانِ render ِ صفحاتِ React در سمتِ سرور هم وجود داره اما خارج از حد این پسته. بنابراین برای حل این مسئله باید چند تا کار انجام بشه:
اجرای front-end چالش خاصی نداره. اما پیاده سازی قسمتِ ASP.NET Core یه کم غیر عادیه. چون به چند طریق می شه با فایل های build شده ی React برخورد کرد. اینجا راه حل هایی که به نظرم می رسه رو آوردم:
1- استفاده از middleware ِ StaticFiles و تلقی فایل های React مثل باقی فایل های static.
2- استفاده از middlewareهای SpaStaticFiles و Spa.
به گزینه ی اول ایراد خاصی وارد نیست اما موقع پیاده سازیش دچار مشکلی می شیم که حلش تقریبا کار سختیه. فرض کنید قراره تمام فایل های صفحه ی admin/ رو در مسیر wwwroot/admin کپی کنیم و بعدشم برنامه رو این طوری configure کنیم:
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExcepti(); } app.UseStaticFiles(); app.UseMvcWithDefaultRoute(); }
حالا هر وقت که کاربر توی مرورگرش آدرسِ admin/ رو وارد کنه سرور به طور پیش فرض فایلِ Index.html ِ موجود توی wwwroot/admin رو ارسال می کنه. تا اینجای کار به نظر می رسه که همه چیز مرتبه. اما موضوع اینجاست فایلِ Index.html تنها فایلی نیست که باید به مرورگرِ کاربر برسه! این فایل به فایل های دیگه ای ارجاع کرده که مرورگر بلافاصله اون ها رو تقاضا می کنه تا سرور براش ارسال کنه. به محتویاتِ فایلِ زیر و خطوطِ پررنگ شده دقت کنید:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Blog Admin</title> <link rel="stylesheet" href="/AdminClient.e31bb0bc.css"> </head> <body> <div id="root"></div> <script src="/AdminClient.e31bb0bc.js"> </body> </html>
نکته: احتمال داره نامِ فایل های ایجاد شده روی ماشین شما متفاوت باشه. پسوندِ عددیِ توی نام فایل ها رو Parcel برای CacheBusting می سازه.
وقتی مرورگر فایلِ Index.html رو دریافت می کنه طبق قاعده فایل های دیگه ای که برای اجرا به اون ها وابسته است رو هم از سرور درخواست می کنه. اما سرور نمی تونه پاسخی بده، چون اصلا فایلی با نامِ AdminClient.e31bb0bc.css در پوشه ی wwwroot وجود نداره! توجه کنید که تمام فایل ها (به خاطر ایجاد URL ِ admin/) در داخل پوشه ی admin قرار دارن! برای اینکه با این مشکل کنار بیایم باید bundler رو طوری تنظیم کنیم که به جای تولید URLهای root آن ها رو طور دیگه ای تولید کنه. مثلا این طوری:
<link rel="stylesheet" href="/admin/AdminClient.936ffee5.css" />
انجامِ این تنظیمات اولاً راحت نیست و دوماً با فرض اینکه انجام هم دادیم، با این کار تنظیماتِ مربوط به URLهای برنامه رو دو-دسته کردیم. یعنی هم در کدهای سمتِ سرور و هم در کدهای front-end. این کار احتمالا دردسرِ نگهداری ایجاد می کنه.
گزینه ی دوم گزینه ی بهتریه. در ASP.NET Core دو تا middleware تعبیه شده که این مشکلات رو به راحتی حل می کنن. یکی SpaStaticFiles و دیگری Spa. اولی مشکل فایل های ارجاعی رو حل می کنه. یعنی فایلی که با URL ِ root درخواست شده رو از پوشه ی مشخص شده از RootPath پیدا می کنه و اون رو برگشت می ده. دومی هم برای ارسال صفحه ی اصلی استفاده می شه.
public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddSpaStaticFiles(spa => spa.RootPath = "wwwroot/admin"); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExcepti(); } app.UseSpaStaticFiles(); app.UseMvcWithDefaultRoute(); app.Map("/admin", adminApp => { adminApp.UseSpa(spa => { }); }); }
از middleware ِ Spa اساساً برای ساخت برنامه های Single Page استفاده می شه. یعنی با صرف نظر از آدرسی که کاربر در مرورگرش وارد می کنه این middleware همواره صفحه ی Index.html رو برگشت می ده. به این ترتیب فقط یک کار می مونه که باید انجام داد: این middleware فقط زمانی کار کنه که URL برابر با admin/ باشه. برای این کار از ()Map استفاده می کنیم که توضیحش در ادامه اومده.
چه خواهیم ساخت؟
یک وبلاگ ساده که صفحه ی اصلیش و صفحه ی مربوط به پست هاش به صورت عادی از ASP.NET Core MVC (یا در واقع Razor) استفاده می کنه و صفحه ی adminاش با React ساخته شده. برای اجرای React از Parcel استفاده کردم. البته از ابزارهای دیگه هم می شه استفاده کرد. به خودتون بستگی داره. Parcel همیشه انتخابِ اولِ منه چون خیلی سریع و ساده راه اندازی می شه. شما می تونید از Webpack، Create-react-app، nano-react-app یا هر چیز دیگه ای استفاده کنید. محدودیتی وجود نداره. در تصویر زیر شِمایی از چینشِ ابزارها کنار هم نشون داده شده.
پیاده سازی
قبلِ شروع، از نصبِ اینها مطمئن باشید: net core. ورژنِ 2.0 یا بیش تر، VSCode، NodeJS و npm.
اول یک پوشه با نام blog-example بسازید. داخلش هم یک پوشه ی دیگه به نام src. از سمتِ سرور شروع می کنیم؛ یعنی ساختِ Viewها، Controllerها و API. در شاخه ی blog-example/src دستور زیر رو اجرا کنید:
dotnet new web --name Blog
این دستور یک پروژه ی ASP.NET Core ِ خام ایجاد می کنه. حالا شاخه ی src/Blog رو با VSCode باز کنید. اولین کار، تنظیم پیکربندیِ برنامه و مسیرهاست. فایل Startup.cs رو طبق کد زیر بنویسید:
// Startup.cs File using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; namespace Blog { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddSpaStaticFiles(spa => { spa.RootPath = "wwwroot/admin" }); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExcepti(); } app.UseSpaStaticFiles(); app.UseMvcWithDefaultRoute(); app.Map("/admin", adminApp => { adminApp.UseSpa(spa => { }); }); } } }
در متدِ ConfigureServices مشخصه ی RootPath تعین شده. این مشخصه محل فایل های خروجیِ React رو تعین می کنه. من محل این فایل ها رو جایی داخل پوشه ی wwwroot مشخص کردم تا برای publish ِ برنامه دچار دشواری نشم. اما به هر حال محل این فایل ها می تونه هر جایی باشه.
نکته: بهتره از هر نوع SCMی که استفاده می کنید، اون رو طوری تنظیم کنید که فایل های این پوشه هیچ وقت commit نشن.
همون طور که گفتم متدِ ()Map نقشِ مهمی داره. وظیفه ی این متد ایجادِ یک شاخه ی فرعی داخلِ pipeline است. یعنی اگر یک request از middleware ِ MVC گذشت و پردازش نشد، در این مرحله آدرسش بررسی می شه تا اگه URLاش admin/ بود به middleware ِ Spa فرستاده بشه. اگر هم URL برابر admin/ نبود طبق قاعده 404 بازگشت داده بشه. در حقیقت با ایجاد شاخه های فرعی requestها به صورت مشروط توسط middlewareها پردازش می شن.
نکته ی مهم اینه که بدون ایجاد شاخه ی فرعی هم می شه ()UseSpa رو نوشت، ولی اینجوری هر آدرسی که MVC نتونست شناسایی کنه باعث بازگشت صفحه ی admin/ می شه. بعید می دونم این حالت برای کسی مطلوب باشه، اما اگه این چیزیه که می خواید، نیازی به استفاده از ()Map ندارید...
در متدِ ()UseSpa می شه تنظیماتی رو اعمال کرد. مثل استفاده از proxy برای اتصال به سرورِ development، اما من تمایلی برای ساخت برنامه به این شکل ندارم. بهتره که روندِ ساخت front-end و back-end رو طوری طراحی کنید که موقع نوشتن شون نیازی به حضور همدیگه نداشته باشن. برای عملی کردن این ایده نیاز دارید که برای front-end تست های unit و integration بنویسید. در نهایت برای اینکه از عملکردِ front-end و back-end در کنار هم مطمئن باشید چند تا تست end-to-end هم بنویسید.
ساختِ Model
در پوشه ی Blog یک پوشه ی جدید با نام Model ایجاد کنید و فایل های زیر رو داخلش بنویسید.
فایل MemoryStore.cs:
// MemoryStore.cs File using System.Collections.Generic; using System.Linq; namespace Blog.Model { public static class MemoryStore { static MemoryStore() { Store = new List<Post>{ new Post { Id = 1, Title = "Being friend with Regular Expressions", Tags = ".net, C#, regex", Content = "<p>Regular expressions are used for string processing.</p>" }, new Post { Id = 2, Title = "Using React Hooks", Tags = "front-end, react, js", Content = "<p>You can use react hooks to get the most out of functions in react.</p>" } }; } public static readonly List<Post> Store; public static void Add(Post post) { post.Id = Store.Max(x => x.Id) + 1; Store.Add(post); } public static void Update(Post post) { var target = Store.Single(x => x.Id == post.Id); target.Title = post.Title; target.Tags = post.Tags; target.Content = post.Content; } public static void Remove(int id) { Store.RemoveAll(x => x.Id == id); } } }
فایل Post.cs:
// Post.cs File using System.Linq; using System.Collections.Generic; namespace Blog.Model { public class Post { public int Id { get; set; } public string Title { get; set; } public string Tags { get; set; } public string Content { get; set; } public IEnumerable<string> GetTags() => string.IsNullOrEmpty(Tags) ? Enumerable.Empty<string>() : Tags.Split(',').Select(x => x.Trim()); } }
برای ساده تر شدنِ کار، کلِ پست ها رو در یک لیستِ static ذخیره کردم. برای تمرین، خودتون این پروژه رو با EFCore پیاده کنید.
ساختِ Controllerها
یک پوشه ی دیگه به نام Controllers ایجاد کنید و فایل های زیر رو داخلش بنویسید:
// HomeController.cs File using Blog.Model; using Microsoft.AspNetCore.Mvc; using System.Linq; namespace Blog.Controllers { public class HomeController : Controller { public IActionResult Index() => View(MemoryStore.Store); public IActionResult Post(int id) => View(MemoryStore.Store.Single(x => x.Id == id)); } }
وظیفه ی این Controller ایجادِ صفحه ی اصلی و صفحه ی مربوط به نمایشِ پست هاست. کار خاصی نمی کنه فقط پست ها رو از MemoryStore می خونه و برای نمایش به View می ده.
// PostController.cs File using Blog.Model; using Microsoft.AspNetCore.Mvc; using System.Linq; namespace Blog.Controllers { [ApiController] [Route("/api/post")] public class PostController : ControllerBase { [HttpGet] public IActionResult GetAll() => Ok(MemoryStore.Store); [HttpGet("{id}")] public IActionResult Get(int id) => Ok(MemoryStore.Store.Single(x => x.Id == id)); [HttpPut] public IActionResult SaveChanges(Post post) { if (post.Id == 0) { MemoryStore.Add(post); return CreatedAtAction(nameof(Get), new { Id = post.Id }); } MemoryStore.Update(post); return Ok(); } } }
این Controller، API ِ مورد نیاز رو پیاده سازی می کنه. ساختارِ EndPoint به این شکله:
GET /api/post : لیستی از پست ها رو برگشت می ده.
GET /api/post/12 : پستی که شناسه اش 12 است رو بر می گردونه.
PUT /api/post : یک پست رو درج/ویرایش می کنه؛ اگه شناسه ی پست ارسالی 0 بود اون رو درج می کنه و شناسه ی جدید رو با کدِ 201 پاسخ می ده و اگه شناسه 0 نبود اون رو ویرایش می کنه و کدِ 200 رو پاسخ می ده.
ساختِ Viewها
پوشه ی Views/Home رو داخلِ Blog ایجاد کنید و بعد فایل های زیر رو بنویسید:
// Index.cshtml File @using Blog.Model @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @model IEnumerable<Post> <!DOCTYPE html> <html> <head> <title>My Blog</title> <style> section{ width: 600px; margin: 0 auto; } .blog-container{ display: block; background: #eee; padding: 10px 20px; font-size: 25px; margin-bottom: 2px; text-decoration: none; } span{ font-size: 15px; background: #111; color: white; padding: 2px 5px; } </style> </head> <body> <section> <h1>Welcome to my blog!</h1> @foreach(var post in Model) { <a asp-action="Post" asp-route-id="@post.Id" class="blog-container"> <div>@post.Title</div> @foreach(var tag in post.GetTags()) { <span>@tag</span> } </a> } </section> </body> </html>
وظیفه ی این فایل نمایشِ صفحه ی اصلیِ برنامه ست. کار سنگینی انجام نمی ده. فقط عنوان پست ها رو همراه با تگ هاشون لیست می کنه. یه کمی هم CSS داخلش نوشتم که کم تر زشت باشه!
// Post.cshtml File @using Blog.Model @model Post <!DOCTYPE html> <html> <head> <title>@Model.Title</title> <style> article{ margin: 20px; } </style> </head> <body> <article> <h1>@Model.Title</h1> @Html.Raw(Model.Content) </article> </body> </html>
این View وظیفه ی نمایشِ محتویاتِ یک پست رو داره. با دستور زیر برنامه رو اجرا کنید و نتیجه رو ببینید:
dotnet run
ساختِ صفحه ی Admin با React
داخل پوشه ی src/ یک پوشه ی دیگه با نام AdminClient ایجاد کنید. این پوشه رو با VSCode باز کنید و دستور زیر رو داخل Terminal اجرا کنید تا فایلِ package.json ایجاد بشه و کتابخونه های مورد نیازش دانلود بشه:
npm init -y npm install parcel-bundler --save-dev npm install react react-dom
حالا فایل های زیر رو بنویسید:
<!-- index.html File --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Blog Admin</title> </head> <body> <div id="root"></div> <script src="index.js"> </body> </html>
این فایل، فایلِ اصلیِ برنامه ست که توسط Parcel اجرا می شه. نتیجه ی build ِ این فایل توسط ASP.NET Core از آدرسِ admin/ بازگشت داده می شه. کار اصلیِ index.html ارجاع به فایلِ index.js است که به این شکله:
// index.js File import React from "react" import ReactDOM from "react-dom" import Post from "./main" ReactDOM.render(<Post />, document.getElementById("root"));
در این فایل component ِ main خونده و render می شه. این برنامه از سه component تشکیل شده:
editor: وظیفش ویرایش یا درج پست هاست.
post-list: وظیفش نمایش لیستی از پست هاست. کاربر می تونه بین پست ها پیمایش کنه و هر کدوم که نیاز به تغیر داشتن رو در component ِ editor ویرایش کنه.
main: وظیفش دریافتِ اطلاعات از API و مدیریت کردنِ دو component ِ دیگه ست. البته main به صورت مستقیم با APIها درگیر نیست بلکه از طریق services.js این کار رو انجام می ده.
در تصویر زیر شمایی از componentهای پروژه نمایش داده شده:
// editor.js File import React, { useRef } from "react" const Editor = ({ post, onSave, }) => { const titleRef = useRef(); const tagsRef = useRef(); const contentRef = useRef(); const save = e => { e.preventDefault(); onSave({ id: post.id, title: titleRef.current.value, tags: tagsRef.current.value, content: contentRef.current.value }); }; const cancel = e => { e.preventDefault(); (); }; return ( <div className="form-container"> <h1>Enter Post</h1> <form> <div className="control-group"> <label htmlFor="title">Title</label> <input id="title" defaultValue={post.title} ref={titleRef} placeholder="Enter the post title" autoFocus/> </div> <div className="control-group"> <label htmlFor="tags">Tags</label> <input id="tags" defaultValue={post.tags} ref={tagsRef} /> </div> <div className="control-group"> <label htmlFor="content">Content</label> <textarea id="content" defaultValue={post.content} ref={contentRef} /> </div> <button ={save}>Save</button> <button ={cancel}>Cancel</button> </form> </div> ); }; export default Editor;
فایل post-list.js:
// post-list.js File import React from "react" const toTags = tagString => tagString .split(",") .map(x => x.trim()) .map(x => <span key={x}>{x}</span>); const PostList = ({ posts, ed, onAddNew }) => ( <div className="item-container"> <h1>Posts</h1> <button ={onAddNew}>Add New Post</button> <ul>{posts.map(item => ( <li key={item.id} ={() => ed(item.id)}> <div>{item.title}</div> <div>{toTags(item.tags)}</div> </li> ))} </ul> </div> ); export default PostList;
فایل main.js:
// main.js File import React, { useEffect, useReducer } from "react" import Editor from "./editor" import PostList from "./post-list" import { initial, reducer } from "./state" import { getPostItems, saveChanges, newPost } from "./services" const Main = () => { const [state, dispatch] = useReducer(reducer, initial); useEffect(() => { (async () => dispatch({ type: "START", posts: await getPostItems() }))(); }, []); const showPost = async id => dispatch({ type: "SHOW_POST", post: state.posts.find(x => x.id === id) }); const addPost = () => dispatch({ type: "SHOW_POST", post: newPost() }); const saveEdit = async post => { const saveResult = await saveChanges(post); dispatch({ type: "SAVE_DONE", saveResult }); }; const cancelEdit = () => dispatch({ type: "CANCEL_EDIT" }); return state.page === "POST_LIST" ? ( <PostList posts={state.posts} ed={showPost} onAddNew={addPost} /> ) : state.page === "POST" ? ( <Editor post={state.currentPost} onSave={saveEdit} ={cancelEdit} /> ) : null; }; export default Main;
فایل services.js:
// services.js File const getPostItems = async () => { const response = await fetch("/api/post"); return await response.json(); }; const saveChanges = async post => { const response = await fetch("/api/post", { method: "put", body: JSON.stringify(post), headers: { "Content-Type": "application/json" } }); if (response.status === 201) { post.id = (await response.json()).id; return { type: "add", post }; } else if (response.status === 200) { return { type: "update", post }; } else { throw new Error(response.statusText); } }; const newPost = () => ({ id: 0, title: "", tags: "", content: "" }); export { getPostItems, saveChanges, newPost };
فایل state.js:
// state.js File const initial = { page: null, posts: [], currentPost: null }; const reducer = (state, action) => { switch (action.type) { case "START": return { ...state, page: "POST_LIST", posts: action.posts }; case "SHOW_POST": return { ...state, page: "POST", currentPost: action.post }; case "SAVE_DONE": return { ...state, page: "POST_LIST", posts: action.saveResult.type === "update" ? state.posts.map(p => p.id === action.saveResult.post.id ? action.saveResult.post : p) : [...state.posts, action.saveResult.post] }; case "CANCEL_EDIT": return { ...state, page: "POST_LIST", currentPost: null }; } }; export { initial, reducer };
در نهایت باید scriptها رو برای اجرا و build آماده کنیم. در فایلِ package.json تغیرات زیر رو اعمال کنید:
{ "name": "adminclient", "version": "1.0.0", "description": "the admin front-end", "main": "index.js", "scripts": { "start": "parcel index.html", "build": "parcel build index.html --out-dir ../Blog/wwwroot/admin" }, "browserslist": [ "chrome > 55" ], "license": "ISC", "devDependencies": { "parcel-bundler": "^1.12.3" }, "dependencies": { "react": "^16.8.6", "react-dom": "^16.8.6" } }
دو تا script ِ مهم در این فایل تعریف شده:
start: این script یک سرور برای محیط development اجرا می کنه.
build: با اجرای این script تمام فایل ها آماده برای deploy در پوشه ی wwwroot/admin ساخته می شن. یعنی همون محلی که مشخصه ی RootPath در فایلِ Startup.cs بهش اشاره می کرد.
قسمت browserslist هم یکی دیگه از تنظیماتِ مهمه. browserslist یک پکیجِ جداست که کارش ارائه ی لیستِ مرورگرها به همراهِ قابلیت هاییه که پشتیبانی می کنن. Parcel از این پکیج استفاده می کنه تا طبق مرورگرهایی که کاربر مشخص کرده build رو اجرا کنه. دلیل اینکه من مقدار chrome>55 رو تعین کردم استفاده از async/await در برنامه است. مرورگرِ Chrome از ورژنِ 55 به بعد به صورتِ مستقیم از async/await پشتیبانی می کنه و نیازی به ایجادِ Polyfill نداره. بنابراین اگر فایل های build شده ی Parcel رو باز کنید کلماتِ async و await رو خواهید دید.
کلام آخر
در هر پروژه باید طبق نیاز از ترکیبِ ابزارهای مختلف استفاده کرد تا کار به بهترین حالت اجرا بشه. در این پست یکی از این ترکیب ها رو بررسی کردم. امیدوارم به کارتون بیاد. تمام کدهای نوشته شده رو می تونید با کمی تغیر از github بگیرید.