API프로젝트
개요
- 웹, 앱에서 사용하는 API 입니다.
- MVC구조로 되어있습니다. API이기때문에 View는 없습니다.
- 모든 출력 데이터포맷은 JSON 입니다.
- Restful에 맞춰 구현되었습니다. (PUT, GET, POST, DELETE)
구조
1) 프레임워크
ASP.NET Core MVC3 + Entity Framework Core3
- 참고자료
- https://docs.microsoft.com/ko-kr/aspnet/core/tutorials/first-mvc-app/?view=aspnetcore-3.1
- https://docs.microsoft.com/ko-kr/ef/core/
2) 주요서드파티
- Serilog : Logger (포맷,롤링파일)
- Quartz : 스케쥴러(배치작업)
- SelectPdf : PDF to Image (상용)
- ActiveReports : 레포트출력 (상용)
폴더 구조 설명
- .vscode
- vscode 설정파일 (기본설정)
- Controllers
- MVC컨트롤러
- Services
- MVC서비스
- Models
- 엔티티외에 일반 데이터모델 (DO,VO)
- Entities
- DB엔티티(EF)
- Data
- EF Context(ORM 관계설정)
- Helpers
- 각종 헬퍼 클래스
- Modules
- 추가 구현 시스템 클래스 (Exception, Filter)
- Repositories
- MVC서비스에서 사용되는 레파지터리
- QuartzSchedule
- 스케쥴러 Job들 (배치작업 작업, 날씨등)
- Reports
- AR10 레포트파일
- ServerSetting
- IIS세팅 예제들
- wwwroot
- 정적컨텐츠 저장소
- wwwroot/files
- 기본 스토리지 폴더, 업로드되는 파일
설정 파일
appsettings.Development.json
{
// 프로그램 설정
"AppSettings": {
// JWT 시크릿키
"Secret": "THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING",
// 스토리지 내부 경로
"StoragePath": "d:/react/fms-admin-backend",
// 스토리지 URL
"StorageUrl": "http://172.17.50.15:9090",
// API사이트 URL
"SiteUrl": "http://172.17.50.15:9090",
// 스케쥴러 사용유무
"Scheduler": "off",
// 기상청 API키 (공공 데이터포탈)
"WeatherApiKey": "%2Fb1uV%2FBf0ckaHJhP5IMVIBkxVSAB%2F9aixZVSgj0%2BJkpvFZMXUDDD4B16uEFdeM1wnm4iFGc97ghjWZ9oBwsTSQ%3D%3D",
// 대기공기 API키 (공공 데이터포탈)
"DustApiKey": "L2tohxvq9Ro8DU%2BaVOGpY9%2B0EbVxL4HRHCwEAeiUYTRUNegytTdOFPN2W%2FxpTo0%2BeEQE%2FGY85zp5LTEe6n6g0A%3D%3D"
},
// Seilog 로거 설정 (Seilog 도큐먼트 참고)
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore.Hosting.Diagnostics": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"WriteTo": [
{
// 콘설 로거
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u4}] {SourceContext}{MemberName} {Message}{NewLine}{Exception}"
}
},
{
// 파일 로거
"Name": "File",
"Args": {
"path": "Logs/fms.log",
"outputTemplate": "[{Timestamp:HH:mm:ss.fff} {Level:u4}] {SourceContext}{MemberName} {Message}{NewLine}{Exception}",
"rollingInterval": "Day"
}
}
]
},
// CORS
"AllowedHosts": "*",
// DB컨넥션 스트링
"ConnectionStrings": {
"Server": "SqlServer",
"SqliteContext": "Data Source=FMSAdmin.db",
"SqlServerContext": "data source=15.165.55.238,11433;initial catalog=iBems_HD;persist security info=True;user id=sa;password=icontrols123.,"
},
// 테스트시 사용하는 Kestrel 설정 (9090포트)
"Kestrel": {
"EndPoints": {
"Http": {
"Url": "http://0.0.0.0:9090"
}
}
}
}
프로그램 Startup
기본 MVC Startup과 거의 동일합니다. 몇몇 추가되는 모듈이 존재합니다. 주석으로 표시해 두었습니다.
- Startup.cs
// Job Scheduler 추가
bool runScheduler = false;
if (appSettings.Scheduler != "off") { // 설정으로 실행 여부 관리
runScheduler = true;
}
if (runScheduler) {
LogProvider.SetCurrentLogProvider(new QuartzLogProvider());
foreach (var type in Assembly.GetExecutingAssembly().GetTypes().Where(x => x.Namespace == "FMSAdmin.QuartzSchedule.Jobs")) {
services.AddScoped(type);
}
foreach (var type in Assembly.GetExecutingAssembly().GetTypes().Where(x => x.Namespace == "FMSAdmin.QuartzSchedule.Schedulers")) {
services.AddScoped(type);
}
services.AddSingleton<QuartzJobRunner>();
services.AddSingleton<QuartzJobFactory>();
services.AddScoped<QuartzSchedule.Startup>();
services.AddScoped<QuartzSchedulerListener>();
var scheduler = StdSchedulerFactory.GetDefaultScheduler().GetAwaiter().GetResult();
services.AddSingleton(scheduler);
services.AddHostedService<QuartzHostedService>();
}
// AR 추가
app.UseReporting(settings => {
settings.UseCompression = true;
settings.UseFileStore(new DirectoryInfo("Reports")); // Reports 디렉토리
});
Restful API
HTTP 메소드로 기본적인 추가,수정,삭제,조회를 파악할 수 있습니다.
- MaterialController.cs 컨트롤러 (FmsMaterial)
PUT /api/material 추가
GET /api/material 조회(목록배열)
GET /api/material/{siteId}/{id} 조회
POST /api/material/{siteId}/{id} 수정
DELETE /api/material/{siteId}/{id} 삭제
Entity Framework Core (ORM)
EF ORM으로 매핑되어 있습니다. PK, FK, 관계, 타입, 논리이름등을 클래스 단위에서 파악할 수 있습니다.
- CmPosition.cs (DB엔티티)
public partial class CmPosition {
public CmPosition() {
CmUser = new HashSet<CmUser>();
}
// PK
[Display(Name = "직급 코드"), Key]
public int PositionId { get; set; }
[Display(Name = "직급 코드 명"), Required]
[StringLength(40)]
public string Name { get; set; }
[Display(Name = "순서")]
public int Position { get; set; }
[Display(Name = "사용 유무")]
public bool? IsUse { get; set; }
// HasMany (1-N 관계)
[InverseProperty("CmPosition")]
public virtual ICollection<CmUser> CmUser { get; set; }
}
JSON 포맷
1) API의 모든 결과는 JSON
으로 출력됩니다. 웹, 앱 모두 JS(React)기반이므로 쉽게 이용할 수 있습니다.
GET /api/Equipment?limit=5&siteId=115&sort.field=EquipmentId&sort.order=desc
HTTP/1.1 200 OK
Connection: close
Date: Wed, 12 Aug 2020 05:29:56 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 2222
{
"page": 1,
"total": 219,
"limit": 5,
"pages": 44,
"list": [
{
"EquipmentId": 3909,
"SiteId": 115,
"SiteName": "부평",
"EquipmentTypeId": 2,
"EquipmentTypeName": "전동공구",
"Name": "휴대용청소기",
"Standard": "10.8V",
"Unit": "EA",
"StoredCount": 1,
"AddDate": "2020-07-24T14:39:20.543",
"SupplierName": "BLACK&DECKER",
"SupplierPhoneNo": "",
"WarehouseId": 170,
"WarehouseName": "지하1방재실-4",
"CurrentStockCount": 0,
"TotalStockCount": 1,
"RentCount": 1,
"LossCount": 0,
"UpdateDate": "2020-07-24T14:41:26.977"
},
{
"EquipmentId": 2322,
"SiteId": 115,
"SiteName": "부평",
"EquipmentTypeId": 7,
"EquipmentTypeName": "수공구",
"Name": "파이프렌치",
"Standard": "12\"",
"Unit": "EA",
"StoredCount": 2,
"AddDate": "2020-06-10T17:26:21.257",
"SupplierName": "세신버팔로",
"SupplierPhoneNo": "",
"WarehouseId": 39,
"WarehouseName": "지하7자재창고-1",
"CurrentStockCount": 0,
"TotalStockCount": 2,
"RentCount": 2,
"LossCount": 0,
"UpdateDate": "2020-06-14T18:58:06.65"
},
...
]
}
2) API의 모든 입력 FORM데이터는 JSON
으로 입력받습니다.
POST /api/users/login
content-type: application/json
{
"siteId": 115,
"userId": "icontrols115",
"passwd": "비밀번호"
}
ASP.NET Core MVC 에서 쉽게 바인딩해어 사용할 수 있습니다.
- UserController.cs
[AllowAnonymous]
[HttpPost("[action]")]
public IActionResult Login([FromBody] LoginModel model) {
// model.SiteId
// model.UserId
// model.Passwd
...
}
- LoginModel.cs
public class LoginModel {
public LoginModelType Type { get; set; }
public string SiteId { get; set; }
public string UserId { get; set; }
public string Passwd { get; set; }
}
주요 Helper
- ExcelUploadHepler.cs
- 엑셀업로드 헬퍼, 업로드시 사용되는 함수들입니다. (데이터 검증등)
- ExtensionMethods.cs
- 유용한 확장메소드, 조건식 Filter, 멀티 정렬, 엑셀스타일등 유용한 확장함수가 있습니다.
- LunarSolarConverter.cs
- 음력날짜 변환
- QrCodeHelper.cs
- QR코드 생성 헬퍼
- StorageHelper.cs
- 스토리지 헬퍼, 업로드 파일에 관련된 헬퍼입니다. 추후에 스토리지 서버가 분리될 경우에 이부분을 수정하면 쉽게 변경할 수 있습니다.
공통 그리드 조회(목록조회)
1) 요청 데이터 PagingRequest
구조
PagingRequest.cs
public class PagingRequest {
// 페이지
public int page { get; set; } = 1;
// 한페이지 게시물수
public int limit { get; set; } = 20;
// 정렬
public Sort sort { get; set; }
// 검색
public Condition[] conditions { get; set; }
// 엑셀다운로드 헤더매핑용
public Column[] columns { get; set; }
// 사이트ID (본사권한)
public int? siteId { get; set; }
// 사이트ID 여러개 (본사권한)
public int[] siteIds { get; set; }
// 업무분야ID (업무권한)
public int? businessFieldId { get; set; }
// 정렬 클래스
public class Sort {
public string field { get; set; }
public string order { get; set; }
}
// 검색 조건 클래스
public class Condition {
// 엔티티 필드명
public string field { get; set; }
// OP (조건식, =, !=, LIKE, <, >, IN 등)
public string op { get; set; }
// 값
public string value { get; set; }
}
// 엑셀용 컬럼설정 클래스
public class Column {
// 엔티티 필드명
public string field { get; set; }
// 한글명
public string name { get; set; }
// 텍스트정렬(left, center, right)
public string align { get; set; }
// 포맷(price)
public string format { get; set; }
}
}
조건식 OP 종류
eq: =
ne: !=
cn: LIKE %값%
sw: LIKE 값%
ew: LIKE %값
gt: >
lt: <
ge: >=
le: <=
in: IN (값1,값2,값3)
2) 리스트 액션 구조
GET /api/Material 으로 호출
[HttpGet] //Get 메소드
public IActionResult List([FromQuery] PagingRequest req) {
// 검색 & 정렬
var query = _FilterAndSort(req);
// 리스트 셀렉트 컬럼
var list = _ListSelect(query);
// 페이징 적용
var paging = PagingList.Pagination(list, req.page, req.limit);
return Ok(paging);
}
- _FilterAndSort 검색 & 정렬
// 서비스에서 query를 가져옴 (linq표현식)
var query = _service.Stock();
// 기본 Entity 검색 (Filter 조건식 OP에 맞게 자동으로 검색)
query = query.Filter(req.conditions);
if (req.siteId != null) {
query = query.Where(x => x.Material.SiteId == Util.ToInt(req.siteId));
}
if (req.siteIds != null && req.siteIds.Length > 0) {
query = query.Where(x => req.siteIds.Contains(x.Material.SiteId));
}
// 업무분야 필드가 있는경우만 추가
if (req.businessFieldId != null) {
query = query.Where(x => x.Material.SiteId == req.siteId);
query = query.Where(x => x.Material.BusinessFieldId == req.businessFieldId || x.Material.CmBusinessField.Name == "공통");
}
// 고정정렬 (사옥 준공순)
var sortQuery = query.OrderBy(x => x.Material.CmSite.SortOrderNo);
// 추가 정렬
if (req.sort.field == "BusinessAndClass") { // 기본 커스텀 정렬 (업무분야,대중소,명칭)
sortQuery = sortQuery.ThenBy(x => x.Material.BusinessFieldId);
sortQuery = sortQuery.ThenBy(x => x.Material.FmsMaterialCodeClass.Name);
sortQuery = sortQuery.ThenBy(x => x.Material.FmsMaterialCodeClass1.Name);
sortQuery = sortQuery.ThenBy(x => x.Material.FmsMaterialCodeClass2.Name);
sortQuery = sortQuery.ThenBy(x => x.Material.Name);
} else {
// 그외 일반 요청 컬럼 정렬
sortQuery = sortQuery.ThenSort(req.sort);
}
- _ListSelect 컬럼 선택
가져올 데이터를 정의해줍니다. 이때 데이터 구조는 클라이언트에서 표현하기 쉽게 Depth 1단계로 펼쳐줘야합니다.
var list = query.Select(x => new {
MaterialId = x.Material.MaterialId,
SiteId = x.Material.SiteId,
SiteName = x.Material.CmSite.Name,
BusinessFieldId = x.Material.BusinessFieldId,
BusinessFieldName = x.Material.CmBusinessField.Name,
FirstClassId = x.Material.FirstClassId,
FirstClassName = x.Material.FmsMaterialCodeClass.Name,
SecondClassId = x.Material.SecondClassId,
SecondClassName = x.Material.FmsMaterialCodeClass1.Name,
ThirdClassId = x.Material.ThirdClassId,
ThirdClassName = x.Material.FmsMaterialCodeClass2.Name,
Name = x.Material.Name,
Standard = x.Material.Standard,
Unit = x.Material.Unit,
FinalPrice = x.Material.FinalPrice,
ReasonableStockCount = x.Material.ReasonableStockCount,
QrCode = x.Material.Rfid,
DurableYears = x.Material.DurableYears,
Manufacturer = x.Material.Manufacturer,
Note = x.Material.Note,
IsDiscontinued = x.Material.IsDiscontinued.GetValueOrDefault() ? "Y" : "N",
IsUse = x.Material.IsUse ? "Y" : "N",
MaterialCode = x.Material.MaterialCode,
MaterialCount = 1, //자재팝업에서 기본값
x.StockCount,
x.StockAmount
});
- PagingList.Pagination 페이징 적용
// 해당 페이지와 페이지표시갯수가 자동으로 계산됨
var paging = PagingList.Pagination(list, req.page, req.limit);
- 페이징 결과값 구조
public class PagingList {
// 요청 페이지
public int page { get; set; }
// 총 갯수
public int total { get; set; }
// 페이지당 표시갯수
public int limit { get; set; }
// 총 페이지수
public int pages { get; set; }
// 데이터 배열
public IEnumerable list { get; set; }
}
- 페이징 결과값을 Ok()로 출력
JSON
으로 자동으로 변환되서 출력됩니다.
{
"page": 1,
"total": 219,
"limit": 5,
"pages": 44,
"list": [
{
"EquipmentId": 3909,
"SiteId": 115,
"SiteName": "부평",
"EquipmentTypeId": 2,
"EquipmentTypeName": "전동공구",
"Name": "휴대용청소기",
"Standard": "10.8V",
"Unit": "EA",
"StoredCount": 1,
"AddDate": "2020-07-24T14:39:20.543",
"SupplierName": "BLACK&DECKER",
"SupplierPhoneNo": "",
"WarehouseId": 170,
"WarehouseName": "지하1방재실-4",
"CurrentStockCount": 0,
"TotalStockCount": 1,
"RentCount": 1,
"LossCount": 0,
"UpdateDate": "2020-07-24T14:41:26.977"
},
{
"EquipmentId": 2322,
"SiteId": 115,
"SiteName": "부평",
"EquipmentTypeId": 7,
"EquipmentTypeName": "수공구",
"Name": "파이프렌치",
"Standard": "12\"",
"Unit": "EA",
"StoredCount": 2,
"AddDate": "2020-06-10T17:26:21.257",
"SupplierName": "세신버팔로",
"SupplierPhoneNo": "",
"WarehouseId": 39,
"WarehouseName": "지하7자재창고-1",
"CurrentStockCount": 0,
"TotalStockCount": 2,
"RentCount": 2,
"LossCount": 0,
"UpdateDate": "2020-06-14T18:58:06.65"
},
...
]
}
서비스 쿼리
Entity Framework Core를 사용하고 있습니다. LINQ 쿼리식, 메소드식을 이용하여 데이터를 가져오고 있습니다.
MaterialService.cs
// 자재와 재고수를 가져오는 쿼리식
public IQueryable<MaterialStock> Stock() {
var query = from x in _context.FmsMaterial.AsExpandable()
select new MaterialStock {
Material = x,
StockCount = FmsMaterial.GetStockCount.Invoke(x),
StockAmount = FmsMaterial.GetStockAmount.Invoke(x),
};
return query;
}
// 재고 표현식을 묶어둠
public static Expression<Func<FmsMaterial, long>> GetStockCount =
(x) => x.FmsMaterialStored.Where(s => s.IsApproval == true).OrderByDescending(s => s.MaterialStoredId).FirstOrDefault().StockCount
+ (x.FmsMaterialRelease.Where(r => r.IsConfirmed == true && r.AdjustmentTypeId == 2).Sum(r => (long?)r.MaterialCount) ?? 0)
- (x.FmsMaterialRelease.Where(r => r.IsConfirmed == true && r.AdjustmentTypeId == 1).Sum(r => (long?)r.MaterialCount) ?? 0)
- (x.FmsMaterialRelease.Where(r => r.IsConfirmed == true && r.AdjustmentTypeId == 0).Sum(r => (long?)r.MaterialCount) ?? 0);
public static Expression<Func<FmsMaterial, long>> GetStockAmount =
(x) => (x.FmsMaterialStored.Where(s => s.IsApproval == true).Sum(s => (long?)(s.StoredCount * s.UnitCost)) ?? 0)
+ (x.FmsMaterialRelease.Where(r => r.IsConfirmed == true && r.AdjustmentTypeId == 2).Sum(r => (long?)r.ReleaseTotalCost) ?? 0)
- (x.FmsMaterialRelease.Where(r => r.IsConfirmed == true && r.AdjustmentTypeId == 1).Sum(r => (long?)r.ReleaseTotalCost) ?? 0)
- (x.FmsMaterialRelease.Where(r => r.IsConfirmed == true && r.AdjustmentTypeId == 0).Sum(r => (long?)r.ReleaseTotalCost) ?? 0);