개발소스 이관

2020년 8월 14일 금요일

API프로젝트

개요

  • 웹, 앱에서 사용하는 API 입니다.
  • MVC구조로 되어있습니다. API이기때문에 View는 없습니다.
  • 모든 출력 데이터포맷은 JSON 입니다.
  • Restful에 맞춰 구현되었습니다. (PUT, GET, POST, DELETE)

구조

  1. 프레임워크

ASP.NET Core MVC3 + Entity Framework Core3

  1. 주요서드파티
  • 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"
    },
    ...
  ]
}
  1. 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);