On this page
- מה זה?
- מילות מפתח שחשוב לזכור
- בונים מאפס — Docker, DbContext, Migrations, ושני Endpoints שרצים באמת
- שלב 1: מוסיפים את EF Core לפרויקט — NuGet Packages
- שלב 2: מרימים מסד נתונים אמיתי — SQL Server בתוך Docker
- שלב 3: מגדירים את מחלקות הנתונים (Entities)
- שלב 4: בונים את ה-DbContext
- שלב 5: מחברים הכול ב-Program.cs — בדיוק כמו כל שירות אחר עם DI
- שלב 6: יוצרים את הטבלאות בפועל — Migrations
- שלב 7: משתמשים בזה — Controller עם שני Endpoints אמיתיים
- שלב 8: מריצים ובודקים בפועל
- הסבר עיקרי
- יתרונות
- חסרונות
- נקודות חשובות
- טעויות נפוצות
- סיכום
- דוקומנטציה רשמית
- תרגילים
- פרויקט מסכם
- מה בפרק הבא
EF Core — DbContext, DbSet ו-Migrations
מה זה?
בשיעור הקודם תכננו את הסכמה וכתבנו CREATE TABLE ידני. אבל בקוד C# בפועל, נרצה לעבוד עם אובייקטים (Student, Grade) לא עם SQL גולמי בכל מקום. EF Core (Entity Framework Core) הוא ORM (Object-Relational Mapper) — ממפה בין מחלקות C# לטבלאות SQL אוטומטית, ו-Migrations בונות/מעדכנות את מבנה ה-DB מתוך הקוד עצמו (“Code First”), בלי לכתוב CREATE TABLE ידני בנפרד.
השיעור הזה שונה מהשיעורים הקודמים בדבר אחד חשוב: הוא לא נגמר בקטע קוד לדוגמה. הוא מתחיל מ"אין כלום", ומסתיים ב-API אמיתי שרץ על המחשב שלכם ומדבר עם מסד נתונים אמיתי — כולל להרים את מסד הנתונים עצמו עם Docker (שכבר למדתם ביחידה הקודמת), ולבדוק את הכול בפועל עם שני endpoints. כל שלב בונה על השלב שלפניו — כדאי לעבור אותם לפי הסדר, לא לדלג.
מילות מפתח שחשוב לזכור
• ORM (Object-Relational Mapper) — שכבה שממפה אוטומטית בין אובייקטים ב-C# לשורות בטבלאות SQL
• NuGet Package — ספריית קוד חיצונית שמוסיפים לפרויקט .NET (המקביל ל-npm install ב-Node) — EF Core הוא לא חלק מובנה ב-.NET, אלא Package כזה
• DbContext — המחלקה המרכזית של EF Core; מייצגת “חיבור” לוגי למסד הנתונים כולו
• DbSet<T> — Property בתוך DbContext שמייצג טבלה בודדת (DbSet<Student> = טבלת Students)
• Connection String — מחרוזת טקסט שאומרת לתוכנית איך להגיע למסד הנתונים: איפה הוא (כתובת+פורט), שמו, ואיך מתחברים אליו (משתמש+סיסמה)
• Migration — קובץ שמתעד שינוי במבנה ה-DB (הוספת עמודה, טבלה חדשה), שאפשר להחיל בפועל על ה-DB
בונים מאפס — Docker, DbContext, Migrations, ושני Endpoints שרצים באמת
שלב 1: מוסיפים את EF Core לפרויקט — NuGet Packages
עד עכשיו כל מה שהשתמשנו בו הגיע מובנה בתוך .NET — לא היינו צריכים “להתקין” שום דבר נוסף. EF Core שונה: הוא ספרייה חיצונית, שצריך להוסיף במפורש לפרויקט לפני שאפשר להשתמש בה:
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Designdotnet add package <שם> מוריד ספרייה (NuGet Package) ומוסיף אותה לפרויקט — בדיוק כמו npm install בעולם ה-JavaScript. הראשונה נותנת ל-EF Core את היכולת לדבר עם SQL Server ספציפית; השנייה נדרשת כדי שהכלים של Migrations (שנגיע אליהם בשלב 6) יעבדו בכלל.
חוץ מזה, צריך גם כלי שורת-פקודה גלובלי, בנפרד מהפרויקט עצמו — לא קוד שהתוכנית שלכם משתמשת בו, אלא פקודה שמריצים מהטרמינל:
dotnet tool install --global dotnet-efמתקינים את זה פעם אחת על המחשב (לא לכל פרויקט מחדש) — זה מה שנותן לכם את הפקודה dotnet ef שנשתמש בה בשלב 6.
שלב 2: מרימים מסד נתונים אמיתי — SQL Server בתוך Docker
עד עכשיו לא היה לנו שום מסד נתונים אמיתי להתחבר אליו. פותרים את זה עם בדיוק אותו Docker שכבר תרגלתם:
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=YourStrong@Passw0rd" -p 1433:1433 --name sql-server -d mcr.microsoft.com/mssql/server:2022-latestחלק מהדגלים כבר מוכרים לכם מיחידת ה-Docker, וחלק חדשים:
mcr.microsoft.com/mssql/server:2022-latest— ה-Image להרצה (בדיוק כמוnginxאוhello-worldשכבר הרצתם) — הפעם, Image רשמי של מיקרוסופט שמכיל SQL Server מוכן לשימוש.-p 1433:1433— מיפוי פורטים, בדיוק כמו-p 8080:80שכבר תרגלתם:1433הוא הפורט הסטנדרטי שבו SQL Server מאזין. אחרי השורה הזו,localhost:1433על המחשב שלכם מוביל ל-SQL Server בתוך הקונטיינר.-e "ACCEPT_EULA=Y"ו--e "MSSQL_SA_PASSWORD=..."— חדש:-eמעביר משתנה סביבה (Environment Variable) לתוך הקונטיינר — דרך נפוצה מאוד להעביר הגדרות ל-Image בלי לערוך קבצים. ה-Image הזה “יודע לקרוא” שני משתנים ספציפיים:ACCEPT_EULA=Y(מאשר את תנאי הרישוי של SQL Server) ו-MSSQL_SA_PASSWORD(קובע את הסיסמה של המשתמש הראשי,sa, שנשתמש בו כדי להתחבר בעוד רגע). הסיסמה חייבת להיות “חזקה” (אותיות גדולות/קטנות/ספרות/סימן) — אחרת ה-Image מסרב לעלות.--name sql-server— חדש: קובע שם קבוע לקונטיינר, במקום שתצטרכו להעתיק כל פעם את ה-ID המסורבל מ-docker ps. מעכשיו אפשר לכתובdocker logs sql-serverאוdocker stop sql-serverישירות בשם.-d— חדש: (detached) מריץ את הקונטיינר ברקע. בלעדיו (כמו בדוגמאות הקודמות שלכם ביחידת Docker), הטרמינל “נתפס” ומציג את הפלט החי של הקונטיינר עד שעוצרים אותו — לא מעשי כשרוצים להמשיך לעבוד באותו טרמינל בזמן שהמסד רץ.
מוודאים שזה עלה בהצלחה, עם כלים שכבר מכירים מיחידת ה-Docker:
docker ps # sql-server אמור להופיע ברשימת הקונטיינרים הרצים
docker logs sql-server # מחפשים שורה שמכילה "SQL Server is now ready for client connections"מזה והלאה, ה-DB פשוט רץ ברקע — בדיוק כמו כל שירות אחר שהרמתם בקונטיינר.
שלב 3: מגדירים את מחלקות הנתונים (Entities)
public class Student
{
public int Id { get; set; } // EF Core automatically recognizes "Id" as a Primary Key
public string Name { get; set; }
public int Age { get; set; }
public List<Grade> Grades { get; set; } = new(); // relationship to Grade — a Navigation Property
}
public class Grade
{
public int Id { get; set; }
public int StudentId { get; set; } // Foreign Key
public int Score { get; set; }
}שימו לב: Student כאן היא class רגילה עם { get; set; } על כל שדה — לא record, כמו ששימש בשיעורי ה-CRUD המוקדמים. הסיבה: record נועד לערך קבוע שנוצר פעם אחת; EF Core צריך למלא ולעדכן שדות אחרי שהאובייקט כבר קיים (למשל, למלא את ה-Id בעצמו אחרי ה-Insert) — לשם כך צריך Properties שאפשר לשנות (set), כמו שכבר הכרתם ביחידת ה-OOP.
List<Grade> Grades נקראת Navigation Property — זו הדרך של EF Core “לדעת” שיש קשר בין Student ל-Grade (בדיוק כמו ה-Foreign Key StudentId בעיצוב מהשיעור הקודם), בלי שתצטרכו לכתוב JOIN ידני כדי לגשת לציונים של תלמיד. נעמיק בזה בשיעור העתידי על Queries; כרגע מספיק להגדיר את זה, לא להשתמש בזה.
שלב 4: בונים את ה-DbContext
public class SchoolDbContext : DbContext
{
public SchoolDbContext(DbContextOptions<SchoolDbContext> options) : base(options) { }
public DbSet<Student> Students { get; set; }
public DbSet<Grade> Grades { get; set; }
}DbContext הוא מחלקת-בסיס שמגיעה מתוך חבילת EF Core שהתקנו בשלב 1 — SchoolDbContext יורש ממנה (בדיוק כמו ירושה שכבר הכרתם ביחידת ה-OOP), ומקבל בזכות זה את כל היכולות לדבר עם מסד הנתונים. שתי השורות public DbSet<Student> Students { get; set; } ו-public DbSet<Grade> Grades { get; set; } הן ה"תרגום" בין מחלקה לטבלה: כל DbSet<T> מייצג טבלה בודדת. חדש לעומת הגרסה שאולי ראיתם במקומות אחרים: הקונסטרוקטור public SchoolDbContext(DbContextOptions<SchoolDbContext> options) : base(options) מקבל את הגדרות החיבור (איזה DB, איך מתחברים) מבחוץ, דרך פרמטר — בדיוק אותו רעיון של Constructor Injection שכבר הכרתם משיעור ה-DI. לא כותבים את מחרוזת החיבור בתוך ה-DbContext עצמו; מי שיזין אותה הוא Program.cs, בשלב הבא.
שלב 5: מחברים הכול ב-Program.cs — בדיוק כמו כל שירות אחר עם DI
var builder = WebApplication.CreateBuilder(args);
var connectionString =
"Server=localhost,1433;Database=SchoolDb;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;";
builder.Services.AddDbContext<SchoolDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();בואו נפרק את מחרוזת החיבור, מקטע-מקטע:
Server=localhost,1433— לאיזה כתובת ופורט מתחברים. שימו לב: SQL Server משתמש בפסיק (,) לפני מספר הפורט, לא בנקודתיים (:) — טעות נפוצה.localhostו-1433הם בדיוק הכתובת והפורט שפתחנו ב--p 1433:1433בשלב 2.Database=SchoolDb— שם מסד הנתונים. לא צריך שיהיה קיים מראש — Migrations (שלב 6) ייצרו אותו בעצמם אם הוא לא קיים.User Id=sa;Password=YourStrong@Passw0rd— פרטי ההתחברות: המשתמשsaוהסיסמה בדיוק כפי שקבענו ל-Docker ב-MSSQL_SA_PASSWORDבשלב 2. חייבים להיות זהים.TrustServerCertificate=True— ה-Image של SQL Server מגיע עם תעודת אבטחה “עצמית” (לא חתומה רשמית) — בלי השורה הזו, ההתחברות נכשלת עם שגיאת אבטחה. בפרויקט production אמיתי משתמשים בתעודה חתומה אמיתית; לפיתוח מקומי זה בסדר גמור.
builder.Services.AddDbContext<SchoolDbContext>(...) — הכרתם את AddScoped/AddControllers בשיעורים קודמים; AddDbContext הוא בדיוק אותו רעיון (למעשה, הוא רושם את ה-DbContext בתור Scoped מאחורי הקלעים), רק גרסה מיוחדת שמיועדת ל-DbContext — היא גם יודעת להעביר את ה-options (עם מחרוזת החיבור) לקונסטרוקטור שבנינו בשלב 4. מהרגע הזה, כל Controller שיבקש SchoolDbContext בקונסטרוקטור שלו יקבל אותו אוטומטית מ-ASP.NET — בדיוק כמו כל תלות אחרת שכבר הזרקתם.
שלב 6: יוצרים את הטבלאות בפועל — Migrations
עד עכשיו הכול “בטיוטה” — יש מחלקות ויש חיבור, אבל שום טבלה עדיין לא נוצרה בפועל ב-DB. שתי הפקודות שסוגרות את הפער:
dotnet ef migrations add InitialCreateהפקודה הזו לא נוגעת ב-DB בכלל — היא רק משווה בין המחלקות (Student, Grade) לבין מה שקיים כרגע (כלום, בפעם הראשונה), ויוצרת קובץ C# חדש בתיקיית Migrations/ שמתעד את השינוי הנדרש (“צור טבלת Students עם העמודות האלה, צור טבלת Grades…”). InitialCreate הוא רק שם — תיעוד קריא-לאדם למה השתנה, בדיוק כמו שם ל-commit ב-Git.
dotnet ef database updateזו הפקודה שבאמת מתחברת ל-DB (דרך מחרוזת החיבור מ-Program.cs) ומריצה את השינויים המתועדים ב-Migration — יוצרת את מסד הנתונים SchoolDb אם הוא לא קיים, ובתוכו את הטבלאות Students ו-Grades. זה בדיוק אותו אפקט כמו להריץ CREATE TABLE ידני מהשיעור הקודם — רק שהוא נגזר אוטומטית מהמחלקות שלכם, לא נכתב בנפרד.
טעות נפוצה: להריץ רק migrations add ולשכוח database update, ואז לתהות למה הטבלה “לא קיימת” כשמריצים את התוכנית (שגיאה כמו Invalid object name 'Students'). migrations add בלבד רק מתכנן את השינוי בקובץ — הוא לא נוגע ב-DB בפועל. צריך את שתי הפקודות, בסדר הזה.
שלב 7: משתמשים בזה — Controller עם שני Endpoints אמיתיים
עכשיו, כדי לוודא שכל השרשרת (Docker → DbContext → Migrations) באמת עובדת, בונים Controller פשוט שמדבר ישירות עם SchoolDbContext:
[ApiController]
[Route("api/[controller]")]
public class StudentsController : ControllerBase
{
private readonly SchoolDbContext _context;
public StudentsController(SchoolDbContext context) // injected automatically, thanks to AddDbContext in step 5
{
_context = context;
}
[HttpGet]
public IActionResult GetAll()
{
return Ok(_context.Students.ToList());
}
[HttpPost]
public IActionResult Create([FromBody] Student newStudent)
{
_context.Students.Add(newStudent);
_context.SaveChanges(); // only here does it actually get written to the DB
return CreatedAtAction(nameof(GetAll), newStudent);
}
}שום דבר כאן לא חדש בפני עצמו — [ApiController], [Route], [HttpGet]/[HttpPost], Constructor Injection — כל אלה כבר הכרתם. השינוי היחיד: במקום IRepository<Student> או List<Student> בזיכרון, ה-Controller מקבל SchoolDbContext ישירות, ומדבר עם _context.Students בדיוק כמו List<T> — רק שמאחורי הקלעים, .ToList() שולח שאילתת SELECT אמיתית ל-SQL Server, ו-.Add(...) + SaveChanges() שולחים INSERT אמיתי.
(שימו לב: ה-Controller הזה מדבר עם ה-DbContext ישירות, לא דרך IRepository/Service שבנינו ביחידה הקודמת — זו דרך מכוונת פשוטה כדי לוודא קודם שכל השרשרת הטכנית עובדת מקצה לקצה. בשיעור הבא נחבר את SchoolDbContext הזה בדיוק לתוך ה-IRepository<Student> שכבר בנינו, כך ש-StudentsController יחזור להיות דק ולא ידבר עם ה-DB ישירות.)
שלב 8: מריצים ובודקים בפועל
dotnet runהשרת עולה (כמו בכל שיעור עד עכשיו), ומדפיס את הכתובת שהוא מאזין לה. עכשיו הבדיקה האמיתית — לא רק “זה מתקמפל”, אלא “זה באמת שומר ל-DB”:
# 1. Create a student
curl -X POST http://localhost:5123/api/students -H "Content-Type: application/json" -d "{\"name\":\"Dana\",\"age\":25}"
# 2. Read all students back
curl http://localhost:5123/api/studentsהבקשה הראשונה (POST) צריכה להחזיר 201 Created עם התלמיד החדש. הבקשה השנייה (GET) צריכה להחזיר רשימה שמכילה אותו — ההוכחה שהמידע נשמר בפועל: אם עוצרים את dotnet run (Ctrl+C) ומריצים אותו שוב, ואז שולחים שוב GET, דנה עדיין שם. בניגוד ל-List<T> בזיכרון מהשיעורים המוקדמים (שהיה “שוכח” הכול עם כל הפעלה מחדש), הנתונים כאן חיים בתוך הקונטיינר של SQL Server, לא בתוך התוכנית שלכם.
הסבר עיקרי
DbSet<T> “נראה” כמו List<T> אבל הוא לא — context.Students.Add(...) נראה זהה לפעולת List<T>.Add, אבל מאחורי הקלעים EF Core בונה שאילתת SQL בהתאם. ההבדל הקריטי: השינוי לא נשמר בפועל ל-DB עד קריאה מפורשת ל-SaveChanges() — אפשר להוסיף/לעדכן/למחוק כמה פעולות ברצף, וכולן נשמרות ביחד בקריאה אחת, לא אחת-אחת.
Code First הופך את EF Core למקור-האמת היחיד — במקום לתחזק גם CREATE TABLE SQL וגם מחלקות C# בנפרד (וסיכון שהם “יתפצלו” ולא יתאימו יותר), עם Code First המחלקות הן מקור האמת — Migrations נגזרות מהן אוטומטית. שינוי מבני (הוספת עמודה) = שינוי במחלקה + migrations add חדש, לא עריכת SQL ידנית.
Navigation Property (Student.Grades) — זו הדרך של EF Core “לדעת” על קשרים בלי לכתוב JOIN ידני כל פעם — Student מכיל List<Grade> שמייצג את כל הציונים שלו, ו-EF Core מטפל בקישור (דרך ה-StudentId FK) מאחורי הקלעים. זה יאפשר בשיעור העתידי על Queries לשלוף תלמיד עם כל ציוניו בשאילתה אחת פשוטה, בלי SQL JOIN ידני.
יתרונות
EF Core חוסך כתיבת SQL ידני לרוב הפעולות הנפוצות (CRUD בסיסי); Migrations מתעדות כל שינוי מבני ב-DB לאורך זמן, וניתנות לביטול; Code First שומר על מחלקות C# כמקור אמת יחיד, במקום סנכרון ידני בין קוד ל-SQL; Docker נותן מסד נתונים אמיתי וזהה לכל מפתח בצוות, בלי התקנה ידנית של SQL Server על כל מחשב.
חסרונות
EF Core מוסיף שכבת עקיפין — שאילתות מורכבות מאוד לפעמים קשות לכתוב/לייעל דרכו לעומת SQL ישיר; SaveChanges() שנשכח משאיר שינויים “תקועים” בזיכרון בלי להיכתב בפועל ל-DB; שכחת docker run (מהשלב הראשון) לפני הרצת התוכנית גורמת לשגיאת חיבור מיידית.
נקודות חשובות
• EF Core הוא NuGet Package חיצוני (לא מובנה ב-.NET) — דורש dotnet add package לפני שימוש, ו-dotnet tool install --global dotnet-ef לכלי ה-Migrations
• DbContext = חיבור לוגי ל-DB, מקבל את מחרוזת החיבור דרך הקונסטרוקטור (DbContextOptions); DbSet<T> = טבלה בודדת בתוכו
• AddDbContext ב-Program.cs הוא בדיוק AddScoped, מותאם ל-DbContext — כולל העברת מחרוזת החיבור
• Code First = מחלקות C# קודם; migrations add מתכנן שינוי, database update מריץ אותו בפועל על ה-DB
• SaveChanges() הוא הרגע היחיד שבו שינויים נכתבים בפועל ל-DB
טעויות נפוצות
• לשכוח להריץ docker run (או שהקונטיינר נעצר) לפני dotnet run — שגיאת חיבור מיידית; docker ps תמיד הבדיקה הראשונה
• להריץ migrations add בלי database update אחריו — הטבלה עדיין לא קיימת בפועל ב-DB (ראו הסבר מלא בשלב 6)
• לשכוח SaveChanges() ולתהות למה נתונים “לא נשמרו” ל-DB בפועל
• לבלבל בין DbSet<T> (טבלה) ל-List<T> רגילה — נראים דומים בתחביר, אך DbSet מתורגם לשאילתות SQL אמיתיות
סיכום
בנינו שרשרת מלאה מאפס: התקנת EF Core כ-NuGet Package, הרמת SQL Server אמיתי בתוך Docker (-e למשתני סביבה, --name+-d לניהול נוח), מחלקות Student/Grade כ-Entities, DbContext עם DbSet<T> לכל טבלה שמקבל את מחרוזת החיבור דרך הקונסטרוקטור, רישום ב-Program.cs עם AddDbContext (בדיוק כמו כל שירות אחר ב-DI), יצירת הטבלאות בפועל עם migrations add + database update, ולבסוף Controller עם שני endpoints (GET/POST) שהוכיחו שהכול עובד ביחד — כולל שהנתונים שורדים הפעלה מחדש של השרת, בניגוד ל-List<T> בזיכרון.
דוקומנטציה רשמית
Microsoft Learn — EF Core overview
Microsoft Learn — SQL Server container images
תרגילים
תרגיל 1 — DbContext ראשון
המשימה: צרו מחלקת Product פשוטה (Id, Name, Price), DbContext עם DbSet<Product>, והריצו migrations add + database update ליצירת הטבלה על מסד הנתונים ב-Docker שהרמתם בשלב 2.
בדיקה: הטבלה Products נוצרת בפועל ב-DB, עם עמודות תואמות למחלקה.
תרגיל 2 — CRUD בסיסי עם SaveChanges
המשימה: הוסיפו 3 מוצרים עם context.Products.Add, קראו אותם חזרה עם context.Products.ToList(), ועדכנו את המחיר של אחד מהם.
בדיקה: לפני SaveChanges() הראשון, הטבלה ריקה ב-DB; אחריו, שלושת המוצרים קיימים בפועל; עדכון המחיר משתקף בשליפה חוזרת מה-DB (לא רק בזיכרון).
פרויקט מסכם
המשימה: ממשו את סכמת ה-DB שתכננתם בשיעור הקודם (בלוג: Authors/Posts/Comments) עם EF Core Code First, מול אותו מסד נתונים ב-Docker.
דרישות:
- שלוש מחלקות תואמות (
Author,Post,Comment) עם Navigation Properties מתאימות (Author.Posts,Post.Comments) BlogDbContextעם שלושהDbSet<T>, ו-Migration ראשונה שיוצרת את כל הטבלאות- Controller עם
GET/POSTשמוכיח שהכול עובד מקצה לקצה, כמו בשלבים 7-8 למעלה
בדיקה: בדיקה ב-DB (או בשליפה חוזרת) שכל הרשומות נוצרו עם FK נכונים בין הטבלאות, תואם בדיוק את התכנון מהשיעור הקודם.
מה בפרק הבא
בפרק הבא — SqlRepository: ה-Controller ששלב 7 בנה מדבר עם SchoolDbContext ישירות — נוח להוכחה מהירה, אבל סותר את כל תבנית ה-DAL שבנינו ביחידת ASP.NET Core. בשיעור הבא נחבר את ה-DbContext הזה בדיוק לתוך IRepository<Student> הקיים, כך שה-Controller יחזור להיות דק ולא ידבר עם ה-DB בכלל — בלי לגעת ב-Service וב-Controller עצמם.