ng new apollo-angular-project
ng add apollo-angular
const uri = 'https://localhost:5001/graphql';
npm install --save apollo-angular \ apollo-angular-link-http \ apollo-link \ apollo-client \ apollo-cache-inmemory \ graphql-tag \ graphql
{ "compilerOptions": { // ... "lib": [ "es2017", "dom", "esnext.asynciterable" ] } }
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { HttpClientModule } from "@angular/common/http"; import { ApolloModule, APOLLO_OPTIONS } from "apollo-angular"; import { HttpLinkModule, HttpLink } from "apollo-angular-link-http"; import { InMemoryCache } from "apollo-cache-inmemory"; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, HttpClientModule, ApolloModule, HttpLinkModule ], providers: [ { provide: APOLLO_OPTIONS, useFactory: (httpLink: HttpLink) => { return { cache: new InMemoryCache(), link: httpLink.create({ uri: "https://localhost:5001/graphql" }) } }, deps: [ HttpLink ] }], bootstrap: [ AppComponent ] }) export class AppModule { }
export type OwnerInputType = { name: string; address: string; }
export type AccountType = { 'id': string; 'description': string; 'ownerId' : string; 'type': string; }
import { AccountType } from './accountType'; export type OwnerType = { 'id': string; 'name': string; 'address': string; 'accounts': AccountType[]; }
ng g s graphql
import { Injectable } from '@angular/core'; import { Apollo } from 'apollo-angular'; import gql from 'graphql-tag'; @Injectable({ providedIn: 'root' }) export class GraphqlService { constructor(private apollo: Apollo) { } }
public getOwners = () => { return this.apollo.query({ query: gql`query getOwners{ owners{ id, name, address, accounts{ id, description, type } } }` }); }
export class AppComponent implements OnInit { public owners: OwnerType[]; public loading = true; constructor(private graphQLService: GraphqlService) { } ngOnInit() { this.graphQLService.getOwners().subscribe(result => { this.owners = result.data["owners"] as OwnerType[]; this.loading = result.loading; }); } }
<div> <div *ngIf="!this.loading"> <table> <thead> <tr> <th> # </th> <th> نام و نام خانوادگی </th> <th> آدرس </th> </tr> </thead> <tbody> <ng-container *ngFor="let item of this.owners;let idx=index" [ngTemplateOutlet]="innertable" [ngTemplateOutletContext]="{item:item, index:idx}"></ng-container> </tbody> </table> </div> <div *ngIf="this.loading"> <p> در حال بارگذاری لیست ... </p> </div> </div> <ng-template #innertable let-item="item" let-idx="index"> <tr> <td>{{idx+1}}</td> <td>{{item.name}}</td> <td>{{item.address}}</td> </tr> <tr *ngIf="this.item.accounts && this.item.accounts.length > 0"> <td colspan="4"> <div> <p>Accounts</p> </div> <div> <table> <thead> <tr> <th>#</th> <th>نوع</th> <th>توضیحات</th> </tr> </thead> <tbody> <tr *ngFor="let innerItem of this.item.accounts;let innerIndex=index"> <td> {{innerIndex+1}} </td> <td> {{innerItem.type}} </td> <td> {{innerItem.description}} </td> </tr> </tbody> </table> </div> </td> </tr> </ng-template>
dotnet restore dotnet run
ng serve
public getOwners = () => { return this.apollo.watchQuery<any>({ query: gql`query getOwners{ owners{ id, name, address, accounts{ id, description, type } } }` }) }
export class AppComponent implements OnInit { loading: boolean; public owners: OwnerType[]; private querySubscription: Subscription; constructor(private graphQLService: GraphqlService) { } ngOnInit() { this.querySubscription = this.graphQLService.getOwners() .valueChanges .subscribe(result => { this.loading = result.loading; this.owners = result.data["owners"] as OwnerType[]; }); } ngOnDestroy() { this.querySubscription.unsubscribe(); } }
public getOwner = (id) => { return this.apollo.query({ query: gql`query getOwner($ownerID: ID!){ owner(ownerId: $ownerID){ id, name, address, accounts{ id, description, type } } }`, variables: { ownerID: id } }) }
public createOwner = (ownerToCreate: OwnerInputType) => { return this.apollo.mutate({ mutation: gql`mutation($owner: ownerInput!){ createOwner(owner: $owner){ id, name, address } }`, variables: { owner: ownerToCreate } }) }
public updateOwner = (ownerToUpdate: OwnerInputType, id: string) => { return this.apollo.mutate({ mutation: gql`mutation($owner: ownerInput!, $ownerId: ID!){ updateOwner(owner: $owner, ownerId: $ownerId){ id, name, address } }`, variables: { owner: ownerToUpdate, ownerId: id } }) }
public deleteOwner = (id: string) => { return this.apollo.mutate({ mutation: gql`mutation($ownerId: ID!){ deleteOwner(ownerId: $ownerId) }`, variables: { ownerId: id } }) }
قسمتی از یک پروژه به همراه کلاس SqlHelper آن در کامنتهای مطلب «اهمیت Code review» توسط یکی از خوانندگان بلاگ جهت Code review مطرح شده که بهتر است در یک مطلب جدید و مجزا به آن پرداخته شود. قسمت مهم آن کلاس SqlHelper است و مابقی در اینجا ندید گرفته میشوند:
//It's only for code review purpose!
using System.Data;
using System.Data.SqlClient;
using System.Web.Configuration;
public sealed class SqlHelper
{
private SqlHelper() { }
// Send Connection String
//---------------------------------------------------------------------------------------
public static string GetCntString()
{
return WebConfigurationManager.ConnectionStrings["db_ConnectionString"].ConnectionString;
}
// Connect to Data Base SqlServer
//---------------------------------------------------------------------------------------
public static SqlConnection Connect2Db(ref SqlConnection sqlCnt, string cntString)
{
try
{
if (sqlCnt == null) sqlCnt = new SqlConnection();
sqlCnt.ConnectionString = cntString;
if (sqlCnt.State != ConnectionState.Open) sqlCnt.Open();
return sqlCnt;
}
catch (SqlException)
{
return null;
}
}
// Run ExecuteScalar Command
//---------------------------------------------------------------------------------------
public static string RunExecuteScalarCmd(ref SqlConnection sqlCnt, string strCmd, bool blnClose)
{
Connect2Db(ref sqlCnt, GetCntString());
using (sqlCnt)
{
using(SqlCommand sqlCmd = sqlCnt.CreateCommand())
{
sqlCmd.CommandText = strCmd;
object objResult = sqlCmd.ExecuteScalar();
if (blnClose) CloseCnt(ref sqlCnt, true);
return (objResult == null) ? string.Empty : objResult.ToString();
}
}
}
// Close SqlServer Connection
//---------------------------------------------------------------------------------------
public static bool CloseCnt(ref SqlConnection sqlCnt, bool nullSqlCnt)
{
try
{
if (sqlCnt == null) return true;
if (sqlCnt.State == ConnectionState.Open)
{
sqlCnt.Close();
sqlCnt.Dispose();
}
if (nullSqlCnt) sqlCnt = null;
return true;
}
catch (SqlException)
{
return false;
}
}
}
مثالی از نحوه استفاده ارائه شده:
protected void BtnTest_Click(object sender, EventArgs e)
{
SqlConnection sqlCnt = new SqlConnection();
string strQuery = "SELECT COUNT(UnitPrice) AS PriceCount FROM [Order Details]";
// در این مرحله پارامتر سوم یعنی کانکشن باز نگه داشته شود
string strResult = SqlHelper.RunExecuteScalarCmd(ref sqlCnt, strQuery, false);
strQuery = "SELECT LastName + N'-' + FirstName AS FullName FROM Employees WHERE (EmployeeID = 9)";
// در این مرحله پارامتر سوم یعنی کانکشن بسته شود
strResult = SqlHelper.RunExecuteScalarCmd(ref sqlCnt, strQuery, true);
}
مروری بر این کد:
1) نحوه کامنت نوشتن
بین سی شارپ و زبان سی++ تفاوت وجود دارد. این نحوه کامنت نویسی بیشتر در سی++ متداول است. اگر از ویژوال استودیو استفاده میکنید، مکان نما را به سطر قبل از یک متد منتقل کرده و سه بار پشت سر هم forward slash را تایپ کنید. به صورت خودکار ساختار خالی زیر تشکیل خواهد شد:
/// <summary>
///
/// </summary>
/// <param name="sqlCnt"></param>
/// <param name="cntString"></param>
/// <returns></returns>
public static SqlConnection Connect2Db(ref SqlConnection sqlCnt, string cntString)
این روش مرسوم کامنت نویسی کدهای سی شارپ است. خصوصا اینکه ابزارهایی وجود دارند که به صورت خودکار از این نوع کامنتها، فایل CHM درست میکنند.
2) وجود سازنده private
احتمالا هدف این بوده که نه شخصی و نه حتی کامپایلر، وهلهای از این کلاس را ایجاد نکند. بنابراین بهتر است کلاسی را که تمام متدهای آن static است (که به این هم خواهیم رسید!) ، راسا static معرفی کنید. به این ترتیب نیازی به سازنده private نخواهد بود.
3) وجود try/catch
یک اصل کلی وجود دارد: اگر در حال طراحی یک کتابخانه پایهای هستید، try/catch را در هیچ متدی از آن لحاظ نکنید. بله؛ درست خوندید! لطفا try/catch ننویسید! کرش کردن برنامه خوب است! لایههای بالاتر برنامه که در حال استفاده از کدهای شما هستند متوجه خواهند شد که مشکلی رخ داده و این مشکل توسط کتابخانه مورد استفاده «خفه» نشده. برای مثال اگر هم اکنون SQL Server در دسترس نیست، لایههای بالاتر برنامه باید این مشکل را متوجه شوند. Exception اصلا چیز بدی نیست! کرش برنامه اصلا بد نیست!
فرض کنید که دچار بیماری شدهاید. اگر مثلا تبی رخ ندهد، از کجا باید متوجه شد که نیاز به مراقبت پزشکی وجود دارد؟ اگر هیچ علامتی بروز داده نشود که تا الان نسل بشر منقرض شده بود!
4) وجود ref و out
دوستان گرامی! این ref و out فقط جهت سازگاری با زبان C در سی شارپ وجود دارد. لطفا تا حد ممکن از آن استفاده نکنید! مثلا استفاده از توابع API ویندوز که با C نوشته شدهاند.
یکی از مهمترین کاربردهای pointers در زبان سی، دریافت بیش از یک خروجی از یک تابع است. برای مثال یک متد API ویندوز را فراخوانی میکنید؛ خروجی آن یک ساختار است که به کمک pointers به عنوان یکی از پارامترهای همان متد معرفی شده. این روش به وفور در طراحی ویندوز بکار رفته. ولی خوب در سی شارپ که از این نوع مشکلات وجود ندارد. یک کلاس ساده را طراحی کنید که چندین خاصیت دارد. هر کدام از این خاصیتها میتوانند نمایانگر یک خروجی باشند. خروجی متد را از نوع این کلاس تعریف کنید. یا برای مثال در دات نت 4، امکان دیگری به نام Tuples معرفی شده برای کسانی که سریع میخواهند چند خروجی از یک تابع دریافت کنند و نمیخواهند برای اینکار یک کلاس بنویسند.
ضمن اینکه برای مثال در متد Connect2Db، هم کانکشن یکبار به صورت ref معرفی شده و یکبار به صورت خروجی متد. اصلا نیازی به استفاده از ref در اینجا نبوده. حتی نیازی به خروجی کانکشن هم در این متد وجود نداشته. کلیه تغییرات شما در شیء کانکشنی که به عنوان پارامتر ارسال شده، در خارج از آن متد هم منعکس میشود (شبیه به همان بحث pointers در زبان سی). بنابراین وجود ref غیرضروری است؛ وجود خروجی متد هم به همین صورت.
5) استفاده از using در متد RunExecuteScalarCmd
استفاده از using خیلی خوب است؛ همیشه اینکار را انجام دهید!
اما اگر اینکار را انجام دادید، بدانید که شیء sqlCnt در پایان بدنه using ، توسط GC نابوده شده است. بنابراین اینجا bool blnClose دیگر چه کاربردی دارد؟! تصمیم شما دیگر اهمیتی نخواهد داشت؛ چون کار تخریبی پیشتر انجام شده.
6) متد CloseCnt
این متد زاید است؛ به دلیلی که در قسمت (5) عنوان شد. using های استفاده شده، کار را تمام کردهاند. بنابراین بستن اشیاء dispose شده معنا نخواهد داشت.
7) در مورد نحوه استفاده
اگر SqlHelper را در اینجا مثلا یک DAL ساده فرض کنیم (data access layer)، جای قسمت BLL (business logic layer) در اینجا خالی است. عموما هم چون توضیحات این موارد را خیلی بد ارائه دادهاند، افراد از شنیدن اسم آنها هم وحشت میکنند. BLL یعنی کمی دست به Refactoring بزنید و این پیاده سازی منطق تجاری ارائه شده در متد BtnTest_Click را به یک کلاس مجزا خارج از code behind پروژه منتقل کنید. Code behind فقط محل استفاده نهایی از آن باشد. همین! فعلا با همین مختصر شروع کنید.
مورد دیگری که در اینجا باز هم مشهود است، عدم استفاده از پارامتر در کوئریها است. چون از پارامتر استفاده نکردهاید، SQL Server مجبور است برای حالت EmployeeID = 9 یکبار execution plan را محاسبه کند، برای کوئری بعدی مثلا EmployeeID = 19، اینکار را تکرار کند و الی آخر. این یعنی مصرف حافظه بالا و همچنین سرعت پایین انجام کوئریها. بنابراین اینقدر در قید و بند باز نگه داشتن یک کانکشن نباشید؛ مشکل اصلی جای دیگری است!
8) برنامه وب و اطلاعات استاتیک!
این پروژه، یک پروژه ASP.NET است. دیدن تعاریف استاتیک در این نوع پروژهها یک علامت خطر است! در این مورد قبلا مطلب نوشتم:
متغیرهای استاتیک و برنامههای ASP.NET
یک درخواست عمومی!
لطف کنید در پروژهای «جدید» خودتون این نوع کلاسهای SqlHelper رو «دور بریزید». یاد گرفتن کار با یک ORM جدید اصلا سخت نیست. مثلا طراحی Entity framework مایکروسافت به حدی ساده است که هر شخصی با داشتن بهره هوشی در حد یک عنکبوت آبی یا حتی جلبک دریایی هم میتونه با اون کار کنه! فقط NHibernate هست که کمی مرد افکن است و گرنه مابقی به عمد ساده طراحی شدهاند.
مزایای کار کردن با ORM ها این است:
- کوئریهای حاصل از آنها «پارامتری» است؛ که این دو مزیت عمده را به همراه دارد:
امنیت: مقاومت در برابر SQL Injection
سرعت و همچنین مصرف حافظه کمتر: با کوئریهای پارامتری در SQL Server همانند رویههای ذخیره شده رفتار میشود.
- عدم نیاز به نوشتن DAL شخصی پر از باگ. چون ORM یعنی همان DAL که توسط یک سری حرفهای طراحی شده.
- یک دست شدن کدها در یک تیم. چون همه بر اساس یک اینترفیس مشخص کار خواهند کرد.
- امکان استفاده از امکانات جدید زبانهای دات نتی مانند LINQ و نوشتن کوئریهای strongly typed تحت کنترل کامپایلر.
- پایین آوردن هزینههای آموزشی افراد در یک تیم. مثلا EF را میشود به عنوان یک پیشنیاز در نظر گرفت؛ عمومی است و همه گیر. کسی هم از شنیدن نام آن تعجب نخواهد کرد. کتاب(های) آموزشی هم در مورد آن زیاد هست.
و ...
کدام نگارشهای NET Core. بر روی سیستم شما نصب هستند؟
پیش از انجام هرکاری نیاز است بررسی کنیم کدامیک از بستههای ارائه شده، بر روی سیستم جاری نصب هستند. برای انجام اینکار دستور زیر را در خط فرمان صادر کنید:
dotnet --info
Runtime تنها ویژگیهای اساسی جهت اجرای برنامههای از پیش کامپایل شدهی NET Core. را با اجرای فرمانی مانند dotnet mydll.dll و یا اجرای دستور dotnet --info برای دریافت اطلاعاتی از جزئیات این ویژگیها، به همراه دارد. اما برای کار با سورس کدها، build، publish و هر کار دیگری با آنها، حتما باید SDK نیز نصب شود.
خروجی فرمان فوق بر روی سیستم من چنین چیزی است:
C:\Users\Vahid>dotnet --info .NET Core SDK (reflecting any global.json): Version: 2.1.301 Commit: 59524873d6 Runtime Environment: OS Name: Windows OS Version: 10.0.17134 OS Platform: Windows RID: win10-x64 Base Path: C:\Program Files\dotnet\sdk\2.1.301\ Host (useful for support): Version: 2.1.1 Commit: 6985b9f684 .NET Core SDKs installed: 2.1.300 [C:\Program Files\dotnet\sdk] 2.1.301 [C:\Program Files\dotnet\sdk] .NET Core runtimes installed: Microsoft.NETCore.App 2.1.0 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.1 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] To install additional .NET Core runtimes or SDKs: https://aka.ms/dotnet-download
باید دقت داشت که بر روی یک سیستم میتوان چندین SDK و چندین Runtime مختلف را نصب کرد و هر پروژه از شماره نگارش خاصی استفاده کند. شماره نگارش runtime استفاده شدهی در پروژهها در فایل csproj، توسط مدخل زیر مشخص میشود:
<TargetFramework>netcoreapp2.1</TargetFramework>
{ "sdk": { "version": "2.1.300-rc.31211" } }
البته در اکثر موارد نیازی به انجام این کار نیست؛ چون SDK، با تمام نگارشهای قبلی سازگار است و همواره استفادهی از آخرین SDK نصب شده توصیه میشود. به همین جهت فایل global.json را پس از ایجاد یک solution جدید مشاهده نمیکنید؛ مگر اینکه خودتان به دلایل خاصی آنرا اضافه و مقید نمائید.
تفاوت بستههای مختلف قابل دریافت NET Core. در چیست؟
زمانیکه برای دریافت آخرین نگارش NET Core. به سایت آن مراجعه میکنیم، به ازای هر نگارش، یک چنین لیستی قابل مشاهده است:
• .NET Core Runtime • .NET Core SDK • .NET Core Hosting Bundle • Visual Studio • ASP.NET Core Installer
Visual Studio
اگر کاربر ویندوز هستید، با نصب آخرین نگارش Visual Studio، میتوانید به همراه آن، آخرین نگارش SDK ،runtime و اجزای هاست برنامههای ASP.NET Core بر روی IIS را نیز بر روی سیستم خود نصب کنید.
NET Core SDK.
هدف از ارائهی بستهی SDK، انجام فرآیندهای build، اجرا و مدیریت امور مرتبط با NET Core.، بدون استفاده از Visual Studio و بر روی تمام سیستم عاملهای پشتیبانی شدهاست. زمانیکه یک بستهی SDK را نصب میکنید، به همراه آن این موارد نیز نصب میشوند:
• .NET Core SDK • .NET Core Runtime • ASP.NET Core Runtime
بنابراین دلیل نصب آن میتواند شامل یکی از موارد زیر باشد:
- بر روی سیستمی که در حال توسعهی برنامههای مبتنی بر NET Core. هستید. این تمام چیزی است که به آن نیاز دارید.
- بر روی سروری که نیاز است دستور dotnet را برای انجام فرآیندهای build/publish اجرا کند.
NET Core Runtime.
بستههای Runtimes، کوچکترین بستهی ممکن در این لیست هستند و هدف از آنها صرفا اجرای برنامههای کامپایل شدهی NET Core. در سکوهای کاری مختلف پشتیبانی شدهی توسط آن است.
باید دقت داشت که اگر برنامهی شما از «ASP.NET Core meta package» استفاده میکند، این بسته در runtime لحاظ نشدهاست و در یک چنین حالتی باید بستهی ASP.NET Core را به صورت جداگانه دریافت و نصب کنید. هرچند اگر از این متاپکیجها استفاده نکنید و بستههای مورد نیاز را به صورت مستقیم به برنامهی خود اضافه کنید، این بستهها جزئی از فایلهای publish نهایی بوده و در این حالت برنامه توسط بستهی runtime نیز قابل اجرا است.
در این حالت برنامهی dotnet بجز اجرای برنامهها و ارائهی اطلاعاتی در مورد خود آن، کارهای دیگری را مانند build و یا publish، نمیتواند انجام دهد و برنامه در این حالت باید کاملا از پیش کامپایل شده باشد.
بنابراین دلیل نصب آن میتواند شامل یکی از موارد زیر باشد:
- برای اجرای برنامههای از پیش کامپایل شدهای که به همراه تمام وابستگیهای مورد نیاز هم هستند.
- برای اجرای برنامههای وبی که از ASP.NET Meta packages استفاده نمیکنند
ASP.NET Core Installer
همانطور که در توضیحات بستهی runtime عنوان شد، این بسته، متاپکیجهای ASP.NET Core را به همراه ندارد. اگر به آنها نیاز دارید، باید آنها را به صورت جداگانه توسط ASP.NET Core installer نصب کنید که شامل این موارد است:
- The ASP.NET Runtime Meta Packages - Microsoft.AspNetCore.App - Microsoft.AspNetCore.All
نصب این بسته برای هاست برنامههای ASP.NET Core در ویندوز و بر روی IIS ضروری است و شامل این اجزا میشود:
- 32 bit and 64 .NET Core Runtimes - ASP.NET Runtime Packages (Microsoft.AspNetCode.App/All) - IIS Hosting Components
بنابراین به صورت خلاصه
برای سرورها این موارد را نصب کنید:
- در ویندوز: Windows Server Hosting Bundle
- برای Mac و لینوکس: .NET Core Runtime + ASP.NET Core Runtimes
برای سیستم توسعهی شخصی این موارد را نصب کنید:
- SDK
- اگر از ویندوز استفاده میکنید: Visual Studio هم به همراه SDK نصب میشود.
برای اجرای برنامههای از پیش کامپایل شده که به همراه تمام وابستگیهای مورد نیاز هم هستند:
- تنها Runtime را نصب کنید.
اگر این برنامهی از پیش کامپایل شده از ASP.NET Runtime Meta packages استفاده میکند:
- ASP.NET Runtimes را نیز نصب کنید.
الف) Policies
ب) Role Claims
سیاستهای دسترسی یا Policies در ASP.NET Core Identity
ASP.NET Core Identity هنوز هم از مفهوم Roles پشتیبانی میکند. برای مثال میتوان مشخص کرد که اکشن متدی و یا تمام اکشن متدهای یک کنترلر تنها توسط کاربران دارای نقش Admin قابل دسترسی باشند. اما نقشها نیز در این سیستم جدید تنها نوعی از سیاستهای دسترسی هستند.
[Authorize(Roles = ConstantRoles.Admin)] public class RolesManagerController : Controller
اما نقشهای ثابت، بسیار محدود و غیر قابل انعطاف هستند. برای رفع این مشکل مفهوم جدیدی را به نام Policy اضافه کردهاند.
[Authorize(Policy="RequireAdministratorRole")] public IActionResult Get() { /* .. */ }
برای مثال اگر بخواهیم تک نقش Admin را به صورت یک سیاست دسترسی جدید تعریف کنیم، روش کار به صورت ذیل خواهد بود:
public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddAuthorization(options => { options.AddPolicy("RequireAdministratorRole", policy => policy.RequireRole("Admin")); }); }
و یا بجای اینکه چند نقش مجاز به دسترسی منبعی را با کاما از هم جدا کنیم:
[Authorize(Roles = "Administrator, PowerUser, BackupAdministrator")]
options.AddPolicy("ElevatedRights", policy => policy.RequireRole("Administrator", "PowerUser", "BackupAdministrator"));
[Authorize(Policy = "ElevatedRights")] public IActionResult Shutdown() { return View(); }
سیاستهای دسترسی تنها به نقشها محدود نیستند:
services.AddAuthorization(options => { options.AddPolicy("EmployeeOnly", policy => policy.RequireClaim("EmployeeNumber")); });
[Authorize(Policy = "EmployeeOnly")] public IActionResult VacationBalance() { return View(); }
سیاستهای دسترسی پویا در ASP.NET Core Identity
مهمترین مزیت کار با سیاستهای دسترسی، امکان سفارشی سازی و تهیهی نمونههای پویای آنها هستند؛ موردی که با نقشهای ثابت سیستم قابل پیاده سازی نبوده و در نگارشهای قبلی، جهت پویا سازی آن، یکی از روشهای بسیار متداول، تهیهی فیلتر Authorize سفارشی سازی شده بود. اما در اینجا دیگر نیازی نیست تا فیلتر Authorize را سفارشی سازی کنیم. با پیاده سازی یک AuthorizationHandler جدید و معرفی آن به سیستم، پردازش سیاستهای دسترسی پویای به منابع، فعال میشود.
پیاده سازی سیاستهای پویای دسترسی شامل مراحل ذیل است:
1- تعریف یک نیازمندی دسترسی جدید
public class DynamicPermissionRequirement : IAuthorizationRequirement { }
2- پیاده سازی یک AuthorizationHandler استفاده کنندهی از نیازمندی دسترسی تعریف شده
پس از اینکه نیازمندی DynamicPermissionRequirement را تعریف کردیم، در ادامه باید یک AuthorizationHandler استفاده کنندهی از آن را تعریف کنیم:
public class DynamicPermissionsAuthorizationHandler : AuthorizationHandler<DynamicPermissionRequirement> { private readonly ISecurityTrimmingService _securityTrimmingService; public DynamicPermissionsAuthorizationHandler(ISecurityTrimmingService securityTrimmingService) { _securityTrimmingService = securityTrimmingService; _securityTrimmingService.CheckArgumentIsNull(nameof(_securityTrimmingService)); } protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, DynamicPermissionRequirement requirement) { var mvcContext = context.Resource as AuthorizationFilterContext; if (mvcContext == null) { return Task.CompletedTask; } var actionDescriptor = mvcContext.ActionDescriptor; var area = actionDescriptor.RouteValues["area"]; var controller = actionDescriptor.RouteValues["controller"]; var action = actionDescriptor.RouteValues["action"]; if(_securityTrimmingService.CanCurrentUserAccess(area, controller, action)) { context.Succeed(requirement); } else { context.Fail(); } return Task.CompletedTask; } }
در کلاس تهیه شده باید متد HandleRequirementAsync آنرا بازنویسی کرد و اگر در این بین، منطق سفارشی ما context.Succeed را فراخوانی کند، به معنای برآورده شدن سیاست دسترسی بوده و کاربر جاری میتواند به منبع درخواستی، بلافاصله دسترسی یابد و اگر context.Fail فراخوانی شود، در همینجا دسترسی کاربر قطع شده و HTTP status code مساوی 401 (عدم دسترسی) را دریافت میکند.
منطق سفارشی پیاده سازی شده نیز به این صورت است:
نام ناحیه، کنترلر و اکشن متد درخواستی کاربر از مسیریابی جاری استخراج میشوند. سپس توسط سرویس سفارشی ISecurityTrimmingService تهیه شده، بررسی میکنیم که آیا کاربر جاری به این سه مؤلفه دسترسی دارد یا خیر؟
3- معرفی سیاست دسترسی پویای تهیه شده به سیستم
معرفی سیاست کاری پویا و سفارشی تهیه شده، شامل دو مرحلهی زیر است:
private static void addDynamicPermissionsPolicy(this IServiceCollection services) { services.AddScoped<IAuthorizationHandler, DynamicPermissionsAuthorizationHandler>(); services.AddAuthorization(opts => { opts.AddPolicy( name: ConstantPolicies.DynamicPermission, configurePolicy: policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new DynamicPermissionRequirement()); }); }); }
سپس یک Policy جدید را با نام دلخواه DynamicPermission تعریف کرده و نیازمندی علامتگذار خود را به عنوان یک policy.Requirements جدید، اضافه میکنیم. همانطور که ملاحظه میکنید یک وهلهی جدید از DynamicPermissionRequirement در اینجا ثبت شدهاست. همین وهله به متد HandleRequirementAsync نیز ارسال میشود. بنابراین اگر نیاز به ارسال پارامترهای بیشتری به این متد وجود داشت، میتوان خواص مرتبطی را به کلاس DynamicPermissionRequirement نیز اضافه کرد.
همانطور که مشخص است، در اینجا یک نیازمندی را میتوان ثبت کرد و نه Handler آنرا. این Handler از سیستم تزریق وابستگیها، بر اساس آرگومان جنریک AuthorizationHandler پیاده سازی شده، به صورت خودکار یافت شده و اجرا میشود (بنابراین اگر Handler شما اجرا نشد، مطمئن شوید که حتما آنرا به سیستم تزریق وابستگیها معرفی کردهاید).
پس از آن هر کنترلر یا اکشن متدی که از این سیاست دسترسی پویای تهیه شده استفاده کند:
[Authorize(Policy = ConstantPolicies.DynamicPermission)] [DisplayName("کنترلر نمونه با سطح دسترسی پویا")] public class DynamicPermissionsSampleController : Controller
سرویس ISecurityTrimmingService چگونه کار میکند؟
کدهای کامل ISecurityTrimmingService را در کلاس SecurityTrimmingService میتوانید مشاهده کنید.
پیشنیاز درک عملکرد آن، آشنایی با دو قابلیت زیر هستند:
الف) «روش یافتن لیست تمام کنترلرها و اکشن متدهای یک برنامهی ASP.NET Core»
دقیقا از همین سرویس توسعه داده شدهی در مطلب فوق، در اینجا نیز استفاده شدهاست؛ با یک تفاوت تکمیلی:
public interface IMvcActionsDiscoveryService { ICollection<MvcControllerViewModel> MvcControllers { get; } ICollection<MvcControllerViewModel> GetAllSecuredControllerActionsWithPolicy(string policyName); }
بنابراین همینقدر که تعریف ذیل یافت شود، این اکشن متد نیز در صفحهی مدیریت سطوح دسترسی پویا لیست خواهد شد.
[Authorize(Policy = ConstantPolicies.DynamicPermission)]
ابتدا به مدیریت نقشهای ثابت سیستم میرسیم. سپس به هر نقش میتوان یک Claim جدید را با مقدار area:controller:action انتساب داد.
به این ترتیب میتوان به یک نقش، تعدادی اکشن متد را نسبت داد و سطوح دسترسی به آنها را پویا کرد. اما ذخیره سازی آنها چگونه است و چگونه میتوان به اطلاعات نهایی ذخیره شده دسترسی پیدا کرد؟
مفهوم جدید Role Claims در ASP.NET Core Identity
تا اینجا موفق شدیم تمام اکشن متدهای دارای سیاست دسترسی سفارشی سازی شدهی خود را لیست کنیم، تا بتوان آنها را به صورت دلخواهی انتخاب کرد و سطوح دسترسی به آنها را به صورت پویا تغییر داد. اما این اکشن متدهای انتخاب شده را در کجا و به چه صورتی ذخیره کنیم؟
برای ذخیره سازی این اطلاعات نیازی نیست تا جدول جدیدی را به سیستم اضافه کنیم. جدول جدید AppRoleClaims به همین منظور تدارک دیده شدهاست.
وقتی کاربری عضو یک نقش است، به صورت خودکار Role Claims آن نقش را نیز به ارث میبرد. هدف از نقشها، گروه بندی کاربران است. توسط Role Claims میتوان مشخص کرد این نقشها چه کارهایی را میتوانند انجام دهند. اگر از قسمت قبل بخاطر داشته باشید، سرویس توکار UserClaimsPrincipalFactory دارای مرحلهی 5 ذیل است:
«5) اگر یک نقش منتسب به کاربر دارای Role Claim باشد، این موارد نیز واکشی شده و به کوکی او به عنوان یک Claim جدید اضافه میشوند. در ASP.NET Identity Core نقشها نیز میتوانند Claim داشته باشند (امکان پیاده سازی سطوح دسترسی پویا).»
به این معنا که با لاگین شخص به سیستم، تمام اطلاعات مرتبط به او که در جدول AppRoleClaims وجود دارند، به کوکی او به صورت خودکار اضافه خواهند شد و دسترسی به آنها فوق العاده سریع است.
در کنترلر DynamicRoleClaimsManagerController، یک Role Claim Type جدید به نام DynamicPermissionClaimType اضافه شدهاست و سپس ID اکشن متدهای انتخابی را به نقش جاری، تحت Claim Type عنوان شده، اضافه میکند (تصویر فوق). این ID به صورت area:controller:action طراحی شدهاست. به همین جهت است که در DynamicPermissionsAuthorizationHandler همین سه جزء از سیستم مسیریابی استخراج و در سرویس SecurityTrimmingService مورد بررسی قرار میگیرد:
return user.HasClaim(claim => claim.Type == ConstantPolicies.DynamicPermissionClaimType && claim.Value == currentClaimValue);
متد HasClaim هیچگونه رفت و برگشتی را به بانک اطلاعاتی ندارد و اطلاعات خود را از کوکی شخص دریافت میکند. متد user.IsInRole نیز به همین نحو عمل میکند.
Tag Helper جدید SecurityTrimming
اکنون که سرویس ISecurityTrimmingService را پیاده سازی کردهایم، از آن میتوان جهت توسعهی SecurityTrimmingTagHelper نیز استفاده کرد:
public override void Process(TagHelperContext context, TagHelperOutput output) { context.CheckArgumentIsNull(nameof(context)); output.CheckArgumentIsNull(nameof(output)); // don't render the <security-trimming> tag. output.TagName = null; if(_securityTrimmingService.CanCurrentUserAccess(Area, Controller, Action)) { // fine, do nothing. return; } // else, suppress the output and generate nothing. output.SuppressOutput(); }
نمونهای از کاربرد آنرا در ReportsMenu.cshtml_ میتوانید مشاهده کنید:
<security-trimming asp-area="" asp-controller="DynamicPermissionsTest" asp-action="Products"> <li> <a asp-controller="DynamicPermissionsTest" asp-action="Products" asp-area=""> <span class="left5 fa fa-user" aria-hidden="true"></span> گزارش از لیست محصولات </a> </li> </security-trimming>
برای آزمایش آن یک کاربر جدید را به سیستم DNT Identity اضافه کنید. سپس آنرا در گروه نقشی مشخص قرار دهید (منوی مدیریتی،گزینهی مدیریت نقشهای سیستم). سپس به این گروه دسترسی به تعدادی از آیتمهای پویا را بدهید (گزینهی مشاهده و تغییر لیست دسترسیهای پویا). سپس با این اکانت جدید به سیستم وارد شده و بررسی کنید که چه تعدادی از آیتمهای منوی «گزارشات نمونه» را میتوانید مشاهده کنید (تامین شدهی توسط ReportsMenu.cshtml_).
مدیریت اندازهی حجم کوکیهای ASP.NET Core Identity
همانطور که ملاحظه کردید، جهت بالابردن سرعت دسترسی به اطلاعات User Claims و Role Claims، تمام اطلاعات مرتبط با آنها، به کوکی کاربر وارد شدهی به سیستم، اضافه میشوند. همین مساله در یک سیستم بزرگ با تعداد صفحات بالا، سبب خواهد شد تا حجم کوکی کاربر از 5 کیلوبایت بیشتر شده و توسط مرورگرها مورد قبول واقع نشوند و عملا سیستم از کار خواهد افتاد.
برای مدیریت یک چنین مسالهای، امکان ذخیره سازی کوکیهای شخص در داخل بانک اطلاعاتی نیز پیش بینی شدهاست. زیر ساخت آنرا در مطلب «تنظیمات کش توزیع شدهی مبتنی بر SQL Server در ASP.NET Core» پیشتر در این سایت مطالعه کردید و در پروژهی DNT Identity بکارگرفته شدهاست.
اگر به کلاس IdentityServicesRegistry مراجعه کنید، یک چنین تنظیمی در آن قابل مشاهده است:
var ticketStore = provider.GetService<ITicketStore>(); identityOptionsCookies.ApplicationCookie.SessionStore = ticketStore; // To manage large identity cookies
الف) DistributedCacheTicketStore
ب) MemoryCacheTicketStore
اولی از همان زیرساخت «تنظیمات کش توزیع شدهی مبتنی بر SQL Server در ASP.NET Core» استفاده میکند و دومی از IMemoryCache توکار ASP.NET Core برای پیاده سازی مکان ذخیره سازی محتوای کوکیهای سیستم، بهره خواهد برد.
باید دقت داشت که اگر حالت دوم را انتخاب کنید، با شروع مجدد برنامه، تمام اطلاعات کوکیهای کاربران نیز حذف خواهند شد. بنابراین استفادهی از حالت ذخیره سازی آنها در بانک اطلاعاتی منطقیتر است.
نحوهی تنظیم سرویس ITicketStore را نیز در متد setTicketStore میتوانید مشاهده کنید و در آن، در صورت انتخاب حالت بانک اطلاعاتی، ابتدا تنظیمات کش توزیع شده، صورت گرفته و سپس کلاس DistributedCacheTicketStore به عنوان تامین کنندهی ITicketStore به سیستم تزریق وابستگیها معرفی میشود.
همین اندازه برای انتقال محتوای کوکیهای کاربران به سرور کافی است و از این پس تنها اطلاعاتی که به سمت کلاینت ارسال میشود، ID رمزنگاری شدهی این کوکی است، جهت بازیابی آن از بانک اطلاعاتی و استفادهی خودکار از آن در برنامه.
کدهای کامل این سری را در مخزن کد DNT Identity میتوانید ملاحظه کنید.
به دلیل اینکه هدر xml خروجی از سرویس دارای چندین namespace هست هنگام کار با آنها به مشکل خواهیم خورد. (هم هنگام کار با xml taskها و هم هنگام کار با xml در sql)
به همین دلیل باید این قسمت از محتوا را حذف کرد . برای همین پس از گرفتن اطلاعات از سرویس آن را به کمک یک Script task حذف میکنیم
در این مرحله اطلاعات استخراج شده را باید در SQL درج کنیم . برای همین ساختاری که باید اطلاعات را در SQL نگه دارد را در دیتابیس ایجاد میکنیم :
جدول person برای نگهداری اطلاعات سرویس و XmlContainer برای نگهداری xmlهای سرویس .(برای داشتن History)
برای درج هم از SP استفاده میکنیم :
در نهایت به عنوان یک facility میتوانیم وضعیت تراکنش را به کاربر نمایش دهیم ( به کمک Script Task ) :
و تمام ...
Collations و حساسیت به بزرگی و کوچکی حروف
پردازش متون در بانکهای اطلاعاتی پیچیدهاست و عموما فراتر است از انتظارات سادهی اولیه، خصوصا اینکه بانکهای اطلاعاتی متفاوت، روشهای متفاوتی را هم در این زمینه بکار میگیرند. برای مثال بانکهای اطلاعاتی مانند SQLite و PostgreSQL به صورت پیشفرض به بزرگی و کوچکی حروف حساس هستند، اما بانکهایی مانند SQL Server و MySQL خیر. همچنین این حساسیت، بر روی کارآیی جستجو نیز بسیار تاثیر گذار است. برای مثال میتوان از متدهایی مانند string.ToLower برای انجام جستجوهای غیرحساس به بزرگی و کوچکی حروف استفاده کرد، اما بکارگیری آنها بلافاصله استفادهی از ایندکسها را غیرفعال میکنند و سبب انجام جستجوهایی بسیار کند خواهند شد.
برای مواجه شدن با یک چنین حالتهایی بدون افت کارآیی برنامه، مفهوم پایهای به نام collation در بانکهای اطلاعاتی ارائه شدهاست که مشخص میکند مقادیر رشتهای چگونه باید مرتب شده یا مقایسه شوند. برای مثال یک collation غیرحساس به بزرگی و کوچکی حروف، در حین مقایسهی رشتهها، به بزرگی و کوچکی حروف بکار گرفته شدهی در عبارت اهمیتی نمیدهد. همچنین باید دقت داشت که یک چنین مقایسهای بسته به فرهنگ بکار گرفته شده، میتوان متفاوت باشد؛ برای مثال در زبان ترکی، i و I حروف متفاوتی هستند و نباید در حین مقایسهی غیرحساس به بزرگی و کوچکی حروف، یکی در نظر گرفته شوند. به همین جهت تعداد قابل ملاحظهای case-insensitive collations از پیش تعریف شده، بسته به فرهنگهای مختلف وجود دارند؛ نمونهی دیگر آن فرهنگ آلمانی است که در آن عموما ä و ae را یکسان درنظر میگیرند. به علاوه collation بر روی نحوهی مرتب سازی حروف نیز تاثیر دارد؛ برای مثال در فرهنگ آلمانی، ä پس از a قرار میگیرد، اما در فرهنگ سوئدی در انتهای حروف الفباء واقع شدهاست.
تمام پردازشهای متنی در بانکهای اطلاعاتی (چه به صورت صریح و یا ضمنی) از collations استفاده میکنند و نام آنها از هر بانک اطلاعاتی به بانک اطلاعاتی دیگری متفاوت است. عموما میتوان این collations را در سطح کل بانک اطلاعاتی و یا در سطح یک ستون مشخص از آن و یا حتی در سطح یک کوئری مشخص، تعیین کرد.
روش تعیین collation در سطح بانک اطلاعاتی
در اغلب بانکهای اطلاعاتی، یک collation پیشفرض، در سطح کل آنها تعریف شدهاست و بر روی تمام پردازشهای متنی و تمام ستونهای جداول تاثیرگذار است. برای مثال حالت پیشفرض collation در SQL Server (اگر هیچ تنظیم پیشفرض دیگری در حین تعریف بانک اطلاعاتی وجود نداشته باشد) مقدار SQL_Latin1_General_CP1_CI_AS است. این مقدار یک collation غیرحساس به بزرگی و کوچکی حروف است. مقدار CI آن به معنای case-insensitive و AS آن مخفف accent-sensitive (حساس به لهجه) است.
از زمان EF-Core 5x، امکان کار با collations و تعیین آنها نیز میسر شدهاست. برای مثال برای تعیین یک چنین collation ای در سطح بانک اطلاعاتی میتوان به صورت زیر در متد OnModelCreating عمل کرد:
modelBuilder.UseCollation("SQL_Latin1_General_CP1_CS_AS");
روش تعیین collation در سطح جداول بانک اطلاعاتی
Collations را همچنین میتوان در سطح جداول نیز مشخص کرد تا بتوان در صورت نیاز، collation پیشفرض بانک اطلاعاتی را بازنویسی نمود. برای مثال شاید نیاز داشته باشید جداولی case-insensitive و تعدادی دیگر case-sensitive باشند.
در EF-Core 5x به بعد، روش انجام اینکار به صورت زیر است:
modelBuilder.Entity<Customer>().Property(c => c.Name) .UseCollation("SQL_Latin1_General_CP1_CI_AS");
روش تعیین پویای collation در سطح کوئریهای بانک اطلاعاتی
یک جدول میتواند collation پیشفرضی داشته باشد، اما در حین کوئری گرفتن، collation آنرا به صورت موقت و پویا تغییر داد. برای مثال بجای استفاده از متد ToLower که سبب میشود از ایندکسها استفاده نشود، میتوان از collation خاصی در حین کوئری گرفتن استفاده کرد:
var customers = context.Customers .Where(c => EF.Functions.Collate(c.Name, "SQL_Latin1_General_CP1_CS_AS") == "John").ToList();
تعیین collation غیرحساس به بزرگی و کوچکی حروف در SQLite، توسط EF-Core
با توجه به توضیحات فوق، متد زیر، collation ویژهی nocase را که در SQLite به معنای collation غیرحساس به بزرگی و کوچکی حروف است، به کل بانک اطلاعاتی و همچنین تمام ستونهای رشتهای آن به صورت خودکار اعمال میکند:
public static void SetCaseInsensitiveSearchesForSQLite(this ModelBuilder modelBuilder) { if (modelBuilder == null) { throw new ArgumentNullException(nameof(modelBuilder)); } modelBuilder.UseCollation("NOCASE"); foreach (var property in modelBuilder.Model.GetEntityTypes() .SelectMany(t => t.GetProperties()) .Where(p => p.ClrType == typeof(string))) { property.SetCollation("NOCASE"); } }
protected override void OnModelCreating(ModelBuilder modelBuilder) { if (modelBuilder == null) { throw new ArgumentNullException(nameof(modelBuilder)); } modelBuilder.SetCaseInsensitiveSearchesForSQLite(); }
The fourth preview of Entity Framework Core (EF Core) 8 is available on NuGet today!
Basic information
EF Core 8, or just EF8, is the successor to EF Core 7, and is scheduled for release in November 2023, at the same time as .NET 8.
EF8 previews currently target .NET 6, and can therefore be used with either .NET 6 (LTS) or .NET 7. This will likely be updated to .NET 8 as we near release.
EF8 will align with .NET 8 as a long-term support (LTS) release. See the .NET support policy for more information.
در این مخزن روشهای بهینه و توصیه شده جهت ساخت برنامههای تحت وب با استفاده از Net Core. در قالب 12 پروژه پیاده سازی شده است که منبع خوبی جهت الگو برداری است.
Boilerplate for ASP.NET Core reference application with Entity Framework Core, demonstrating a layered application architecture with DDD best practices. Implements NLayer Hexagonal architecture (Core, Application, Infrastructure and Presentation Layers) and Domain Driven Design (Entities, Repositories, Domain/Application Services, DTO's...) and aimed to be a Clean Architecture, with applying SOLID principles in order to use for a project template. Also implements best practices like loosely-coupled, dependency-inverted architecture and using design patterns such as Dependency Injection, logging, validation, exception handling, localization and so on.
چرا باید میزان دسترسی به منابع یک برنامهی وب را محدود کرد؟
فرض کنید در حال ساخت یک web API هستید که کارش ذخیره سازی لیست وظایف اشخاص است و برای مثال از یک GET /api/todos برای دریافت لیست ظایف، یک POST /api/todos برای ثبت و یک PUT /api/todos/{id} برای تغییر موارد ثبت شده، تشکیل میشود.
سؤال: چه مشکلی ممکن است به همراه این سه endpoint بروز کند؟
پاسخ: به حداقل چهار مورد زیر میتوان اشاره کرد:
- یک مهاجم سعی میکند با برنامهای که تدارک دیده، هزاران وظیفهی جدید را در چند ثانیه به سمت برنامه ارسال کند تا سبب خاتمهی سرویس آن شود.
- برنامهی ما در حین سرویس دهی، به یک سرویس ثالث نیز وابستهاست و آن سرویس ثالث، اجازهی استفادهی بیش از اندازهی از منابع خود را نمیدهد. با رسیدن تعداد زیادی درخواست به برنامهی ما تنها از طرف یک کاربر، به سقف مجاز استفادهی از آن سرویس ثالث رسیدهایم و اکنون برنامه، برای تمام کاربران آن قابل استفاده نیست.
- شخصی در حال دریافت اطلاعات تک تک کاربران است. از شماره یک شروع کرده و به همین نحو جلو میرود. برای دریافت اطلاعات کاربران، نیاز است شخص به سیستم وارد شده و اعتبارسنجی شود؛ یعنی به ازای هر درخواست، یک کوئری نیز به سمت بانک اطلاعاتی جهت بررسی وضعیت فعلی و آنی کاربر ارسال میشود. به همین جهت عدم کنترل میزان دسترسی به لیست اطلاعات کاربران، بار سنگینی را به بانک اطلاعاتی و CPU سیستم وارد میکند.
- هم اکنون چندین موتور جستجو و باتهایی نظر آنها در حال پیمایش سایت و برنامهی شما هستند که هر کدام از آنها میتوانند در حد یک مهاجم رفتار کنند.
به صورت خلاصه، همیشه استفادهی از برنامه، به آن نحوی که ما پیشبینی کردهایم، به پیش نمیرود و در آن لحظه، برنامه، در حال استفاده از CPU، حافظه و بانک اطلاعاتی به اشتراک گذاشته شدهی با تمام کاربران برنامهاست. در این حالت فقط یک کاربر مهاجم میتواند سبب از کار افتادن و یا به شدت کند شدن این برنامه شود و دسترسی سایر کاربران همزمان را مختل کند.
محدود کردن نرخ دسترسی به برنامه چیست؟
Rate limiting و یا نام دیگر آن request throttling، روشی است که توسط آن بتوان از الگوهای پیش بینی نشدهی استفادهی از برنامه جلوگیری کرد. عموما برنامههای وب، محدود کردن نرخ دسترسی را بر اساس تعداد بار درخواست انجام شدهی در یک بازهی زمانی مشخص، انجام میدهند و یا اگر کار برنامهی شما ارائهی فیلمهای ویدیویی است، شاید بخواهید میزان حجم استفاده شدهی توسط یک کاربر را کنترل کنید. در کل هدف نهایی از آن، کاهش و به حداقل رساندن روشهای آسیب زنندهی به برنامه و سیستم است؛ صرفنظر از اینکه این نحوهی استفادهی خاص، سهوی و یا عمدی باشد.
محدود کردن نرخ دسترسی را باید به چه منابعی اعمال کرد؟
پاسخ دقیق به این سؤال: «همه چیز» است! بله! همه چیز را کنترل کنید! در اینجا منظور از همه چیز، همان endpointهایی هستند که استفادهی نابجای از آنها میتوانند سبب کند شدن برنامه یا از دسترس خارج شدن آن شوند. برای مثال هر endpointای که از CPU، حافظه، دسترسی به دیسک سخت، بانک اطلاعاتی، APIهای ثالث و خارجی و امثال آن استفاده میکند، باید کنترل و محدود شود تا استفادهی ناصحیح یک کاربر از آنها، استفادهی از برنامه را برای سایر کاربران غیرممکن نکند. البته باید دقت داشت که هدف از اینکار، عصبی کردن کاربران عادی و معمولی برنامه نیست. هدف اصلی در اینجا، تشویق به استفادهی منصفانه از منابع سیستم است.
الگوریتمهای محدود کردن نرخ دسترسی
پیاده سازی ابتدایی محدود کردن نرخ دسترسی به منابع یک برنامه کار مشکلی است و در صورت استفاده از الگوریتمهای متداولی مانند تعریف یک جدول که شامل user-id، action-id و timestamp، به همراه یکبار ثبت اطلاعات به ازای هر درخواست و همچنین خواندن اطلاعات موجود است که جدول آن نیز به سرعت افزایش حجم میدهد. به همین جهت تعدادی الگوریتم بهینه برای اینکار طراحی شدهاند:
الگوریتمهای بازهی زمانی مشخص
در این روش، یک شمارشگر در یک بازهی زمانی مشخص فعال میشود و بر این مبنا است که محدودیتها اعمال خواهند شد. یک مثال آن، مجاز دانستن فقط «100 درخواست در یک دقیقه» است که نام دیگر آن «Quantized buckets / Fixed window limit» نیز هست.
برای مثال «نام هر اکشن + یک بازهی زمانی»، یک کلید دیکشنری نگهدارندهی اطلاعات محدود کردن نرخ دسترسی خواهد بود که به آن کلید، «bucket name» هم میگویند؛ مانند مقدار someaction_106062120. سپس به ازای هر درخواست رسیده، شمارشگر مرتبط با این کلید، یک واحد افزایش پیدا میکند و محدود کردن دسترسیها بر اساس مقدار این کلید صورت میگیرد. در ادامه با شروع هر بازهی زمانی جدید که در اینجا window نام دارد، یک کلید یا همان «bucket name» جدید تولید شده و مقدار متناظر با این کلید، به صفر تنظیم میشود.
اگر بجای دیکشنریهای #C از بانک اطلاعاتی Redis برای نگهداری این key/valueها استفاده شود، میتوان برای هر کدام از مقادیر آن، طول عمری را نیز مشخص کرد تا خود Redis، کار حذف خودکار اطلاعات غیرضروری را انجام دهد.
یک مشکل الگوریتمهای بازهی زمانی مشخص، غیر دقیق بودن آنها است. برای مثال فرض کنید که به ازای هر 10 ثانیه میخواهید تنها اجازهی پردازش 4 درخواست رسیده را بدهید. مشکل اینجا است که در این حالت یک کاربر میتواند 5 درخواست متوالی را بدون مشکل ارسال کند؛ 3 درخواست را در انتهای بازهی اول و دو درخواست را در ابتدای بازهی دوم:
به یک بازهی زمانی مشخص، fixed window و به انتها و ابتدای دو بازهی زمانی مشخص متوالی، sliding window میگویند. همانطور که در تصویر فوق هم مشاهده میکنید، در این اگوریتم، امکان محدود سازی دقیقی تنها در یک fixed window میسر است و نه در یک sliding window.
سؤال: آیا این مساله عدم دقت الگوریتمهای بازهی زمانی مشخص مهم است؟
پاسخ: بستگی دارد! اگر هدف شما، جلوگیری از استفادهی سهوی یا عمدی بیش از حد از منابع سیستم است، این مساله مشکل مهمی را ایجاد نمیکند. اما اگر دقت بالایی را انتظار دارید، بله، مهم است! در این حالت از الگوریتمهای «sliding window limit » بیشتر استفاده میشود که در پشت صحنه از همان روش استفادهی از چندین fixed window کوچک، کمک میگیرند.
الگوریتمهای سطل توکنها (Token buckets)
در دنیای مخابرات، از الگوریتمهای token buckets جهت کنترل میزان مصرف پهنای باند، زیاد استفاده میشود. از واژهی سطل در اینجا استفاده شده، چون عموما به همراه آب بکارگرفته میشود:
فرض کنید سطل آبی را دارید که در کف آن نشتی دارد. اگر نرخ پر کردن این سطل، با آب، از نرخ نشتی کف آن بیشتر باشد، آب از سطل، سرریز خواهد شد. به این معنا که با سرریز توکنها یا آب در این مثال، هیچ درخواست جدید دیگری پردازش نمیشود؛ تا زمانیکه مجددا سطل، به اندازهای خالی شود که بتواند توکن یا آب بیشتری را بپذیرد.
یکی از مزیتهای این روش، نداشتن مشکل عدم دقت به همراه بازههای زمانی مشخص است. در اینجا اگر تعداد درخواست زیادی به یکباره به سمت برنامه ارسال شوند، سطل پردازشی آنها سرریز شده و دیگر پردازش نمیشوند.
مزیت دیگر آنها، امکان بروز انفجاری یک ترافیک (bursts in traffic) نیز هست. برای مثال اگر قرار است سطلی با 60 توکن در دقیقه پر شود و این سطل نیز هر ثانیه یکبار تخلیه میشود، کلاینتها هنوز میتوانند 60 درخواست را در طی یک ثانیه ارسال کنند (ترافیک انفجاری) و پس از آن نرخ پردازشی، یک درخواست به ازای هر ثانیه خواهد شد.
آیا باید امکان بروز انفجار در ترافیک را داد؟
عموما در اکثر برنامهها وجود یک محدود کنندهی نرخ دسترسی کافی است. برای مثال یک محدود کنندهی نرخ دسترسی سراسری 600 درخواست در هر دقیقه، برای هر endpoint ای شاید مناسب باشد. اما گاهی از اوقات نیاز است تا امکان بروز انفجار در ترافیک (bursts) را نیز درنظر گرفت. برای مثال زمانیکه یک برنامهی موبایل شروع به کار میکند، در ابتدای راه اندازی آن تعداد زیادی درخواست، به سمت سرور ارسال میشوند و پس از آن، این سرعت کاهش پیدا میکند. در این حالت بهتر است چندین محدودیت را تعریف کرد: برای مثال امکان ارسال 10 درخواست در هر ثانیه و حداکثر 3600 درخواست در هر ساعت.
روش تشخیص کلاینتها چگونه باشد؟
تا اینجا در مورد bucket name یا کلید دیکشنری اطلاعات محدود کردن دسترسی به منابع، از روش «نام هر اکشن + یک بازهی زمانی» استفاده کردیم. به این کار «پارتیشن بندی درخواستها» هم گفته میشود. روشهای دیگری نیز برای انجام اینکار وجود دارند:
پارتیشن بندی به ازای هر
- endpoint
- آدرس IP. البته باید دقت داشت که کاربرانی که در پشت یک پروکسی قرار دارند، از یک IP آدرس اشتراکی استفاده میکنند.
- شماره کاربری. البته باید در اینجا بحث کاربران اعتبارسنجی نشده و anonymous را نیز مدنظر قرار داد.
- شمار سشن کاربر. در این حالت باید بحث ایجاد سشنهای جدید به ازای دستگاههای مختلف مورد استفادهی توسط کاربر را هم مدنظر قرار داد.
- نوع مروگر.
- هدر ویژه رسیده مانند X-Api-Token
بسته به نوع برنامه عموما از ترکیبی از موارد فوق برای پارتیشن بندی درخواستهای رسیده استفاده میشود.
درنظر گرفتن حالتهای استثنائی
هرچند همانطور که عنوان شد تمام قسمتهای برنامه باید از لحاظ میزان دسترسی محدود شوند، اما استثناءهای زیر را نیز باید درنظر گرفت:
- عموما تیم مدیریتی یا فروش برنامه، بیش از سایر کاربران، با برنامه کار میکنند.
- بیش از اندازه محدود کردن Web crawlers میتواند سبب کاهش امتیاز SEO سایت شما شود.
- گروههای خاصی از کاربران برنامه نیز میتوانند دسترسیهای بیشتری را خریداری کنند.
نحوهی خاتمهی اتصال و درخواست
اگر کاربری به حد نهایی استفادهی از منابع خود رسید، چه باید کرد؟ آیا باید صرفا درخواست او را برگشت زد یا اطلاعات بهتری را به او نمایش داد؟
برای مثال GitHub یک چنین خروجی را به همراه هدرهای ویژهای جهت مشخص سازی وضعیت محدود سازی دسترسی به منابع و علت آن، ارائه میدهد:
> HTTP/2 403 > Date: Tue, 20 Aug 2013 14:50:41 GMT > x-ratelimit-limit: 60 > x-ratelimit-remaining: 0 > x-ratelimit-used: 60 > x-ratelimit-reset: 1377013266 > { > "message": "API rate limit exceeded for xxx.xxx.xxx.xxx. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", > "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting" > }
حتی یکسری از APIها از status codeهای ویژهای مانند 403 (دسترسی ممنوع)، 503 (سرویس در دسترس نیست) و یا 429 (تعداد درخواستهای زیاد) برای پاسخ دهی استفاده میکنند.
محل ذخیره سازی اطلاعات محدود سازی دسترسی به منابع کجا باشد؟
اگر محدودسازی دسترسی به منابع، جزئی از مدل تجاری برنامهی شما است، نیاز است حتما از یک بانک اطلاعاتی توزیع شده مانند Redis استفاده کرد تا بتواند اطلاعات تمام نمونههای در حال اجرای برنامه را پوشش دهد. اما اگر هدف از این محدود سازی تنها میسر ساختن دسترسی منصفانهی به منابع آن است، ذخیره سازی آنها در حافظهی همان نمونهی در حال اجرای برنامه هم کافی است.