On this page
- מה זה?
- מילות מפתח שחשוב לזכור
- בונים את SqlRepository שלב אחר שלב
- שלב 1: מזכירים את מה שכבר קיים
- שלב 2: מתחילים מהמתודה הכי פשוטה — GetAll
- שלב 3: מוסיפים GetById
- שלב 4: מוסיפים Add
- שלב 5: מוסיפים Update — מושג חדש, Change Tracking
- שלב 6: מוסיפים Delete — המתודה האחרונה
- שלב 7: רושמים אותו ב-Program.cs — שורה אחת
- שלב 8: מחרוזת החיבור “כמו שצריך” — appsettings.json
- שלב 9: ה-Controller חוזר להיות דק
- שלב 10: מריצים ובודקים שבאמת כלום לא “הרגיש” את השינוי
- הסבר עיקרי
- יתרונות
- חסרונות
- נקודות חשובות
- טעויות נפוצות
- סיכום
- דוקומנטציה רשמית
- תרגילים
- פרויקט מסכם
- מה בפרק הבא
SqlRepository — מעבר מ-InMemory ל-EF Core
מה זה?
ביחידת ASP.NET Core בנינו תבנית שלמה: IRepository<T> (הבטחה ל-CRUD), InMemoryStudentRepository (מימוש שמחזיק List<Student> בזיכרון), StudentService (לוגיקה עסקית, מקבל IRepository<Student> בקונסטרוקטור), ו-StudentsController (מקבל StudentService בקונסטרוקטור). בשיעור הקודם למדנו להרים SQL Server אמיתי ולדבר איתו דרך DbContext — אבל חיברנו אותו ישירות ל-Controller, בעקיפין לכל התבנית הזו, רק כדי להוכיח שהחיבור הטכני עובד.
השיעור הזה סוגר את המעגל: כותבים SqlStudentRepository — מימוש חדש של אותו IRepository<T> שכבר קיים, עם DbContext במקום List. אם התבנית תוכננה נכון (וכבר ראינו שכן), StudentService ו-StudentsController לא ישתנו אף שורה אחת.
מילות מפתח שחשוב לזכור
• SqlRepository — מימוש של IRepository<T> שמשתמש ב-EF Core (DbContext/DbSet) במקום מבנה נתונים בזיכרון
• Change Tracking — המנגנון שבו DbContext “זוכר” אילו אובייקטים נוספו/שונו/נמחקו, כדי לדעת איזו שאילתת SQL לייצר כש-SaveChanges() נקרא
• appsettings.json — קובץ ההגדרות של הפרויקט (כבר הוזכר בשיעור הראשון של היחידה הקודמת); כאן נשתמש בו בפועל לראשונה, במקום לכתוב מחרוזת חיבור ישירות בקוד
בונים את SqlRepository שלב אחר שלב
שלב 1: מזכירים את מה שכבר קיים
לפני שכותבים שורת קוד אחת, שני תזכורות קצרות מהיחידה הקודמת. ההבטחה שכבר הוגדרה:
public interface IRepository<T>
{
List<T> GetAll();
T GetById(int id);
void Add(T item);
void Update(T item);
void Delete(int id);
}והמימוש הקיים, שמחזיק הכול בזיכרון:
public class InMemoryStudentRepository : IRepository<Student>
{
private List<Student> _students = new List<Student>();
public List<Student> GetAll() => _students;
public Student GetById(int id) => _students.FirstOrDefault(s => s.Id == id);
public void Add(Student item) => _students.Add(item);
public void Update(Student item) { /* update in the list */ }
public void Delete(int id) => _students.RemoveAll(s => s.Id == id);
}המטרה של השיעור הזה: לכתוב מחלקה שלישית — לא נוגעים בשתיים האלה — שמממשת בדיוק את אותו IRepository<Student>, אבל עם SchoolDbContext מהשיעור הקודם במקום List.
שלב 2: מתחילים מהמתודה הכי פשוטה — GetAll
public class SqlStudentRepository : IRepository<Student>
{
private readonly SchoolDbContext _context;
public SqlStudentRepository(SchoolDbContext context) // injected automatically — AddDbContext from the previous lesson
{
_context = context;
}
public List<Student> GetAll() => _context.Students.ToList();
}: IRepository<Student> אומר שהמחלקה הזו מתחייבת לממש את כל חמש המתודות של ה-Interface — עדיין רק אחת כתובה, בכוונה. הקונסטרוקטור מקבל SchoolDbContext — בדיוק אותו DbContext שבנינו בשיעור הקודם, ובדיוק אותו עקרון של Constructor Injection שכבר מוכר: אתם לא כותבים new SchoolDbContext(...) בשום מקום, ASP.NET מעביר אותו אוטומטית כי הוא רשום ב-DI (עוד מעט נזכיר גם את שורת הרישום).
GetAll() מתורגם ל-SELECT * FROM Students — בדיוק כמו שכבר ראיתם בשיעור הקודם.
שלב 3: מוסיפים GetById
public Student GetById(int id) => _context.Students.FirstOrDefault(s => s.Id == id);זהה לחלוטין לתחביר ב-InMemoryStudentRepository משלב 1 — FirstOrDefault עובד זהה על List<T> ועל DbSet<T>. ההבדל היחיד (בלתי-נראה מהקוד עצמו): כאן זה מתורגם ל-SELECT TOP(1) * FROM Students WHERE Id = @id, לא לולאה על רשימה בזיכרון.
שלב 4: מוסיפים Add
public void Add(Student item)
{
_context.Students.Add(item);
_context.SaveChanges(); // this is where it's actually written to the DB
}כאן מופיע ההבדל האמיתי הראשון מול InMemoryStudentRepository: שם, _students.Add(item) לבדו היה מספיק — השורה היא הפעולה. כאן, _context.Students.Add(item) לא כותב כלום ל-DB עדיין — הוא רק אומר ל-DbContext “תזכור שצריך להוסיף את זה” (זה בדיוק ה-Change Tracking שהוזכר במילות המפתח). רק SaveChanges() הופך את זה לשאילתת INSERT אמיתית. בלי השורה הזו, Add “עובד” מבחינת הקומפיילר אבל לא עושה כלום בפועל ב-DB.
שלב 5: מוסיפים Update — מושג חדש, Change Tracking
public void Update(Student item)
{
_context.Students.Update(item);
_context.SaveChanges();
}_context.Students.Update(item) מסמן את האובייקט item כ"השתנה" אצל ה-DbContext — כך ש-SaveChanges() ידע לייצר UPDATE SQL (עם כל העמודות) במקום INSERT. זה שונה מהותית מ-InMemoryStudentRepository, ששם Update היה צריך למצוא את השורה הישנה ברשימה ולהחליף אותה ידנית — כאן DbContext עושה את ה"זיהוי מה השתנה" בשבילכם.
שלב 6: מוסיפים Delete — המתודה האחרונה
public void Delete(int id)
{
var student = GetById(id);
if (student != null)
{
_context.Students.Remove(student);
_context.SaveChanges();
}
}שימו לב: קודם שולפים את האובייקט עם GetById (המתודה שכבר כתבנו בשלב 3) — EF Core לא יכול למחוק “לפי מספר בלבד”, הוא צריך אובייקט שהוא כבר “מכיר” (עקב ה-Change Tracking). if (student != null) מונע ניסיון מחיקה של תלמיד שלא קיים בכלל. Remove + SaveChanges() — אותו דפוס בדיוק כמו שאר המתודות.
כל חמש המתודות של IRepository<Student> ממומשות — ה-SqlStudentRepository שלם.
שלב 7: רושמים אותו ב-Program.cs — שורה אחת
// before:
builder.Services.AddScoped<IRepository<Student>, InMemoryStudentRepository>();
// after — a single change:
builder.Services.AddScoped<IRepository<Student>, SqlStudentRepository>();זהו. לא שורה נוספת, לא שינוי בשום מחלקה אחרת — רק מה נרשם מול אותו IRepository<Student>. AddDbContext<SchoolDbContext>(...) מהשיעור הקודם כבר רשום ונשאר בדיוק כמו שהיה; SqlStudentRepository מקבל ממנו את ה-DbContext בדיוק כפי שכל Controller קיבל אותו קודם.
שלב 8: מחרוזת החיבור “כמו שצריך” — appsettings.json
בשיעור הקודם כתבנו את מחרוזת החיבור ישירות בתוך Program.cs, כדי להתמקד בעיקר. עכשיו, כשהכול עובד, שווה לעשות את זה נכון: זוכרים את appsettings.json מהשיעור הראשון של היחידה הקודמת (“קובץ הגדרות תצורה”)? זה בדיוק המקום שמחרוזות חיבור אמורות לחיות בו:
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost,1433;Database=SchoolDb;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
}
}ו-ב-Program.cs, במקום המחרוזת הקשיחה:
// before (previous lesson):
// var connectionString = "Server=localhost,1433;...";
// after:
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<SchoolDbContext>(options =>
options.UseSqlServer(connectionString));builder.Configuration יודע לקרוא את appsettings.json אוטומטית (זו בדיוק ההתנהגות המובנית שהוזכרה כבר בשיעור הראשון של היחידה הקודמת). GetConnectionString("DefaultConnection") הוא קיצור נוח ל-"ConnectionStrings:DefaultConnection" בתוך הקובץ. למה זה עדיף על מחרוזת קשיחה בקוד: אפשר לשים appsettings.Development.json נפרד עם DB אחר לפיתוח, ו-appsettings.json ל-production — בלי לגעת בקוד בכלל, רק בקבצי הגדרות.
שלב 9: ה-Controller חוזר להיות דק
בשיעור הקודם, StudentsController דיבר ישירות עם SchoolDbContext — זה היה מסומן במפורש כקיצור-דרך זמני. עכשיו, כשיש SqlStudentRepository תקין, חוזרים בדיוק לקוד ה-Controller המקורי מיחידת ה-DAL — בלי שום שינוי:
public class StudentsController : ControllerBase
{
private readonly StudentService _service;
public StudentsController(StudentService service) => _service = service;
[HttpGet]
public IActionResult GetAll() => Ok(_service.GetAll());
[HttpPost]
public IActionResult Create([FromBody] Student newStudent)
{
var student = _service.RegisterStudent(newStudent.Name, newStudent.Age);
return CreatedAtAction(nameof(GetAll), student);
}
}זו בדיוק המחלקה מיחידת ה-DAL, מילה במילה. StudentService עצמה גם היא לא השתנתה — היא עדיין מקבלת IRepository<Student> בקונסטרוקטור, ולא יודעת (ולא צריכה לדעת) שמאחורי הקלעים זה כבר לא List בזיכרון אלא SQL Server אמיתי.
שלב 10: מריצים ובודקים שבאמת כלום לא “הרגיש” את השינוי
dotnet runcurl -X POST http://localhost:5123/api/students -H "Content-Type: application/json" -d "{\"name\":\"Dana\",\"age\":25}"
curl http://localhost:5123/api/studentsאותן שתי קריאות בדיוק כמו בסוף השיעור הקודם, אותה תוצאה — אבל הפעם הבקשה עוברת דרך StudentsController → StudentService → SqlStudentRepository → DbContext → SQL Server, לא דרך Controller שמדבר עם ה-DB ישירות. אם עוצרים את השרת ומריצים שוב, דנה עדיין שם — בדיוק כמו קודם, רק שעכשיו זה עובר דרך כל השכבות שבנינו ביחידה הקודמת, בדיוק כמו שתוכננו.
הסבר עיקרי
זו ההוכחה המעשית ל-DIP ולתבנית ה-DAL כולה — StudentService מקבל IRepository<Student> דרך הקונסטרוקטור, ולא יודע (ולא צריך לדעת) אם המימוש בפועל הוא List בזיכרון או DbContext אמיתי. כל מה שהשתנה הוא מה נרשם ב-DI Container — שורה אחת ב-Program.cs.
SaveChanges() בכל מתודה, לא רק ב-Add — בניגוד ל-InMemoryRepository שבו Add/Update/Delete פעלו מיידית על ה-List בזיכרון, כאן כל שינוי חייב SaveChanges() מפורש כדי להיכתב בפועל ל-DB. זו נקודת ההבדל הכי משמעותית בין שני המימושים, למרות שה-Interface שלהם זהה לחלוטין.
יתרונות
מעבר ממימוש בזיכרון למסד נתונים אמיתי הוא שינוי מבודד לחלוטין — הוכחה חיה לערך של DIP+DAL; קוד ה-Service וה-Controller נשארים זהים לגמרי, כולל בדיקות שכבר נכתבו עליהם; Connection String דרך Configuration מאפשר סביבות מרובות בלי שינוי קוד.
חסרונות
SqlRepository דורש SaveChanges() מודע בכל מתודה — קל לשכוח בהשוואה למימוש InMemory ה"אוטומטי"; שגיאות DB אמיתיות (חיבור נכשל, timeout) הן מקור כשלים חדש שלא היה קיים במימוש InMemory כלל, ודורש טיפול נוסף.
נקודות חשובות
• SqlRepository מממש את אותו IRepository<T> כמו InMemoryRepository — אותו חוזה, מימוש שונה
• השינוי היחיד הנדרש במעבר הוא רישום ה-DI ב-Program.cs, לא בכל קוד קורא
• כל מתודה ב-SqlRepository שמשנה נתונים חייבת SaveChanges() משלה; Update/Remove מסתמכים על Change Tracking
• Connection String נטען מ-appsettings.json/Configuration, לא מקושח בקוד
טעויות נפוצות
• לשכוח SaveChanges() באחת המתודות (למשל Delete) ולתהות למה השינוי “לא נשמר”
• לקושש Connection String ישירות בקוד במקום Configuration — קשה להחליף בין סביבות (ראו שלב 8)
• לשנות בטעות את חתימת IRepository<T> כדי “להתאים” ל-EF Core — מאבד את כל התועלת של ההפשטה המקורית
סיכום
SqlRepository הוא מימוש חדש של אותו IRepository<T> שכבר הוגדר ביחידת ASP.NET Core, הפעם עם EF Core (DbContext/DbSet) במקום List בזיכרון. בנינו אותו מתודה-מתודה (GetAll → GetById → Add → Update → Delete), רשמנו אותו ב-Program.cs בשורה אחת, העברנו את מחרוזת החיבור ל-appsettings.json, והחזרנו את ה-Controller לגרסתו הדקה המקורית — בלי לגעת בשכבות ה-Service וה-Controller כלל. זו ההוכחה המעשית והמוחשית ביותר לערך של הפרדת שכבות שנלמדה קודם.
דוקומנטציה רשמית
Microsoft Learn — Implementing the repository pattern with EF Core
Microsoft Learn — Configuration in ASP.NET Core
תרגילים
תרגיל 1 — SqlRepository ראשון
המשימה: ממשו SqlProductRepository : IRepository<Product> מתרגיל היחידה הקודמת, עם DbContext אמיתי במקום List — מתודה אחר מתודה, כמו בשלבים 2-6 למעלה.
בדיקה: כל פעולות ה-CRUD עובדות זהה מבחינת ה-Controller/Service, אך הנתונים נשמרים בפועל ב-DB (נשארים גם אחרי restart של האפליקציה).
תרגיל 2 — החלפת רישום DI
המשימה: קחו פרויקט קיים עם InMemoryProductRepository רשום ב-DI, והחליפו לרישום SqlProductRepository — שינוי בשורה אחת בלבד.
בדיקה: כל שאר הקוד (Service, Controller) ממשיך לעבוד בלי שום שינוי נוסף.
פרויקט מסכם
המשימה: השלימו את המעבר המלא של TasksController/TaskService (מהיחידה הקודמת) ל-EF Core אמיתי.
דרישות:
TaskDbContextעםDbSet<TaskItem>, ו-Migration ראשונהSqlTaskRepository : IRepository<TaskItem>עם מימוש מלא (כל מתודה עםSaveChanges()משלה)- עדכון
Program.csבלבד (כולל מעבר ל-appsettings.json) —TaskServiceו-TasksControllerהקיימים לא נוגעים
בדיקה: כל ה-endpoints הקיימים (CRUD מלא) ממשיכים לעבוד זהה מבחוץ; יצירת משימה ואז restart של האפליקציה + GET מראה שהמשימה עדיין קיימת — הוכחה שהיא נשמרה ב-DB אמיתי, לא רק בזיכרון.
מה בפרק הבא
בפרק הבא — Queries מתקדמות: LINQ על DbSet, ההבדל בין raw SQL ל-LINQ, Include לטעינת קשרים (Student.Grades מהשיעור על DbContext), ובעיית ה-N+1 — מלכודת ביצועים נפוצה שחייבים להכיר.